diff --git a/src/api/mod.rs b/src/api/mod.rs index bbc6b89..366921a 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -15,6 +15,7 @@ pub mod licence; pub mod notification; pub mod priority; pub mod project; +pub mod pull_request; pub mod rate_limit; pub mod resolution; pub mod shared_file; @@ -41,6 +42,10 @@ use project::{ ProjectStatus, ProjectUser, ProjectVersion, ProjectWebhook, UpdateProjectVersionParams, UpdateProjectWebhookParams, }; +use pull_request::{ + PullRequest, PullRequestAttachment, PullRequestComment, PullRequestCommentCount, + PullRequestCount, +}; use rate_limit::RateLimit; use resolution::Resolution; use shared_file::SharedFile; @@ -624,6 +629,109 @@ pub trait BacklogApi { ) -> Result { unimplemented!() } + fn get_pull_requests( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _params: &[(String, String)], + ) -> Result> { + unimplemented!() + } + fn count_pull_requests( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _params: &[(String, String)], + ) -> Result { + unimplemented!() + } + fn get_pull_request( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _number: u64, + ) -> Result { + unimplemented!() + } + fn create_pull_request( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _params: &[(String, String)], + ) -> Result { + unimplemented!() + } + fn update_pull_request( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _number: u64, + _params: &[(String, String)], + ) -> Result { + unimplemented!() + } + fn get_pull_request_comments( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _number: u64, + _params: &[(String, String)], + ) -> Result> { + unimplemented!() + } + fn count_pull_request_comments( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _number: u64, + ) -> Result { + unimplemented!() + } + fn add_pull_request_comment( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _number: u64, + _params: &[(String, String)], + ) -> Result { + unimplemented!() + } + fn update_pull_request_comment( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _number: u64, + _comment_id: u64, + _params: &[(String, String)], + ) -> Result { + unimplemented!() + } + fn get_pull_request_attachments( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _number: u64, + ) -> Result> { + unimplemented!() + } + fn download_pull_request_attachment( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _number: u64, + _attachment_id: u64, + ) -> Result<(Vec, String)> { + unimplemented!() + } + fn delete_pull_request_attachment( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _number: u64, + _attachment_id: u64, + ) -> Result { + unimplemented!() + } } impl BacklogApi for BacklogClient { @@ -1295,6 +1403,125 @@ impl BacklogApi for BacklogClient { ) -> Result { self.get_git_repository(project_id_or_key, repo_id_or_name) } + fn get_pull_requests( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + params: &[(String, String)], + ) -> Result> { + self.get_pull_requests(project_id_or_key, repo_id_or_name, params) + } + fn count_pull_requests( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + params: &[(String, String)], + ) -> Result { + self.count_pull_requests(project_id_or_key, repo_id_or_name, params) + } + fn get_pull_request( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + number: u64, + ) -> Result { + self.get_pull_request(project_id_or_key, repo_id_or_name, number) + } + fn create_pull_request( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + params: &[(String, String)], + ) -> Result { + self.create_pull_request(project_id_or_key, repo_id_or_name, params) + } + fn update_pull_request( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + number: u64, + params: &[(String, String)], + ) -> Result { + self.update_pull_request(project_id_or_key, repo_id_or_name, number, params) + } + fn get_pull_request_comments( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + number: u64, + params: &[(String, String)], + ) -> Result> { + self.get_pull_request_comments(project_id_or_key, repo_id_or_name, number, params) + } + fn count_pull_request_comments( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + number: u64, + ) -> Result { + self.count_pull_request_comments(project_id_or_key, repo_id_or_name, number) + } + fn add_pull_request_comment( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + number: u64, + params: &[(String, String)], + ) -> Result { + self.add_pull_request_comment(project_id_or_key, repo_id_or_name, number, params) + } + fn update_pull_request_comment( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + number: u64, + comment_id: u64, + params: &[(String, String)], + ) -> Result { + self.update_pull_request_comment( + project_id_or_key, + repo_id_or_name, + number, + comment_id, + params, + ) + } + fn get_pull_request_attachments( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + number: u64, + ) -> Result> { + self.get_pull_request_attachments(project_id_or_key, repo_id_or_name, number) + } + fn download_pull_request_attachment( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + number: u64, + attachment_id: u64, + ) -> Result<(Vec, String)> { + self.download_pull_request_attachment( + project_id_or_key, + repo_id_or_name, + number, + attachment_id, + ) + } + fn delete_pull_request_attachment( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + number: u64, + attachment_id: u64, + ) -> Result { + self.delete_pull_request_attachment( + project_id_or_key, + repo_id_or_name, + number, + attachment_id, + ) + } } /// How the client authenticates with Backlog. diff --git a/src/api/pull_request.rs b/src/api/pull_request.rs new file mode 100644 index 0000000..fc0b564 --- /dev/null +++ b/src/api/pull_request.rs @@ -0,0 +1,574 @@ +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 PrUser { + 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 PullRequestStatus { + pub id: u64, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrLinkedIssue { + pub id: u64, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PullRequestAttachment { + pub id: u64, + pub name: String, + pub size: u64, + pub created_user: PrUser, + pub created: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PullRequestStar { + pub id: u64, + pub comment: Option, + pub url: String, + pub title: String, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrChangeLog { + pub field: String, + pub new_value: Option, + pub original_value: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PullRequestComment { + pub id: u64, + pub content: Option, + pub change_log: Vec, + pub created_user: PrUser, + pub created: String, + pub updated: String, + pub stars: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PullRequestCount { + pub count: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PullRequestCommentCount { + pub count: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PullRequest { + pub id: u64, + pub project_id: u64, + pub repository_id: u64, + pub number: u64, + pub summary: String, + pub description: String, + pub base: String, + pub branch: String, + pub status: PullRequestStatus, + pub assignee: Option, + pub issue: Option, + pub base_commit: Option, + pub branch_commit: Option, + pub merge_commit: Option, + pub close_at: Option, + pub merge_at: Option, + pub created_user: PrUser, + pub created: String, + pub updated_user: PrUser, + pub updated: String, + pub attachments: Vec, + pub stars: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl BacklogClient { + pub fn get_pull_requests( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + params: &[(String, String)], + ) -> Result> { + let value = self.get_with_query( + &format!( + "/projects/{}/git/repositories/{}/pullRequests", + project_id_or_key, repo_id_or_name + ), + params, + )?; + deserialize(value) + } + + pub fn count_pull_requests( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + params: &[(String, String)], + ) -> Result { + let value = self.get_with_query( + &format!( + "/projects/{}/git/repositories/{}/pullRequests/count", + project_id_or_key, repo_id_or_name + ), + params, + )?; + deserialize(value) + } + + pub fn get_pull_request( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + number: u64, + ) -> Result { + let value = self.get(&format!( + "/projects/{}/git/repositories/{}/pullRequests/{}", + project_id_or_key, repo_id_or_name, number + ))?; + deserialize(value) + } + + pub fn create_pull_request( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + params: &[(String, String)], + ) -> Result { + let value = self.post_form( + &format!( + "/projects/{}/git/repositories/{}/pullRequests", + project_id_or_key, repo_id_or_name + ), + params, + )?; + deserialize(value) + } + + pub fn update_pull_request( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + number: u64, + params: &[(String, String)], + ) -> Result { + let value = self.patch_form( + &format!( + "/projects/{}/git/repositories/{}/pullRequests/{}", + project_id_or_key, repo_id_or_name, number + ), + params, + )?; + deserialize(value) + } + + pub fn get_pull_request_comments( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + number: u64, + params: &[(String, String)], + ) -> Result> { + let value = self.get_with_query( + &format!( + "/projects/{}/git/repositories/{}/pullRequests/{}/comments", + project_id_or_key, repo_id_or_name, number + ), + params, + )?; + deserialize(value) + } + + pub fn count_pull_request_comments( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + number: u64, + ) -> Result { + let value = self.get(&format!( + "/projects/{}/git/repositories/{}/pullRequests/{}/comments/count", + project_id_or_key, repo_id_or_name, number + ))?; + deserialize(value) + } + + pub fn add_pull_request_comment( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + number: u64, + params: &[(String, String)], + ) -> Result { + let value = self.post_form( + &format!( + "/projects/{}/git/repositories/{}/pullRequests/{}/comments", + project_id_or_key, repo_id_or_name, number + ), + params, + )?; + deserialize(value) + } + + pub fn update_pull_request_comment( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + number: u64, + comment_id: u64, + params: &[(String, String)], + ) -> Result { + let value = self.patch_form( + &format!( + "/projects/{}/git/repositories/{}/pullRequests/{}/comments/{}", + project_id_or_key, repo_id_or_name, number, comment_id + ), + params, + )?; + deserialize(value) + } + + pub fn get_pull_request_attachments( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + number: u64, + ) -> Result> { + let value = self.get(&format!( + "/projects/{}/git/repositories/{}/pullRequests/{}/attachments", + project_id_or_key, repo_id_or_name, number + ))?; + deserialize(value) + } + + pub fn download_pull_request_attachment( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + number: u64, + attachment_id: u64, + ) -> Result<(Vec, String)> { + self.download(&format!( + "/projects/{}/git/repositories/{}/pullRequests/{}/attachments/{}", + project_id_or_key, repo_id_or_name, number, attachment_id + )) + } + + pub fn delete_pull_request_attachment( + &self, + project_id_or_key: &str, + repo_id_or_name: &str, + number: u64, + attachment_id: u64, + ) -> Result { + let value = self.delete_req(&format!( + "/projects/{}/git/repositories/{}/pullRequests/{}/attachments/{}", + project_id_or_key, repo_id_or_name, number, attachment_id + ))?; + deserialize(value) + } +} + +#[cfg(test)] +mod tests { + use httpmock::prelude::*; + use serde_json::json; + + const TEST_KEY: &str = "test-api-key"; + + fn pr_user_json() -> serde_json::Value { + json!({ + "id": 1, + "userId": "john", + "name": "John Doe", + "roleType": 1, + "lang": "ja", + "mailAddress": "john@example.com" + }) + } + + fn pr_json() -> serde_json::Value { + json!({ + "id": 1, + "projectId": 10, + "repositoryId": 2, + "number": 1, + "summary": "Fix bug", + "description": "Fixes the bug", + "base": "main", + "branch": "feature/fix", + "status": {"id": 1, "name": "Open"}, + "assignee": null, + "issue": null, + "baseCommit": null, + "branchCommit": null, + "mergeCommit": null, + "closeAt": null, + "mergeAt": null, + "createdUser": pr_user_json(), + "created": "2024-01-01T00:00:00Z", + "updatedUser": pr_user_json(), + "updated": "2024-01-01T00:00:00Z", + "attachments": [], + "stars": [] + }) + } + + fn pr_comment_json() -> serde_json::Value { + json!({ + "id": 1, + "content": "LGTM", + "changeLog": [], + "createdUser": pr_user_json(), + "created": "2024-01-01T00:00:00Z", + "updated": "2024-01-01T00:00:00Z", + "stars": [], + "notifications": [] + }) + } + + fn pr_attachment_json() -> serde_json::Value { + json!({ + "id": 1, + "name": "screenshot.png", + "size": 1024, + "createdUser": pr_user_json(), + "created": "2024-01-01T00:00:00Z" + }) + } + + fn client(server: &MockServer) -> super::super::BacklogClient { + super::super::BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap() + } + + #[test] + fn get_pull_requests_returns_list() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/projects/TEST/git/repositories/main/pullRequests") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(json!([pr_json()])); + }); + let prs = client(&server) + .get_pull_requests("TEST", "main", &[]) + .unwrap(); + assert_eq!(prs.len(), 1); + assert_eq!(prs[0].summary, "Fix bug"); + assert!(prs[0].assignee.is_none()); + } + + #[test] + fn count_pull_requests_returns_count() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/projects/TEST/git/repositories/main/pullRequests/count") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(json!({"count": 5})); + }); + let count = client(&server) + .count_pull_requests("TEST", "main", &[]) + .unwrap(); + assert_eq!(count.count, 5); + } + + #[test] + fn get_pull_request_returns_single() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/projects/TEST/git/repositories/main/pullRequests/1") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(pr_json()); + }); + let pr = client(&server).get_pull_request("TEST", "main", 1).unwrap(); + assert_eq!(pr.number, 1); + assert_eq!(pr.base, "main"); + } + + #[test] + fn get_pull_request_comments_returns_list() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/projects/TEST/git/repositories/main/pullRequests/1/comments") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(json!([pr_comment_json()])); + }); + let comments = client(&server) + .get_pull_request_comments("TEST", "main", 1, &[]) + .unwrap(); + assert_eq!(comments.len(), 1); + assert_eq!(comments[0].content.as_deref(), Some("LGTM")); + } + + #[test] + fn count_pull_request_comments_returns_count() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/projects/TEST/git/repositories/main/pullRequests/1/comments/count") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(json!({"count": 3})); + }); + let count = client(&server) + .count_pull_request_comments("TEST", "main", 1) + .unwrap(); + assert_eq!(count.count, 3); + } + + #[test] + fn get_pull_request_attachments_returns_list() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/projects/TEST/git/repositories/main/pullRequests/1/attachments") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(json!([pr_attachment_json()])); + }); + let attachments = client(&server) + .get_pull_request_attachments("TEST", "main", 1) + .unwrap(); + assert_eq!(attachments.len(), 1); + assert_eq!(attachments[0].name, "screenshot.png"); + } + + #[test] + fn create_pull_request_returns_pr() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST) + .path("/projects/TEST/git/repositories/main/pullRequests") + .query_param("apiKey", TEST_KEY); + then.status(201).json_body(pr_json()); + }); + let pr = client(&server) + .create_pull_request("TEST", "main", &[]) + .unwrap(); + assert_eq!(pr.number, 1); + assert_eq!(pr.summary, "Fix bug"); + } + + #[test] + fn update_pull_request_returns_pr() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(PATCH) + .path("/projects/TEST/git/repositories/main/pullRequests/1") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(pr_json()); + }); + let pr = client(&server) + .update_pull_request("TEST", "main", 1, &[]) + .unwrap(); + assert_eq!(pr.number, 1); + } + + #[test] + fn add_pull_request_comment_returns_comment() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST) + .path("/projects/TEST/git/repositories/main/pullRequests/1/comments") + .query_param("apiKey", TEST_KEY); + then.status(201).json_body(pr_comment_json()); + }); + let comment = client(&server) + .add_pull_request_comment("TEST", "main", 1, &[]) + .unwrap(); + assert_eq!(comment.content.as_deref(), Some("LGTM")); + } + + #[test] + fn update_pull_request_comment_returns_comment() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(PATCH) + .path("/projects/TEST/git/repositories/main/pullRequests/1/comments/1") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(pr_comment_json()); + }); + let comment = client(&server) + .update_pull_request_comment("TEST", "main", 1, 1, &[]) + .unwrap(); + assert_eq!(comment.id, 1); + } + + #[test] + fn download_pull_request_attachment_returns_bytes() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/projects/TEST/git/repositories/main/pullRequests/1/attachments/1"); + then.status(200) + .header( + "Content-Disposition", + "attachment; filename=\"screenshot.png\"", + ) + .body(b"hello"); + }); + let (bytes, filename) = client(&server) + .download_pull_request_attachment("TEST", "main", 1, 1) + .unwrap(); + assert_eq!(bytes, b"hello"); + assert_eq!(filename, "screenshot.png"); + } + + #[test] + fn delete_pull_request_attachment_returns_attachment() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(DELETE) + .path("/projects/TEST/git/repositories/main/pullRequests/1/attachments/1") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(pr_attachment_json()); + }); + let attachment = client(&server) + .delete_pull_request_attachment("TEST", "main", 1, 1) + .unwrap(); + assert_eq!(attachment.name, "screenshot.png"); + } +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index b9cb538..c9b50ff 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -4,6 +4,7 @@ pub mod document; pub mod git; pub mod issue; pub mod notification; +pub mod pr; pub mod priority; pub mod project; pub mod rate_limit; diff --git a/src/cmd/pr/attachment/delete.rs b/src/cmd/pr/attachment/delete.rs new file mode 100644 index 0000000..b57a83b --- /dev/null +++ b/src/cmd/pr/attachment/delete.rs @@ -0,0 +1,106 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct PrAttachmentDeleteArgs { + project_id_or_key: String, + repo_id_or_name: String, + number: u64, + attachment_id: u64, + json: bool, +} + +impl PrAttachmentDeleteArgs { + pub fn new( + project_id_or_key: String, + repo_id_or_name: String, + number: u64, + attachment_id: u64, + json: bool, + ) -> Self { + Self { + project_id_or_key, + repo_id_or_name, + number, + attachment_id, + json, + } + } +} + +pub fn delete(args: &PrAttachmentDeleteArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + delete_with(args, &client) +} + +pub fn delete_with(args: &PrAttachmentDeleteArgs, api: &dyn BacklogApi) -> Result<()> { + let attachment = api.delete_pull_request_attachment( + &args.project_id_or_key, + &args.repo_id_or_name, + args.number, + args.attachment_id, + )?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&attachment).context("Failed to serialize JSON")? + ); + } else { + println!("Deleted: {} ({} bytes)", attachment.name, attachment.size); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::pull_request::PullRequestAttachment; + use crate::cmd::pr::attachment::list::tests_helper::sample_pr_attachment; + use anyhow::anyhow; + + struct MockApi { + attachment: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn delete_pull_request_attachment( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _number: u64, + _attachment_id: u64, + ) -> anyhow::Result { + self.attachment + .clone() + .ok_or_else(|| anyhow!("delete failed")) + } + } + + fn args(json: bool) -> PrAttachmentDeleteArgs { + PrAttachmentDeleteArgs::new("TEST".to_string(), "main".to_string(), 1, 1, json) + } + + #[test] + fn delete_with_text_output_succeeds() { + let api = MockApi { + attachment: Some(sample_pr_attachment()), + }; + assert!(delete_with(&args(false), &api).is_ok()); + } + + #[test] + fn delete_with_json_output_succeeds() { + let api = MockApi { + attachment: Some(sample_pr_attachment()), + }; + assert!(delete_with(&args(true), &api).is_ok()); + } + + #[test] + fn delete_with_propagates_api_error() { + let api = MockApi { attachment: None }; + let err = delete_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("delete failed")); + } +} diff --git a/src/cmd/pr/attachment/get.rs b/src/cmd/pr/attachment/get.rs new file mode 100644 index 0000000..c0de285 --- /dev/null +++ b/src/cmd/pr/attachment/get.rs @@ -0,0 +1,125 @@ +use anstream::println; +use anyhow::{Context, Result}; +use std::path::PathBuf; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct PrAttachmentGetArgs { + project_id_or_key: String, + repo_id_or_name: String, + number: u64, + attachment_id: u64, + output: Option, +} + +impl PrAttachmentGetArgs { + pub fn new( + project_id_or_key: String, + repo_id_or_name: String, + number: u64, + attachment_id: u64, + output: Option, + ) -> Self { + Self { + project_id_or_key, + repo_id_or_name, + number, + attachment_id, + output, + } + } +} + +pub fn get(args: &PrAttachmentGetArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + get_with(args, &client) +} + +pub fn get_with(args: &PrAttachmentGetArgs, api: &dyn BacklogApi) -> Result<()> { + let (bytes, filename) = api.download_pull_request_attachment( + &args.project_id_or_key, + &args.repo_id_or_name, + args.number, + args.attachment_id, + )?; + let path = args.output.clone().unwrap_or_else(|| { + let base = std::path::Path::new(&filename) + .file_name() + .unwrap_or(std::ffi::OsStr::new("attachment")); + PathBuf::from(base) + }); + std::fs::write(&path, &bytes).with_context(|| format!("Failed to write {}", path.display()))?; + println!("Saved: {} ({} bytes)", path.display(), bytes.len()); + Ok(()) +} + +#[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_pull_request_attachment( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _number: u64, + _attachment_id: u64, + ) -> anyhow::Result<(Vec, String)> { + self.result + .clone() + .ok_or_else(|| anyhow!("download failed")) + } + } + + fn args(output: Option) -> PrAttachmentGetArgs { + PrAttachmentGetArgs::new("TEST".to_string(), "main".to_string(), 1, 1, output) + } + + #[test] + fn get_with_saves_file_to_specified_path() { + let dir = tempdir().unwrap(); + let path = dir.path().join("out.png"); + let api = MockApi { + result: Some((b"hello".to_vec(), "file.png".to_string())), + }; + assert!(get_with(&args(Some(path.clone())), &api).is_ok()); + assert_eq!(std::fs::read(&path).unwrap(), b"hello"); + } + + #[test] + fn get_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"world".to_vec(), "response.png".to_string())), + }; + assert!(get_with(&args(None), &api).is_ok()); + assert_eq!( + std::fs::read(dir.path().join("response.png")).unwrap(), + b"world" + ); + } + + #[test] + fn get_with_propagates_api_error() { + let api = MockApi { result: None }; + let err = get_with(&args(None), &api).unwrap_err(); + assert!(err.to_string().contains("download failed")); + } +} diff --git a/src/cmd/pr/attachment/list.rs b/src/cmd/pr/attachment/list.rs new file mode 100644 index 0000000..158bc65 --- /dev/null +++ b/src/cmd/pr/attachment/list.rs @@ -0,0 +1,119 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct PrAttachmentListArgs { + project_id_or_key: String, + repo_id_or_name: String, + number: u64, + json: bool, +} + +impl PrAttachmentListArgs { + pub fn new( + project_id_or_key: String, + repo_id_or_name: String, + number: u64, + json: bool, + ) -> Self { + Self { + project_id_or_key, + repo_id_or_name, + number, + json, + } + } +} + +pub fn list(args: &PrAttachmentListArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + list_with(args, &client) +} + +pub fn list_with(args: &PrAttachmentListArgs, api: &dyn BacklogApi) -> Result<()> { + let attachments = api.get_pull_request_attachments( + &args.project_id_or_key, + &args.repo_id_or_name, + args.number, + )?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&attachments).context("Failed to serialize JSON")? + ); + } else { + for a in &attachments { + println!("[{}] {} ({} bytes)", a.id, a.name, a.size); + } + } + Ok(()) +} + +#[cfg(test)] +pub(crate) mod tests_helper { + use crate::api::pull_request::PullRequestAttachment; + use crate::cmd::pr::list::tests_helper::sample_pr_user; + + pub fn sample_pr_attachment() -> PullRequestAttachment { + PullRequestAttachment { + id: 1, + name: "screenshot.png".to_string(), + size: 1024, + created_user: sample_pr_user(), + created: "2024-01-01T00:00:00Z".to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::pull_request::PullRequestAttachment; + use anyhow::anyhow; + use tests_helper::sample_pr_attachment; + + struct MockApi { + attachments: Option>, + } + + impl crate::api::BacklogApi for MockApi { + fn get_pull_request_attachments( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _number: u64, + ) -> anyhow::Result> { + self.attachments + .clone() + .ok_or_else(|| anyhow!("no attachments")) + } + } + + fn args(json: bool) -> PrAttachmentListArgs { + PrAttachmentListArgs::new("TEST".to_string(), "main".to_string(), 1, json) + } + + #[test] + fn list_with_text_output_succeeds() { + let api = MockApi { + attachments: Some(vec![sample_pr_attachment()]), + }; + assert!(list_with(&args(false), &api).is_ok()); + } + + #[test] + fn list_with_json_output_succeeds() { + let api = MockApi { + attachments: Some(vec![sample_pr_attachment()]), + }; + assert!(list_with(&args(true), &api).is_ok()); + } + + #[test] + fn list_with_propagates_api_error() { + let api = MockApi { attachments: None }; + let err = list_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no attachments")); + } +} diff --git a/src/cmd/pr/attachment/mod.rs b/src/cmd/pr/attachment/mod.rs new file mode 100644 index 0000000..95a24fa --- /dev/null +++ b/src/cmd/pr/attachment/mod.rs @@ -0,0 +1,7 @@ +mod delete; +mod get; +pub mod list; + +pub use delete::{PrAttachmentDeleteArgs, delete}; +pub use get::{PrAttachmentGetArgs, get}; +pub use list::{PrAttachmentListArgs, list}; diff --git a/src/cmd/pr/comment/add.rs b/src/cmd/pr/comment/add.rs new file mode 100644 index 0000000..fd144b8 --- /dev/null +++ b/src/cmd/pr/comment/add.rs @@ -0,0 +1,112 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct PrCommentAddArgs { + project_id_or_key: String, + repo_id_or_name: String, + number: u64, + content: String, + json: bool, +} + +impl PrCommentAddArgs { + pub fn new( + project_id_or_key: String, + repo_id_or_name: String, + number: u64, + content: String, + json: bool, + ) -> Self { + Self { + project_id_or_key, + repo_id_or_name, + number, + content, + json, + } + } +} + +pub fn add(args: &PrCommentAddArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + add_with(args, &client) +} + +pub fn add_with(args: &PrCommentAddArgs, api: &dyn BacklogApi) -> Result<()> { + let params = vec![("content".to_string(), args.content.clone())]; + let comment = api.add_pull_request_comment( + &args.project_id_or_key, + &args.repo_id_or_name, + args.number, + ¶ms, + )?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&comment).context("Failed to serialize JSON")? + ); + } else { + let content = comment.content.as_deref().unwrap_or("(no content)"); + println!("[{}] {}", comment.id, content); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::pull_request::PullRequestComment; + use crate::cmd::pr::comment::list::tests_helper::sample_pr_comment; + use anyhow::anyhow; + + struct MockApi { + comment: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn add_pull_request_comment( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _number: u64, + _params: &[(String, String)], + ) -> anyhow::Result { + self.comment.clone().ok_or_else(|| anyhow!("add failed")) + } + } + + fn args(json: bool) -> PrCommentAddArgs { + PrCommentAddArgs::new( + "TEST".to_string(), + "main".to_string(), + 1, + "LGTM".to_string(), + json, + ) + } + + #[test] + fn add_with_text_output_succeeds() { + let api = MockApi { + comment: Some(sample_pr_comment()), + }; + assert!(add_with(&args(false), &api).is_ok()); + } + + #[test] + fn add_with_json_output_succeeds() { + let api = MockApi { + comment: Some(sample_pr_comment()), + }; + assert!(add_with(&args(true), &api).is_ok()); + } + + #[test] + fn add_with_propagates_api_error() { + let api = MockApi { comment: None }; + let err = add_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("add failed")); + } +} diff --git a/src/cmd/pr/comment/count.rs b/src/cmd/pr/comment/count.rs new file mode 100644 index 0000000..28c092e --- /dev/null +++ b/src/cmd/pr/comment/count.rs @@ -0,0 +1,98 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct PrCommentCountArgs { + project_id_or_key: String, + repo_id_or_name: String, + number: u64, + json: bool, +} + +impl PrCommentCountArgs { + pub fn new( + project_id_or_key: String, + repo_id_or_name: String, + number: u64, + json: bool, + ) -> Self { + Self { + project_id_or_key, + repo_id_or_name, + number, + json, + } + } +} + +pub fn count(args: &PrCommentCountArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + count_with(args, &client) +} + +pub fn count_with(args: &PrCommentCountArgs, api: &dyn BacklogApi) -> Result<()> { + let result = api.count_pull_request_comments( + &args.project_id_or_key, + &args.repo_id_or_name, + args.number, + )?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&result).context("Failed to serialize JSON")? + ); + } else { + println!("{}", result.count); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::pull_request::PullRequestCommentCount; + use anyhow::anyhow; + + struct MockApi { + count: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn count_pull_request_comments( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _number: u64, + ) -> anyhow::Result { + self.count.clone().ok_or_else(|| anyhow!("no count")) + } + } + + fn args(json: bool) -> PrCommentCountArgs { + PrCommentCountArgs::new("TEST".to_string(), "main".to_string(), 1, json) + } + + #[test] + fn count_with_text_output_succeeds() { + let api = MockApi { + count: Some(PullRequestCommentCount { count: 3 }), + }; + assert!(count_with(&args(false), &api).is_ok()); + } + + #[test] + fn count_with_json_output_succeeds() { + let api = MockApi { + count: Some(PullRequestCommentCount { count: 3 }), + }; + assert!(count_with(&args(true), &api).is_ok()); + } + + #[test] + fn count_with_propagates_api_error() { + let api = MockApi { count: None }; + let err = count_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no count")); + } +} diff --git a/src/cmd/pr/comment/list.rs b/src/cmd/pr/comment/list.rs new file mode 100644 index 0000000..074abf0 --- /dev/null +++ b/src/cmd/pr/comment/list.rs @@ -0,0 +1,125 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct PrCommentListArgs { + project_id_or_key: String, + repo_id_or_name: String, + number: u64, + json: bool, +} + +impl PrCommentListArgs { + pub fn new( + project_id_or_key: String, + repo_id_or_name: String, + number: u64, + json: bool, + ) -> Self { + Self { + project_id_or_key, + repo_id_or_name, + number, + json, + } + } +} + +pub fn list(args: &PrCommentListArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + list_with(args, &client) +} + +pub fn list_with(args: &PrCommentListArgs, api: &dyn BacklogApi) -> Result<()> { + let comments = api.get_pull_request_comments( + &args.project_id_or_key, + &args.repo_id_or_name, + args.number, + &[], + )?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&comments).context("Failed to serialize JSON")? + ); + } else { + for c in &comments { + let content = c.content.as_deref().unwrap_or("(no content)"); + println!("[{}] {} — {}", c.id, c.created_user.name, content); + } + } + Ok(()) +} + +#[cfg(test)] +pub(crate) mod tests_helper { + use std::collections::BTreeMap; + + use crate::api::pull_request::PullRequestComment; + use crate::cmd::pr::list::tests_helper::sample_pr_user; + + pub fn sample_pr_comment() -> PullRequestComment { + PullRequestComment { + id: 1, + content: Some("LGTM".to_string()), + change_log: vec![], + created_user: sample_pr_user(), + created: "2024-01-01T00:00:00Z".to_string(), + updated: "2024-01-01T00:00:00Z".to_string(), + stars: vec![], + extra: BTreeMap::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::pull_request::PullRequestComment; + use anyhow::anyhow; + use tests_helper::sample_pr_comment; + + struct MockApi { + comments: Option>, + } + + impl crate::api::BacklogApi for MockApi { + fn get_pull_request_comments( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _number: u64, + _params: &[(String, String)], + ) -> anyhow::Result> { + self.comments.clone().ok_or_else(|| anyhow!("no comments")) + } + } + + fn args(json: bool) -> PrCommentListArgs { + PrCommentListArgs::new("TEST".to_string(), "main".to_string(), 1, json) + } + + #[test] + fn list_with_text_output_succeeds() { + let api = MockApi { + comments: Some(vec![sample_pr_comment()]), + }; + assert!(list_with(&args(false), &api).is_ok()); + } + + #[test] + fn list_with_json_output_succeeds() { + let api = MockApi { + comments: Some(vec![sample_pr_comment()]), + }; + assert!(list_with(&args(true), &api).is_ok()); + } + + #[test] + fn list_with_propagates_api_error() { + let api = MockApi { comments: None }; + let err = list_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no comments")); + } +} diff --git a/src/cmd/pr/comment/mod.rs b/src/cmd/pr/comment/mod.rs new file mode 100644 index 0000000..177bb55 --- /dev/null +++ b/src/cmd/pr/comment/mod.rs @@ -0,0 +1,9 @@ +mod add; +mod count; +pub mod list; +mod update; + +pub use add::{PrCommentAddArgs, add}; +pub use count::{PrCommentCountArgs, count}; +pub use list::{PrCommentListArgs, list}; +pub use update::{PrCommentUpdateArgs, update}; diff --git a/src/cmd/pr/comment/update.rs b/src/cmd/pr/comment/update.rs new file mode 100644 index 0000000..8c959d2 --- /dev/null +++ b/src/cmd/pr/comment/update.rs @@ -0,0 +1,118 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct PrCommentUpdateArgs { + project_id_or_key: String, + repo_id_or_name: String, + number: u64, + comment_id: u64, + content: String, + json: bool, +} + +impl PrCommentUpdateArgs { + pub fn new( + project_id_or_key: String, + repo_id_or_name: String, + number: u64, + comment_id: u64, + content: String, + json: bool, + ) -> Self { + Self { + project_id_or_key, + repo_id_or_name, + number, + comment_id, + content, + json, + } + } +} + +pub fn update(args: &PrCommentUpdateArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + update_with(args, &client) +} + +pub fn update_with(args: &PrCommentUpdateArgs, api: &dyn BacklogApi) -> Result<()> { + let params = vec![("content".to_string(), args.content.clone())]; + let comment = api.update_pull_request_comment( + &args.project_id_or_key, + &args.repo_id_or_name, + args.number, + args.comment_id, + ¶ms, + )?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&comment).context("Failed to serialize JSON")? + ); + } else { + let content = comment.content.as_deref().unwrap_or("(no content)"); + println!("[{}] {}", comment.id, content); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::pull_request::PullRequestComment; + use crate::cmd::pr::comment::list::tests_helper::sample_pr_comment; + use anyhow::anyhow; + + struct MockApi { + comment: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn update_pull_request_comment( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _number: u64, + _comment_id: u64, + _params: &[(String, String)], + ) -> anyhow::Result { + self.comment.clone().ok_or_else(|| anyhow!("update failed")) + } + } + + fn args(json: bool) -> PrCommentUpdateArgs { + PrCommentUpdateArgs::new( + "TEST".to_string(), + "main".to_string(), + 1, + 1, + "Updated".to_string(), + json, + ) + } + + #[test] + fn update_with_text_output_succeeds() { + let api = MockApi { + comment: Some(sample_pr_comment()), + }; + assert!(update_with(&args(false), &api).is_ok()); + } + + #[test] + fn update_with_json_output_succeeds() { + let api = MockApi { + comment: Some(sample_pr_comment()), + }; + assert!(update_with(&args(true), &api).is_ok()); + } + + #[test] + fn update_with_propagates_api_error() { + let api = MockApi { comment: None }; + let err = update_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("update failed")); + } +} diff --git a/src/cmd/pr/count.rs b/src/cmd/pr/count.rs new file mode 100644 index 0000000..21eb2b4 --- /dev/null +++ b/src/cmd/pr/count.rs @@ -0,0 +1,87 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct PrCountArgs { + project_id_or_key: String, + repo_id_or_name: String, + json: bool, +} + +impl PrCountArgs { + 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 count(args: &PrCountArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + count_with(args, &client) +} + +pub fn count_with(args: &PrCountArgs, api: &dyn BacklogApi) -> Result<()> { + let result = api.count_pull_requests(&args.project_id_or_key, &args.repo_id_or_name, &[])?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&result).context("Failed to serialize JSON")? + ); + } else { + println!("{}", result.count); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::pull_request::PullRequestCount; + use anyhow::anyhow; + + struct MockApi { + count: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn count_pull_requests( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _params: &[(String, String)], + ) -> anyhow::Result { + self.count.clone().ok_or_else(|| anyhow!("no count")) + } + } + + fn args(json: bool) -> PrCountArgs { + PrCountArgs::new("TEST".to_string(), "main".to_string(), json) + } + + #[test] + fn count_with_text_output_succeeds() { + let api = MockApi { + count: Some(PullRequestCount { count: 5 }), + }; + assert!(count_with(&args(false), &api).is_ok()); + } + + #[test] + fn count_with_json_output_succeeds() { + let api = MockApi { + count: Some(PullRequestCount { count: 5 }), + }; + assert!(count_with(&args(true), &api).is_ok()); + } + + #[test] + fn count_with_propagates_api_error() { + let api = MockApi { count: None }; + let err = count_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no count")); + } +} diff --git a/src/cmd/pr/create.rs b/src/cmd/pr/create.rs new file mode 100644 index 0000000..2c83deb --- /dev/null +++ b/src/cmd/pr/create.rs @@ -0,0 +1,136 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; +use crate::cmd::pr::show::print_pr; + +pub struct PrCreateArgs { + project_id_or_key: String, + repo_id_or_name: String, + summary: String, + description: Option, + base: String, + branch: String, + issue_id: Option, + assignee_id: Option, + json: bool, +} + +impl PrCreateArgs { + #[allow(clippy::too_many_arguments)] + pub fn new( + project_id_or_key: String, + repo_id_or_name: String, + summary: String, + description: Option, + base: String, + branch: String, + issue_id: Option, + assignee_id: Option, + json: bool, + ) -> Self { + Self { + project_id_or_key, + repo_id_or_name, + summary, + description, + base, + branch, + issue_id, + assignee_id, + json, + } + } +} + +pub fn create(args: &PrCreateArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + create_with(args, &client) +} + +pub fn create_with(args: &PrCreateArgs, api: &dyn BacklogApi) -> Result<()> { + let mut params: Vec<(String, String)> = vec![ + ("summary".to_string(), args.summary.clone()), + ("base".to_string(), args.base.clone()), + ("branch".to_string(), args.branch.clone()), + ]; + if let Some(desc) = &args.description { + params.push(("description".to_string(), desc.clone())); + } + if let Some(issue_id) = args.issue_id { + params.push(("issueId".to_string(), issue_id.to_string())); + } + if let Some(assignee_id) = args.assignee_id { + params.push(("assigneeId".to_string(), assignee_id.to_string())); + } + let pr = api.create_pull_request(&args.project_id_or_key, &args.repo_id_or_name, ¶ms)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&pr).context("Failed to serialize JSON")? + ); + } else { + print_pr(&pr); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::pull_request::PullRequest; + use crate::cmd::pr::list::tests_helper::sample_pr; + use anyhow::anyhow; + + struct MockApi { + pr: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn create_pull_request( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _params: &[(String, String)], + ) -> anyhow::Result { + self.pr.clone().ok_or_else(|| anyhow!("create failed")) + } + } + + fn args(json: bool) -> PrCreateArgs { + PrCreateArgs::new( + "TEST".to_string(), + "main".to_string(), + "Fix bug".to_string(), + None, + "main".to_string(), + "feature/fix".to_string(), + None, + None, + json, + ) + } + + #[test] + fn create_with_text_output_succeeds() { + let api = MockApi { + pr: Some(sample_pr()), + }; + assert!(create_with(&args(false), &api).is_ok()); + } + + #[test] + fn create_with_json_output_succeeds() { + let api = MockApi { + pr: Some(sample_pr()), + }; + assert!(create_with(&args(true), &api).is_ok()); + } + + #[test] + fn create_with_propagates_api_error() { + let api = MockApi { pr: None }; + let err = create_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("create failed")); + } +} diff --git a/src/cmd/pr/list.rs b/src/cmd/pr/list.rs new file mode 100644 index 0000000..3d11de9 --- /dev/null +++ b/src/cmd/pr/list.rs @@ -0,0 +1,163 @@ +use anstream::println; +use anyhow::{Context, Result}; +use owo_colors::OwoColorize; + +use crate::api::{BacklogApi, BacklogClient, pull_request::PullRequest}; + +pub struct PrListArgs { + project_id_or_key: String, + repo_id_or_name: String, + json: bool, +} + +impl PrListArgs { + 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 list(args: &PrListArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + list_with(args, &client) +} + +pub fn list_with(args: &PrListArgs, api: &dyn BacklogApi) -> Result<()> { + let prs = api.get_pull_requests(&args.project_id_or_key, &args.repo_id_or_name, &[])?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&prs).context("Failed to serialize JSON")? + ); + } else { + for pr in &prs { + println!("{}", format_pr_row(pr)); + } + } + Ok(()) +} + +pub fn format_pr_row(pr: &PullRequest) -> String { + format!( + "[{}] {} ({} → {}) [{}]", + pr.number.to_string().cyan().bold(), + pr.summary, + pr.branch, + pr.base, + pr.status.name + ) +} + +#[cfg(test)] +pub(crate) mod tests_helper { + use std::collections::BTreeMap; + + use crate::api::pull_request::{PrUser, PullRequest, PullRequestStatus}; + + pub fn sample_pr_user() -> PrUser { + PrUser { + 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_pr() -> PullRequest { + PullRequest { + id: 1, + project_id: 10, + repository_id: 2, + number: 1, + summary: "Fix bug".to_string(), + description: "Fixes the bug".to_string(), + base: "main".to_string(), + branch: "feature/fix".to_string(), + status: PullRequestStatus { + id: 1, + name: "Open".to_string(), + }, + assignee: None, + issue: None, + base_commit: None, + branch_commit: None, + merge_commit: None, + close_at: None, + merge_at: None, + created_user: sample_pr_user(), + created: "2024-01-01T00:00:00Z".to_string(), + updated_user: sample_pr_user(), + updated: "2024-01-01T00:00:00Z".to_string(), + attachments: vec![], + stars: vec![], + extra: BTreeMap::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::pull_request::PullRequest; + use anyhow::anyhow; + use tests_helper::sample_pr; + + struct MockApi { + prs: Option>, + } + + impl crate::api::BacklogApi for MockApi { + fn get_pull_requests( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _params: &[(String, String)], + ) -> anyhow::Result> { + self.prs.clone().ok_or_else(|| anyhow!("no pull requests")) + } + } + + fn args(json: bool) -> PrListArgs { + PrListArgs::new("TEST".to_string(), "main".to_string(), json) + } + + #[test] + fn list_with_text_output_succeeds() { + let api = MockApi { + prs: Some(vec![sample_pr()]), + }; + assert!(list_with(&args(false), &api).is_ok()); + } + + #[test] + fn list_with_json_output_succeeds() { + let api = MockApi { + prs: Some(vec![sample_pr()]), + }; + assert!(list_with(&args(true), &api).is_ok()); + } + + #[test] + fn list_with_propagates_api_error() { + let api = MockApi { prs: None }; + let err = list_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no pull requests")); + } + + #[test] + fn format_pr_row_contains_expected_fields() { + let pr = sample_pr(); + let row = format_pr_row(&pr); + assert!(row.contains("1")); + assert!(row.contains("Fix bug")); + assert!(row.contains("feature/fix")); + assert!(row.contains("main")); + assert!(row.contains("Open")); + } +} diff --git a/src/cmd/pr/mod.rs b/src/cmd/pr/mod.rs new file mode 100644 index 0000000..69faa13 --- /dev/null +++ b/src/cmd/pr/mod.rs @@ -0,0 +1,13 @@ +pub mod attachment; +pub mod comment; +mod count; +mod create; +pub mod list; +mod show; +mod update; + +pub use count::{PrCountArgs, count}; +pub use create::{PrCreateArgs, create}; +pub use list::{PrListArgs, list}; +pub use show::{PrShowArgs, show}; +pub use update::{PrUpdateArgs, update}; diff --git a/src/cmd/pr/show.rs b/src/cmd/pr/show.rs new file mode 100644 index 0000000..f01c22b --- /dev/null +++ b/src/cmd/pr/show.rs @@ -0,0 +1,113 @@ +use anstream::println; +use anyhow::{Context, Result}; +use owo_colors::OwoColorize; + +use crate::api::{BacklogApi, BacklogClient, pull_request::PullRequest}; + +pub struct PrShowArgs { + project_id_or_key: String, + repo_id_or_name: String, + number: u64, + json: bool, +} + +impl PrShowArgs { + pub fn new( + project_id_or_key: String, + repo_id_or_name: String, + number: u64, + json: bool, + ) -> Self { + Self { + project_id_or_key, + repo_id_or_name, + number, + json, + } + } +} + +pub fn show(args: &PrShowArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + show_with(args, &client) +} + +pub fn show_with(args: &PrShowArgs, api: &dyn BacklogApi) -> Result<()> { + let pr = api.get_pull_request(&args.project_id_or_key, &args.repo_id_or_name, args.number)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&pr).context("Failed to serialize JSON")? + ); + } else { + print_pr(&pr); + } + Ok(()) +} + +pub fn print_pr(pr: &PullRequest) { + println!("[{}] {}", pr.number.to_string().cyan().bold(), pr.summary); + println!(" Branch: {} → {}", pr.branch, pr.base); + println!(" Status: {}", pr.status.name); + if let Some(assignee) = &pr.assignee { + println!(" Assignee: {}", assignee.name); + } + if let Some(issue) = &pr.issue { + println!(" Issue: #{}", issue.id); + } + if !pr.description.is_empty() { + println!("\n{}", pr.description); + } + println!(" Created: {}", pr.created); + println!(" Updated: {}", pr.updated); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::pull_request::PullRequest; + use crate::cmd::pr::list::tests_helper::sample_pr; + use anyhow::anyhow; + + struct MockApi { + pr: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn get_pull_request( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _number: u64, + ) -> anyhow::Result { + self.pr.clone().ok_or_else(|| anyhow!("no pull request")) + } + } + + fn args(json: bool) -> PrShowArgs { + PrShowArgs::new("TEST".to_string(), "main".to_string(), 1, json) + } + + #[test] + fn show_with_text_output_succeeds() { + let api = MockApi { + pr: Some(sample_pr()), + }; + assert!(show_with(&args(false), &api).is_ok()); + } + + #[test] + fn show_with_json_output_succeeds() { + let api = MockApi { + pr: Some(sample_pr()), + }; + assert!(show_with(&args(true), &api).is_ok()); + } + + #[test] + fn show_with_propagates_api_error() { + let api = MockApi { pr: None }; + let err = show_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no pull request")); + } +} diff --git a/src/cmd/pr/update.rs b/src/cmd/pr/update.rs new file mode 100644 index 0000000..bb5bbff --- /dev/null +++ b/src/cmd/pr/update.rs @@ -0,0 +1,182 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; +use crate::cmd::pr::show::print_pr; + +#[cfg_attr(test, derive(Debug))] +pub struct PrUpdateArgs { + project_id_or_key: String, + repo_id_or_name: String, + number: u64, + summary: Option, + description: Option, + base: Option, + issue_id: Option, + assignee_id: Option, + comment: Option, + json: bool, +} + +impl PrUpdateArgs { + #[allow(clippy::too_many_arguments)] + pub fn try_new( + project_id_or_key: String, + repo_id_or_name: String, + number: u64, + summary: Option, + description: Option, + base: Option, + issue_id: Option, + assignee_id: Option, + comment: Option, + json: bool, + ) -> Result { + if summary.is_none() + && description.is_none() + && base.is_none() + && issue_id.is_none() + && assignee_id.is_none() + && comment.is_none() + { + return Err(anyhow::anyhow!( + "at least one of --summary, --description, --base, --issue-id, --assignee-id, or --comment must be specified" + )); + } + Ok(Self { + project_id_or_key, + repo_id_or_name, + number, + summary, + description, + base, + issue_id, + assignee_id, + comment, + json, + }) + } +} + +pub fn update(args: &PrUpdateArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + update_with(args, &client) +} + +pub fn update_with(args: &PrUpdateArgs, api: &dyn BacklogApi) -> Result<()> { + let mut params: Vec<(String, String)> = Vec::new(); + if let Some(summary) = &args.summary { + params.push(("summary".to_string(), summary.clone())); + } + if let Some(description) = &args.description { + params.push(("description".to_string(), description.clone())); + } + if let Some(base) = &args.base { + params.push(("base".to_string(), base.clone())); + } + if let Some(issue_id) = args.issue_id { + params.push(("issueId".to_string(), issue_id.to_string())); + } + if let Some(assignee_id) = args.assignee_id { + params.push(("assigneeId".to_string(), assignee_id.to_string())); + } + if let Some(comment) = &args.comment { + params.push(("comment".to_string(), comment.clone())); + } + let pr = api.update_pull_request( + &args.project_id_or_key, + &args.repo_id_or_name, + args.number, + ¶ms, + )?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&pr).context("Failed to serialize JSON")? + ); + } else { + print_pr(&pr); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::pull_request::PullRequest; + use crate::cmd::pr::list::tests_helper::sample_pr; + use anyhow::anyhow; + + struct MockApi { + pr: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn update_pull_request( + &self, + _project_id_or_key: &str, + _repo_id_or_name: &str, + _number: u64, + _params: &[(String, String)], + ) -> anyhow::Result { + self.pr.clone().ok_or_else(|| anyhow!("update failed")) + } + } + + fn args(json: bool) -> PrUpdateArgs { + PrUpdateArgs::try_new( + "TEST".to_string(), + "main".to_string(), + 1, + Some("Updated summary".to_string()), + None, + None, + None, + None, + None, + json, + ) + .unwrap() + } + + #[test] + fn update_with_text_output_succeeds() { + let api = MockApi { + pr: Some(sample_pr()), + }; + assert!(update_with(&args(false), &api).is_ok()); + } + + #[test] + fn update_with_json_output_succeeds() { + let api = MockApi { + pr: Some(sample_pr()), + }; + assert!(update_with(&args(true), &api).is_ok()); + } + + #[test] + fn update_with_propagates_api_error() { + let api = MockApi { pr: None }; + let err = update_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("update failed")); + } + + #[test] + fn update_rejects_no_fields() { + let err = PrUpdateArgs::try_new( + "TEST".to_string(), + "main".to_string(), + 1, + None, + None, + None, + None, + None, + None, + false, + ) + .unwrap_err(); + assert!(err.to_string().contains("at least one of")); + } +} diff --git a/src/main.rs b/src/main.rs index 5c99153..9924fd0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,6 +33,11 @@ use cmd::issue::{ IssueUpdateArgs, ParentChild, }; use cmd::notification::{NotificationCountArgs, NotificationListArgs, NotificationReadArgs}; +use cmd::pr::attachment::{PrAttachmentDeleteArgs, PrAttachmentGetArgs, PrAttachmentListArgs}; +use cmd::pr::comment::{ + PrCommentAddArgs, PrCommentCountArgs, PrCommentListArgs, PrCommentUpdateArgs, +}; +use cmd::pr::{PrCountArgs, PrCreateArgs, PrListArgs, PrShowArgs, PrUpdateArgs}; use cmd::priority::PriorityListArgs; use cmd::project::admin::{ProjectAdminAddArgs, ProjectAdminDeleteArgs, ProjectAdminListArgs}; use cmd::project::category::{ @@ -202,6 +207,11 @@ enum Commands { #[command(subcommand)] action: GitCommands, }, + /// Manage pull requests + Pr { + #[command(subcommand)] + action: PrCommands, + }, } #[derive(clap::ValueEnum, Clone)] @@ -2097,6 +2107,214 @@ enum GitRepoCommands { }, } +#[derive(Subcommand)] +enum PrCommands { + /// List pull requests in a repository + List { + /// 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, + }, + /// Count pull requests in a repository + Count { + /// 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, + }, + /// Show a pull request + Show { + /// Project ID or key + project_id_or_key: String, + /// Repository ID or name + repo_id_or_name: String, + /// Pull request number + number: u64, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Create a pull request + Create { + /// Project ID or key + project_id_or_key: String, + /// Repository ID or name + repo_id_or_name: String, + /// Pull request summary + #[arg(long)] + summary: String, + /// Pull request description + #[arg(long)] + description: Option, + /// Base branch (merge target) + #[arg(long)] + base: String, + /// Source branch + #[arg(long)] + branch: String, + /// Linked issue ID + #[arg(long)] + issue_id: Option, + /// Assignee user ID + #[arg(long)] + assignee_id: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Update a pull request + Update { + /// Project ID or key + project_id_or_key: String, + /// Repository ID or name + repo_id_or_name: String, + /// Pull request number + number: u64, + /// New summary + #[arg(long)] + summary: Option, + /// New description + #[arg(long)] + description: Option, + /// New base branch + #[arg(long)] + base: Option, + /// Linked issue ID + #[arg(long)] + issue_id: Option, + /// Assignee user ID + #[arg(long)] + assignee_id: Option, + /// Comment to add when updating + #[arg(long)] + comment: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Manage pull request comments + Comment { + #[command(subcommand)] + action: PrCommentCommands, + }, + /// Manage pull request attachments + Attachment { + #[command(subcommand)] + action: PrAttachmentCommands, + }, +} + +#[derive(Subcommand)] +enum PrCommentCommands { + /// List comments on a pull request + List { + /// Project ID or key + project_id_or_key: String, + /// Repository ID or name + repo_id_or_name: String, + /// Pull request number + number: u64, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Count comments on a pull request + Count { + /// Project ID or key + project_id_or_key: String, + /// Repository ID or name + repo_id_or_name: String, + /// Pull request number + number: u64, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Add a comment to a pull request + Add { + /// Project ID or key + project_id_or_key: String, + /// Repository ID or name + repo_id_or_name: String, + /// Pull request number + number: u64, + /// Comment content + #[arg(long)] + content: String, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Update a comment on a pull request + Update { + /// Project ID or key + project_id_or_key: String, + /// Repository ID or name + repo_id_or_name: String, + /// Pull request number + number: u64, + /// Comment ID + comment_id: u64, + /// New comment content + #[arg(long)] + content: String, + /// Output as JSON + #[arg(long)] + json: bool, + }, +} + +#[derive(Subcommand)] +enum PrAttachmentCommands { + /// List attachments of a pull request + List { + /// Project ID or key + project_id_or_key: String, + /// Repository ID or name + repo_id_or_name: String, + /// Pull request number + number: u64, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Download an attachment from a pull request + Get { + /// Project ID or key + project_id_or_key: String, + /// Repository ID or name + repo_id_or_name: String, + /// Pull request number + number: u64, + /// Attachment ID + attachment_id: u64, + /// Output file path + #[arg(long, short)] + output: Option, + }, + /// Delete an attachment from a pull request + Delete { + /// Project ID or key + project_id_or_key: String, + /// Repository ID or name + repo_id_or_name: String, + /// Pull request number + number: u64, + /// Attachment ID + attachment_id: u64, + /// Output as JSON + #[arg(long)] + json: bool, + }, +} + #[derive(Subcommand)] enum AuthCommands { /// Login with your API key @@ -3222,6 +3440,164 @@ fn run() -> Result<()> { )), }, }, + Commands::Pr { action } => match action { + PrCommands::List { + project_id_or_key, + repo_id_or_name, + json, + } => cmd::pr::list(&PrListArgs::new(project_id_or_key, repo_id_or_name, json)), + PrCommands::Count { + project_id_or_key, + repo_id_or_name, + json, + } => cmd::pr::count(&PrCountArgs::new(project_id_or_key, repo_id_or_name, json)), + PrCommands::Show { + project_id_or_key, + repo_id_or_name, + number, + json, + } => cmd::pr::show(&PrShowArgs::new( + project_id_or_key, + repo_id_or_name, + number, + json, + )), + PrCommands::Create { + project_id_or_key, + repo_id_or_name, + summary, + description, + base, + branch, + issue_id, + assignee_id, + json, + } => cmd::pr::create(&PrCreateArgs::new( + project_id_or_key, + repo_id_or_name, + summary, + description, + base, + branch, + issue_id, + assignee_id, + json, + )), + PrCommands::Update { + project_id_or_key, + repo_id_or_name, + number, + summary, + description, + base, + issue_id, + assignee_id, + comment, + json, + } => cmd::pr::update(&PrUpdateArgs::try_new( + project_id_or_key, + repo_id_or_name, + number, + summary, + description, + base, + issue_id, + assignee_id, + comment, + json, + )?), + PrCommands::Comment { action } => match action { + PrCommentCommands::List { + project_id_or_key, + repo_id_or_name, + number, + json, + } => cmd::pr::comment::list(&PrCommentListArgs::new( + project_id_or_key, + repo_id_or_name, + number, + json, + )), + PrCommentCommands::Count { + project_id_or_key, + repo_id_or_name, + number, + json, + } => cmd::pr::comment::count(&PrCommentCountArgs::new( + project_id_or_key, + repo_id_or_name, + number, + json, + )), + PrCommentCommands::Add { + project_id_or_key, + repo_id_or_name, + number, + content, + json, + } => cmd::pr::comment::add(&PrCommentAddArgs::new( + project_id_or_key, + repo_id_or_name, + number, + content, + json, + )), + PrCommentCommands::Update { + project_id_or_key, + repo_id_or_name, + number, + comment_id, + content, + json, + } => cmd::pr::comment::update(&PrCommentUpdateArgs::new( + project_id_or_key, + repo_id_or_name, + number, + comment_id, + content, + json, + )), + }, + PrCommands::Attachment { action } => match action { + PrAttachmentCommands::List { + project_id_or_key, + repo_id_or_name, + number, + json, + } => cmd::pr::attachment::list(&PrAttachmentListArgs::new( + project_id_or_key, + repo_id_or_name, + number, + json, + )), + PrAttachmentCommands::Get { + project_id_or_key, + repo_id_or_name, + number, + attachment_id, + output, + } => cmd::pr::attachment::get(&PrAttachmentGetArgs::new( + project_id_or_key, + repo_id_or_name, + number, + attachment_id, + output, + )), + PrAttachmentCommands::Delete { + project_id_or_key, + repo_id_or_name, + number, + attachment_id, + json, + } => cmd::pr::attachment::delete(&PrAttachmentDeleteArgs::new( + project_id_or_key, + repo_id_or_name, + number, + attachment_id, + 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 1b053b2..0324251 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -1939,6 +1939,116 @@ Example output: [4] Cannot Reproduce ``` +## `bl pr list` + +List pull requests in a repository. + +```bash +bl pr list +bl pr list --json +``` + +## `bl pr count` + +Count pull requests in a repository. + +```bash +bl pr count +bl pr count --json +``` + +## `bl pr show` + +Show details of a pull request. + +```bash +bl pr show +bl pr show --json +``` + +## `bl pr create` + +Create a pull request. + +```bash +bl pr create --summary --base --branch +bl pr create --summary --base --branch --json +bl pr create --summary --base --branch --description --issue-id --assignee-id +``` + +## `bl pr update` + +Update a pull request. At least one of `--summary`, `--description`, `--base`, `--issue-id`, `--assignee-id`, or `--comment` is required. + +```bash +bl pr update --summary +bl pr update --summary --json +bl pr update --comment +``` + +## `bl pr comment list` + +List comments on a pull request. + +```bash +bl pr comment list +bl pr comment list --json +``` + +## `bl pr comment count` + +Count comments on a pull request. + +```bash +bl pr comment count +bl pr comment count --json +``` + +## `bl pr comment add` + +Add a comment to a pull request. + +```bash +bl pr comment add --content +bl pr comment add --content --json +``` + +## `bl pr comment update` + +Update a comment on a pull request. + +```bash +bl pr comment update --content +bl pr comment update --content --json +``` + +## `bl pr attachment list` + +List attachments of a pull request. + +```bash +bl pr attachment list +bl pr attachment list --json +``` + +## `bl pr attachment get` + +Download an attachment from a pull request. + +```bash +bl pr attachment get +bl pr attachment get --output +``` + +## `bl pr attachment delete` + +Delete an attachment from a pull request. + +```bash +bl pr attachment delete +bl pr attachment delete --json +``` + ## `bl git repo list` List Git repositories in a project. @@ -2145,18 +2255,18 @@ The table below maps Backlog API v2 endpoints to `bl` commands. | Command | API endpoint | Status | | --- | --- | --- | -| `bl pr list ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests` | Planned | -| `bl pr count ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/count` | Planned | -| `bl pr show ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}` | Planned | -| `bl pr create ` | `POST /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests` | Planned | -| `bl pr update ` | `PATCH /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}` | Planned | -| `bl pr comment list ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/comments` | Planned | -| `bl pr comment count ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/comments/count` | Planned | -| `bl pr comment add ` | `POST /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/comments` | Planned | -| `bl pr comment update ` | `PATCH /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/comments/{commentId}` | Planned | -| `bl pr attachment list ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/attachments` | Planned | -| `bl pr attachment get ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/attachments/{attachmentId}` | Planned | -| `bl pr attachment delete ` | `DELETE /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/attachments/{attachmentId}` | Planned | +| `bl pr list ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests` | ✅ Implemented | +| `bl pr count ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/count` | ✅ Implemented | +| `bl pr show ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}` | ✅ Implemented | +| `bl pr create ` | `POST /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests` | ✅ Implemented | +| `bl pr update ` | `PATCH /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}` | ✅ Implemented | +| `bl pr comment list ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/comments` | ✅ Implemented | +| `bl pr comment count ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/comments/count` | ✅ Implemented | +| `bl pr comment add ` | `POST /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/comments` | ✅ Implemented | +| `bl pr comment update ` | `PATCH /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/comments/{commentId}` | ✅ Implemented | +| `bl pr attachment list ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/attachments` | ✅ Implemented | +| `bl pr attachment get ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/attachments/{attachmentId}` | ✅ Implemented | +| `bl pr attachment delete ` | `DELETE /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/attachments/{attachmentId}` | ✅ Implemented | ### Git Repositories 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 9dfd342..8a76a49 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,114 @@ bl resolution list [--json] [4] 再現しない ``` +## `bl pr list` + +リポジトリのプルリクエスト一覧を表示します。 + +```bash +bl pr list +bl pr list --json +``` + +## `bl pr count` + +リポジトリのプルリクエスト数を表示します。 + +```bash +bl pr count +bl pr count --json +``` + +## `bl pr show` + +プルリクエストの詳細を表示します。 + +```bash +bl pr show +bl pr show --json +``` + +## `bl pr create` + +プルリクエストを作成します。 + +```bash +bl pr create --summary --base --branch +bl pr create --summary --base --branch --json +``` + +## `bl pr update` + +プルリクエストを更新します。`--summary`、`--description`、`--base`、`--issue-id`、`--assignee-id`、`--comment` のいずれか1つ以上が必要です。 + +```bash +bl pr update --summary +bl pr update --summary --json +``` + +## `bl pr comment list` + +プルリクエストのコメント一覧を表示します。 + +```bash +bl pr comment list +bl pr comment list --json +``` + +## `bl pr comment count` + +プルリクエストのコメント数を表示します。 + +```bash +bl pr comment count +bl pr comment count --json +``` + +## `bl pr comment add` + +プルリクエストにコメントを追加します。 + +```bash +bl pr comment add --content +bl pr comment add --content --json +``` + +## `bl pr comment update` + +プルリクエストのコメントを更新します。 + +```bash +bl pr comment update --content +bl pr comment update --content --json +``` + +## `bl pr attachment list` + +プルリクエストの添付ファイル一覧を表示します。 + +```bash +bl pr attachment list +bl pr attachment list --json +``` + +## `bl pr attachment get` + +プルリクエストの添付ファイルをダウンロードします。 + +```bash +bl pr attachment get +bl pr attachment get --output +``` + +## `bl pr attachment delete` + +プルリクエストの添付ファイルを削除します。 + +```bash +bl pr attachment delete +bl pr attachment delete --json +``` + ## `bl git repo list` プロジェクトの Git リポジトリ一覧を表示します。 @@ -2149,18 +2257,18 @@ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。 | コマンド | API エンドポイント | 状態 | | --- | --- | --- | -| `bl pr list ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests` | 計画中 | -| `bl pr count ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/count` | 計画中 | -| `bl pr show ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}` | 計画中 | -| `bl pr create ` | `POST /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests` | 計画中 | -| `bl pr update ` | `PATCH /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}` | 計画中 | -| `bl pr comment list ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/comments` | 計画中 | -| `bl pr comment count ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/comments/count` | 計画中 | -| `bl pr comment add ` | `POST /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/comments` | 計画中 | -| `bl pr comment update ` | `PATCH /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/comments/{commentId}` | 計画中 | -| `bl pr attachment list ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/attachments` | 計画中 | -| `bl pr attachment get ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/attachments/{attachmentId}` | 計画中 | -| `bl pr attachment delete ` | `DELETE /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/attachments/{attachmentId}` | 計画中 | +| `bl pr list ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests` | ✅ 実装済み | +| `bl pr count ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/count` | ✅ 実装済み | +| `bl pr show ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}` | ✅ 実装済み | +| `bl pr create ` | `POST /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests` | ✅ 実装済み | +| `bl pr update ` | `PATCH /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}` | ✅ 実装済み | +| `bl pr comment list ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/comments` | ✅ 実装済み | +| `bl pr comment count ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/comments/count` | ✅ 実装済み | +| `bl pr comment add ` | `POST /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/comments` | ✅ 実装済み | +| `bl pr comment update ` | `PATCH /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/comments/{commentId}` | ✅ 実装済み | +| `bl pr attachment list ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/attachments` | ✅ 実装済み | +| `bl pr attachment get ` | `GET /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/attachments/{attachmentId}` | ✅ 実装済み | +| `bl pr attachment delete ` | `DELETE /api/v2/projects/{projectIdOrKey}/git/repositories/{repoIdOrName}/pullRequests/{number}/attachments/{attachmentId}` | ✅ 実装済み | ### Git Repositories