From b4397c3d71034895257a05f2a1c966996a6812ae Mon Sep 17 00:00:00 2001 From: Wang Jie Date: Thu, 25 Mar 2021 00:16:29 +0800 Subject: [PATCH] feat: support GitHub WIP: --- Cargo.lock | 9 +- Cargo.toml | 1 + src/command/mod.rs | 56 ++++---- src/command/pr.rs | 160 +++++++++++---------- src/command/profile.rs | 27 ++-- src/github/client.rs | 131 +++++++++++++++++ src/github/mod.rs | 4 + src/github/profile.rs | 86 +++++++++++ src/github/repository.rs | 150 ++++++++++++++++++++ src/github/structs.rs | 111 +++++++++++++++ src/gitlab/mod.rs | 3 +- src/gitlab/profile.rs | 39 +++++ src/gitlab/repository.rs | 299 ++++++++++++++++++++------------------- src/gitlab/structs.rs | 53 +++---- src/logger.rs | 2 +- src/main.rs | 1 + src/profile.rs | 96 ++++++------- src/repository.rs | 28 ++-- src/structs.rs | 30 +++- 19 files changed, 933 insertions(+), 353 deletions(-) create mode 100644 src/github/client.rs create mode 100644 src/github/mod.rs create mode 100644 src/github/profile.rs create mode 100644 src/github/repository.rs create mode 100644 src/github/structs.rs create mode 100644 src/gitlab/profile.rs diff --git a/Cargo.lock b/Cargo.lock index 07ebafb..02f68a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,6 +77,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + [[package]] name = "bitflags" version = "1.2.1" @@ -831,7 +837,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9eaa17ac5d7b838b7503d118fa16ad88f440498bf9ffe5424e621f93190d61e" dependencies = [ "async-compression", - "base64", + "base64 0.12.3", "bytes", "encoding_rs", "futures-core", @@ -1369,6 +1375,7 @@ version = "0.1.3" dependencies = [ "anyhow", "async-trait", + "base64 0.13.0", "clap", "colored", "futures", diff --git a/Cargo.toml b/Cargo.toml index df167ff..36ba83b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ async-trait = "^0.1.40" clap = "^2.33.3" log = { version = "^0.4.11", features = ["std"] } colored = "^2" +base64 = "0.13" [dev-dependencies] libc = "0.2" diff --git a/src/command/mod.rs b/src/command/mod.rs index 9dac42a..b97be05 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -1,42 +1,50 @@ mod pr; mod profile; -use clap::{crate_version, crate_authors, App, Arg}; use crate::logger::Logger; -use anyhow::{Result, anyhow}; +use anyhow::Result; +use clap::{crate_authors, crate_version, App, AppSettings, Arg}; use log::debug; - pub fn get_app<'a, 'b>() -> App<'a, 'b> { - App::new("yag") - .version(crate_version!()) - .author(crate_authors!()) - .arg( - Arg::with_name("v") - .short("v") - .long("verbose") - .help("verbose mode") - .global(true), - ) - .subcommand(pr::sub_command()) - .subcommand(profile::sub_command()) + App::new("yag") + .version(crate_version!()) + .author(crate_authors!()) + .global_setting(AppSettings::ColoredHelp) + .global_setting(AppSettings::DisableHelpSubcommand) + .global_setting(AppSettings::DontCollapseArgsInUsage) + .global_setting(AppSettings::VersionlessSubcommands) + .arg( + Arg::with_name("v") + .short("v") + .long("verbose") + .help("verbose mode") + .global(true), + ) + .subcommand(pr::sub_command().setting(AppSettings::SubcommandRequiredElseHelp)) + .subcommand(profile::sub_command().setting(AppSettings::SubcommandRequiredElseHelp)) } - pub async fn run() -> Result<()> { let mut app = get_app(); + let matches = app.clone().get_matches(); Logger::init(matches.is_present("v"))?; debug!("verbose mode enabled"); - let (command, arg_matches) = matches.subcommand(); - debug!("command: {}", command); - let arg_matches = arg_matches.ok_or(anyhow!("arg matches is none"))?; - debug!("arg matches: {:#?}", arg_matches); - match command { - "pr" => pr::Command::new(&arg_matches).run().await, - "profile" => profile::Command::new(&arg_matches).run().await, - _ => Ok(app.write_long_help(&mut std::io::stdout())?), + if let (command, Some(arg_matches)) = matches.subcommand() { + debug!("command: {}", command); + debug!("arg matches: {:#?}", arg_matches); + match command { + "pr" => pr::Command::new(&arg_matches).unwrap().run().await?, + "profile" => profile::Command::new(&arg_matches).unwrap().run().await?, + _ => (), + } + } else { + app.print_long_help()?; + println!(); } + + Ok(()) } diff --git a/src/command/pr.rs b/src/command/pr.rs index afe0e9e..b9b0a15 100644 --- a/src/command/pr.rs +++ b/src/command/pr.rs @@ -1,5 +1,5 @@ +use anyhow::{bail, Error, Result}; use clap::{App, Arg, ArgMatches, SubCommand}; -use anyhow::{Error, Result, bail}; use colored::Colorize; use utils::user_input; @@ -8,63 +8,71 @@ use crate::utils; #[inline] pub fn sub_command<'a, 'b>() -> App<'a, 'b> { - SubCommand::with_name("pr") - .about("Manage pull requests (aka. merge request for GitLab)") - .alias("mr") - .subcommand( - SubCommand::with_name("get") - .about("Get detail of single pull request") - .arg(Arg::with_name("id").required(true).takes_value(true)), - ) - .subcommand( - SubCommand::with_name("close") - .about("Close pull request") - .arg(Arg::with_name("id").required(true).takes_value(true)), - ) - .subcommand( - SubCommand::with_name("list").about("List pull requests") - .about("List pull requests of current repository") - .arg(Arg::with_name("author").long("author").takes_value(true)) - .arg(Arg::with_name("me").long("me")) - .arg(Arg::with_name("status").long("status").takes_value(true)) - .arg(Arg::with_name("page").long("page").takes_value(true)), - ) - .subcommand( - SubCommand::with_name("create") - .alias("new") - .about("Create a new pull request") - .arg(Arg::with_name("title").takes_value(true)) - .arg( - Arg::with_name("base") - .alias("target") - .long("base") - .short("b") - .takes_value(true), - ) - .arg( - Arg::with_name("head") - .alias("source") - .long("head") - .short("h") - .takes_value(true), - ), - ) + SubCommand::with_name("pr") + .about("Manage pull requests (aka. merge request for GitLab)") + .alias("mr") + .subcommand( + SubCommand::with_name("get") + .about("Get detail of single pull request") + .arg(Arg::with_name("id").required(true).takes_value(true)), + ) + .subcommand( + SubCommand::with_name("close") + .about("Close pull request") + .arg(Arg::with_name("id").required(true).takes_value(true)), + ) + .subcommand( + SubCommand::with_name("list") + .about("List pull requests") + .about("List pull requests of current repository") + .arg(Arg::with_name("author").long("author").takes_value(true)) + .arg(Arg::with_name("me").long("me")) + .arg(Arg::with_name("status").long("status").takes_value(true)) + .arg(Arg::with_name("page").long("page").takes_value(true)), + ) + .subcommand( + SubCommand::with_name("create") + .alias("new") + .about("Create a new pull request") + .arg(Arg::with_name("title").takes_value(true)) + .arg( + Arg::with_name("base") + .alias("target") + .long("base") + .short("b") + .takes_value(true), + ) + .arg( + Arg::with_name("head") + .alias("source") + .long("head") + .short("h") + .takes_value(true), + ), + ) } pub struct Command<'a> { + command: &'a str, matches: &'a ArgMatches<'a>, } impl<'a> Command<'a> { - pub fn new(matches: &'a ArgMatches<'a>) -> Self { - Command { - matches: matches, + pub fn new(matches: &'a ArgMatches<'a>) -> Option { + match matches.subcommand() { + (command, Some(arg_matches)) => Some(Command { + command: command, + matches: arg_matches, + }), + _ => { + println!("{}", matches.usage()); + None + } } } pub async fn run(&self) -> Result<()> { - let (command, _) = self.matches.subcommand(); - match command { + match self.command { "get" => self.get().await, "list" => self.list().await, "create" => self.create().await, @@ -74,13 +82,13 @@ impl<'a> Command<'a> { } async fn get(&self) -> Result<()> { - let id = self.matches - .value_of("id") - .and_then(|s| s.parse::().ok()) - .unwrap(); + let id = self + .matches + .value_of("id") + .and_then(|s| s.parse::().ok()) + .unwrap(); - let pr = get_repo().await? - .get_pull_request(id).await?; + let pr = get_repo().await?.get_pull_request(id).await?; println!("{:#}", pr); @@ -89,8 +97,7 @@ impl<'a> Command<'a> { async fn list(&self) -> Result<()> { let opt = ListPullRequestOpt::from(self.matches.clone()); - let result = get_repo().await? - .list_pull_requests(opt).await?; + let result = get_repo().await?.list_pull_requests(opt).await?; print!("{:#}", result); Ok(()) @@ -102,11 +109,11 @@ impl<'a> Command<'a> { _ => utils::get_current_branch()?, }; - let target_branch = self.matches.value_of("base") + let target_branch = self + .matches + .value_of("base") .map(|base| base.to_string()) - .or_else(|| { - utils::get_git_config("yag.pr.target").ok() - }) + .or_else(|| utils::get_git_config("yag.pr.target").ok()) .unwrap_or("master".to_string()); if source_branch == target_branch { @@ -114,38 +121,39 @@ impl<'a> Command<'a> { } if utils::get_rev(&source_branch)? == utils::get_rev(&target_branch)? { - let ok = user_input(&format!("{} head is same as base. still create pr? (Y/n) ", "warning".yellow().bold()))?; + let ok = user_input(&format!( + "{} head is same as base. still create pr? (Y/n) ", + "warning".yellow().bold() + ))?; if ok == "n" { - return Ok(()) + return Ok(()); } } - let title = self.matches + let title = self + .matches .value_of("title") .map(|title| title.to_string()) - .or_else(|| { - utils::get_latest_commit_message().ok() - }) - .ok_or(Error::msg("Cannot get latest commit message. Please specify title manually."))?; - - let pr = get_repo().await? - .create_pull_request( - &source_branch, - &target_branch, - &title, - ) + .or_else(|| utils::get_latest_commit_message().ok()) + .ok_or(Error::msg( + "Cannot get latest commit message. Please specify title manually.", + ))?; + + let pr = get_repo() + .await? + .create_pull_request(&source_branch, &target_branch, &title) .await?; print!("{:#}", pr); Ok(()) } async fn close(&self) -> Result<()> { - let id = self.matches + let id = self + .matches .value_of("id") .and_then(|s| s.parse::().ok()) .unwrap(); - let pr = get_repo().await? - .close_pull_request(id).await?; + let pr = get_repo().await?.close_pull_request(id).await?; print!("{:#}", pr); Ok(()) diff --git a/src/command/profile.rs b/src/command/profile.rs index ad59dd5..ca7fbdf 100644 --- a/src/command/profile.rs +++ b/src/command/profile.rs @@ -1,30 +1,32 @@ -use clap::{App, ArgMatches, SubCommand}; +use crate::profile::{load_profile, prompt_add_profile, write_profile}; use anyhow::Result; -use crate::profile::{load_profile, prompt_add_profile}; +use clap::{App, ArgMatches, SubCommand}; +use log::debug; #[inline] pub fn sub_command<'a, 'b>() -> App<'a, 'b> { SubCommand::with_name("profile") .about("Manage profiles") - .subcommand( - SubCommand::with_name("add") - .about("Add profile config interactively"), - ) + .subcommand(SubCommand::with_name("add").about("Add profile config interactively")) } pub struct Command<'a> { + command: &'a str, matches: &'a ArgMatches<'a>, } impl<'a> Command<'a> { - pub fn new(matches: &'a ArgMatches<'a>) -> Self { - Command { - matches: matches, + pub fn new(matches: &'a ArgMatches<'a>) -> Option { + match matches.subcommand() { + (command, Some(arg_matches)) => Some(Command { + command: command, + matches: arg_matches, + }), + _ => None, } } pub async fn run(&self) -> Result<()> { - let (command, _) = self.matches.subcommand(); - match command { + match self.command { "add" => self.add().await, _ => Ok(println!("{}", self.matches.usage())), } @@ -32,7 +34,10 @@ impl<'a> Command<'a> { async fn add(&self) -> Result<()> { let mut profile = load_profile().await?; + debug!("profile loaded: {:#?}", profile); prompt_add_profile(&mut profile).await?; + debug!("profile added: {:#?}", profile); + write_profile(&profile).await?; Ok(()) } } diff --git a/src/github/client.rs b/src/github/client.rs new file mode 100644 index 0000000..049837e --- /dev/null +++ b/src/github/client.rs @@ -0,0 +1,131 @@ +use anyhow::Result; +use clap::crate_version; +use log::debug; +use reqwest::{header::HeaderMap, Client, Method, RequestBuilder, Response, Url}; +use serde_json::json; + +use super::structs::{DeviceCode, GetAccessTokenResponse}; + +const GITHUB_API_ENDPOINT: &str = "https://api.github.com"; + +pub struct GitHubClient { + client: Client, +} + +impl GitHubClient { + fn get_default_headers() -> Result { + let mut headers = HeaderMap::new(); + headers.insert("Accept", "application/vnd.github.v3+json".parse()?); + headers.insert( + "User-Agent", + format!("yag/{}", env!("CARGO_PKG_VERSION")).parse()?, + ); + Ok(headers) + } + + pub fn build_with_basic_auth(username: &str, token: &str) -> Result { + let mut headers = Self::get_default_headers()?; + let token = base64::encode(format!("{}:{}", username, token)); + headers.insert("Authorization", format!("Basic {}", token).parse()?); + debug!("default headers: {:?}", headers); + let client = Client::builder().default_headers(headers).build()?; + + Ok(Self { client: client }) + } + + pub fn build_with_oauth_token(token: &str) -> Result { + let mut headers = Self::get_default_headers()?; + headers.insert("Authorization", format!("token {}", token).parse()?); + debug!("default headers: {:?}", headers); + let client = Client::builder().default_headers(headers).build()?; + + Ok(Self { client: client }) + } + + pub fn call(&self, method: Method, uri: &str) -> RequestBuilder { + let mut url = Url::parse(GITHUB_API_ENDPOINT).unwrap(); + url.set_path(uri); + + self.client.request(method, url) + } + + pub async fn graphql(&self, query: &str, variables: serde_json::Value) -> Result { + Ok(self + .call(Method::POST, "/graphql") + .header("Content-Type", "application/json") + .body( + json!({ + "query": query, + "variables": variables, + }) + .to_string(), + ) + .send() + .await?) + } +} + +pub struct GitHubAnonymousClient { + client: Client, +} + +const CLIENT_ID: &str = "57dcd53cb489239f4c7b"; + +impl GitHubAnonymousClient { + pub fn new() -> Result { + let mut headers = HeaderMap::new(); + + headers.insert("Accept", "application/vnd.github.v3+json".parse()?); + + let client = Client::builder().default_headers(headers).build()?; + + Ok(Self { client: client }) + } + + pub async fn gen_device_code(&self) -> Result { + let res = self + .client + .post("https://github.com/login/device/code") + .header("Content-Type", "application/json") + .body( + json!({ + "client_id": CLIENT_ID, + "scope": "repo", + }) + .to_string(), + ) + .send() + .await?; + + let text = res.text().await?; + debug!("gen_device_code res: {}", text); + + let device_code = serde_json::from_str::(&text)?; + + Ok(device_code) + } + + pub async fn gen_access_token(&self, device_code: &str) -> Result { + let res = self + .client + .post("https://github.com/login/oauth/access_token") + .header("Content-Type", "application/json") + .body( + json!({ + "client_id": CLIENT_ID, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }) + .to_string(), + ) + .send() + .await?; + + let text = res.text().await?; + debug!("gen_access_token res: {}", text); + + let access_token = serde_json::from_str::(&text)?; + + Ok(access_token) + } +} diff --git a/src/github/mod.rs b/src/github/mod.rs new file mode 100644 index 0000000..0925ccb --- /dev/null +++ b/src/github/mod.rs @@ -0,0 +1,4 @@ +mod client; +pub mod profile; +pub mod repository; +mod structs; diff --git a/src/github/profile.rs b/src/github/profile.rs new file mode 100644 index 0000000..e0eb5a3 --- /dev/null +++ b/src/github/profile.rs @@ -0,0 +1,86 @@ +use serde_derive::*; + +use crate::profile::{Profile, ProfileConfig, Prompter}; +use crate::utils; +use anyhow::{bail, Result}; +use async_trait::async_trait; +use colored::*; + +use super::client::GitHubAnonymousClient; +use super::structs::{AccessToken, GetAccessTokenResponse}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GitHubConfig { + pub access_token: Option, + pub username: Option, + pub token: Option, +} + +impl ProfileConfig for GitHubConfig { + fn fill_profile(&self, profile: &mut Profile) { + profile.github = Some(self.to_owned()); + } +} +#[derive(Default)] +pub struct GitHubPrompter; + +#[async_trait] +impl Prompter for GitHubPrompter { + fn display_name(&self) -> String { + "GitHub".to_string() + } + + async fn prompt(&self) -> Result> { + let client = GitHubAnonymousClient::new()?; + + println!("Logging in GitHub..."); + let res = client.gen_device_code().await?; + + let notice = format!( + "Please open {} in your browser and enter the code {}", + res.verification_uri.bold(), + res.user_code.green().bold(), + ); + + println!("{}", notice); + utils::user_input("Press enter to continue")?; + + let mut re: Option = None; + + while re.is_none() { + let start_time = tokio::time::Instant::now(); + match client.gen_access_token(&res.device_code).await? { + GetAccessTokenResponse::Ok(access_token) => re = Some(access_token), + GetAccessTokenResponse::Error { + error, + error_description, + interval, + } => { + match error.as_ref() { + "authorization_pending" => println!("{}", error_description), + "slow_down" => { + println!("{}", error_description); + let deadline = + start_time + core::time::Duration::from_secs(interval.unwrap_or(5)); + println!( + "Please wait {} seconds", + deadline.duration_since(start_time).as_secs() + ); + + tokio::time::delay_until(deadline).await; + } + "expired_token" => bail!("expired"), + _ => bail!("unknown error"), + } + utils::user_input("Press enter to retry")?; + } + } + } + + Ok(Box::new(GitHubConfig { + access_token: Some(re.unwrap().access_token), + username: None, + token: None, + })) + } +} diff --git a/src/github/repository.rs b/src/github/repository.rs new file mode 100644 index 0000000..8d9e85a --- /dev/null +++ b/src/github/repository.rs @@ -0,0 +1,150 @@ +use crate::repository::Repository; + +use super::client::GitHubClient; +use super::profile::GitHubConfig; +use super::structs::{GitHubResponse, Pull, SearchResult}; +use crate::profile::load_profile; +use crate::structs::{PaginationResult, PullRequest}; +use anyhow::{anyhow, bail, Result}; +use async_trait::async_trait; +use git_url_parse::GitUrl; +use log::debug; +use reqwest::Method; +use serde_json::json; + +pub struct GitHubRepository { + repo: String, + client: GitHubClient, +} + +impl GitHubRepository { + pub async fn init(remote_url: &GitUrl) -> Result { + let profile = load_profile().await?; + let config = profile + .github + .ok_or(anyhow!("no GitHub profile: Try `yag profile add` first"))?; + let client = match config { + GitHubConfig { + access_token: Some(token), + token: _, + username: _, + } => GitHubClient::build_with_oauth_token(&token)?, + GitHubConfig { + access_token: _, + token: Some(token), + username: Some(username), + } => GitHubClient::build_with_basic_auth(&username, &token)?, + _ => bail!("wrong GitHub profile config"), + }; + Ok(GitHubRepository { + repo: remote_url.fullname.to_string(), + client: client, + }) + } +} + +#[async_trait] +impl Repository for GitHubRepository { + async fn get_pull_request(&self, id: usize) -> Result { + let res = self + .client + .call(Method::GET, &format!("/repos/{}/pulls/{}", self.repo, id)) + .send() + .await?; + + let text = res.text().await?; + debug!("res: {}", text); + + serde_json::from_str::>(&text)? + .map(|pr| Ok(PullRequest::from(pr.to_owned()))) + } + + async fn list_pull_requests( + &self, + opt: crate::repository::ListPullRequestOpt, + ) -> Result> { + debug!("opt: {:#?}", opt); + + let mut pairs: Vec<(String, String)> = + vec![("is".into(), "pr".into()), ("is".into(), "open".into())]; + + pairs.push(("repo".into(), self.repo.to_owned())); + + if opt.me { + pairs.push(("author".into(), "@me".into())); + } else if let Some(author) = opt.author.clone() { + pairs.push(("author".into(), author)); + } + + let res = self + .client + .call(Method::GET, "/search/issues") + .query(&[("per_page", "10")]) + .query(&[("page", opt.get_page())]) + .query(&[("q", self.build_query(&pairs))]) + .send() + .await?; + + let text = res.text().await?; + debug!("res: {}", text); + let result = serde_json::from_str::>>(&text)?; + result.map::, _>(|r| Ok(PaginationResult::from(r.clone()))) + } + + async fn create_pull_request( + &self, + source_branch: &str, + target_branch: &str, + title: &str, + ) -> Result { + let res = self + .client + .call(Method::POST, &format!("/repos/{}/pulls", self.repo)) + .body( + json!({ + "title": title, + "head": source_branch, + "base": target_branch, + }) + .to_string(), + ) + .send() + .await?; + + let text = res.text().await?; + debug!("res: {}", text); + + serde_json::from_str::>(&text)? + .map(|pr| Ok(PullRequest::from(pr.to_owned()))) + } + + async fn close_pull_request(&self, id: usize) -> Result { + let res = self + .client + .call(Method::PATCH, &format!("/repos/{}/pulls/{}", self.repo, id)) + .body( + json!({ + "state": "closed", + }) + .to_string(), + ) + .send() + .await?; + + let text = res.text().await?; + debug!("{:#?}", text); + + serde_json::from_str::>(&text)? + .map(|data| Ok(PullRequest::from(data.to_owned()))) + } +} + +impl GitHubRepository { + fn build_query(&self, pairs: &[(String, String)]) -> String { + pairs + .iter() + .map(|(k, v)| format!("{}:{}", k, v)) + .collect::>() + .join(" ") + } +} diff --git a/src/github/structs.rs b/src/github/structs.rs new file mode 100644 index 0000000..1b7bf55 --- /dev/null +++ b/src/github/structs.rs @@ -0,0 +1,111 @@ +use anyhow::{bail, Result}; +use log::debug; +use std::fmt; + +use serde_derive::*; +use serde_json::Value; + +use crate::structs::{PaginationResult, PullRequest}; + +#[derive(Deserialize)] +pub struct DeviceCode { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub expires_in: usize, + pub interval: usize, +} + +#[derive(Deserialize)] +#[serde(untagged)] +pub enum GetAccessTokenResponse { + Ok(AccessToken), + Error { + error: String, + error_description: String, + interval: Option, + }, +} +#[derive(Deserialize)] +pub struct AccessToken { + pub access_token: String, +} + +#[derive(Deserialize, Debug)] +#[serde(untagged)] +pub enum GitHubResponse { + Ok(T), + Error { error: String }, +} + +impl GitHubResponse +where + T: fmt::Debug, +{ + #[inline] + pub fn map(&self, f: F) -> Result + where + F: FnOnce(&T) -> Result, + { + match self { + GitHubResponse::Ok(data) => f(data), + GitHubResponse::Error { error } => { + debug!("found an error: {:#?}", self); + bail!("error") + } + } + } +} + +#[derive(Deserialize, Debug, Clone)] +pub struct SearchResult { + total_count: u64, + incomplete_results: bool, + items: Vec, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Pull { + id: u64, + html_url: String, + title: String, + user: User, + number: u64, + base: Option, + head: Option, + updated_at: String, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct User { + login: String, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Ref { + #[serde(rename = "ref")] + name: String, +} + +impl From for PullRequest { + fn from(pr: Pull) -> Self { + Self { + id: pr.number, + title: pr.title, + author: pr.user.login, + base: pr.base.map(|r| r.name), + head: pr.head.map(|r| r.name), + updated_at: pr.updated_at, + url: pr.html_url, + } + } +} + +impl> From> for PaginationResult { + fn from(result: SearchResult) -> Self { + Self { + total: result.total_count, + result: result.items.iter().map(|i| U::from(i.to_owned())).collect(), + } + } +} diff --git a/src/gitlab/mod.rs b/src/gitlab/mod.rs index 4459dd5..0925ccb 100644 --- a/src/gitlab/mod.rs +++ b/src/gitlab/mod.rs @@ -1,3 +1,4 @@ mod client; -mod structs; +pub mod profile; pub mod repository; +mod structs; diff --git a/src/gitlab/profile.rs b/src/gitlab/profile.rs new file mode 100644 index 0000000..5cf810c --- /dev/null +++ b/src/gitlab/profile.rs @@ -0,0 +1,39 @@ +use anyhow::Result; +use async_trait::async_trait; +use serde_derive::*; + +use crate::{ + profile::{Profile, ProfileConfig, Prompter}, + utils, +}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GitLabSelfHostedConfig { + pub host: String, + pub token: String, +} + +impl ProfileConfig for GitLabSelfHostedConfig { + fn fill_profile(&self, profile: &mut Profile) { + let mut default = vec![]; + let configs = profile.gitlab_self_hosted.as_mut().unwrap_or(&mut default); + configs.push(self.to_owned()); + profile.gitlab_self_hosted = Some(configs.to_owned()); + } +} + +#[derive(Default)] +pub struct GitLabSelfHostedPrompter; + +#[async_trait] +impl Prompter for GitLabSelfHostedPrompter { + fn display_name(&self) -> String { + "GitLab (self-hosted)".to_string() + } + async fn prompt(&self) -> Result> { + Ok(Box::new(GitLabSelfHostedConfig { + host: utils::user_input("host: ")?, + token: utils::user_input("token: ")?, + })) + } +} diff --git a/src/gitlab/repository.rs b/src/gitlab/repository.rs index 71fa511..5ada8a6 100644 --- a/src/gitlab/repository.rs +++ b/src/gitlab/repository.rs @@ -1,164 +1,175 @@ -use crate::{profile::load_profile, repository::ListPullRequestOpt}; +use super::client::GitLabClient; +use super::structs::User; +use super::structs::{GitLabResponse, MergeRequest}; use crate::repository::Repository; -use crate::structs::{PullRequest, PaginationResult}; +use crate::structs::{PaginationResult, PullRequest}; +use crate::{profile::load_profile, repository::ListPullRequestOpt}; use anyhow::*; use async_trait::async_trait; -use super::structs::User; -use super::client::GitLabClient; use git_url_parse::GitUrl; use log::debug; use reqwest::Method; use serde_json::json; -use super::structs::{GitLabResponse, MergeRequest}; pub struct GitLabRepository { - client: GitLabClient, - project_id: u64, + client: GitLabClient, + project_id: u64, } impl GitLabRepository { - pub async fn init(host: &str, remote_url: &GitUrl) -> Result { - let profile = load_profile().await?; - let token = profile - .get_token_by_host(host) - .expect(&format!("unknown remote host: {}", host)); - let client = GitLabClient::build(host, &token)?; - let project_id = client.get_project_id(remote_url.fullname.as_ref()).await?; - Ok(GitLabRepository { - client, - project_id, - }) - } + pub async fn init(host: &str, remote_url: &GitUrl) -> Result { + let profile = load_profile().await?; + let token = profile + .get_gitlab_token_by_host(host) + .expect(&format!("unknown remote host: {}", host)); + let client = GitLabClient::build(host, &token)?; + let project_id = client.get_project_id(remote_url.fullname.as_ref()).await?; + Ok(GitLabRepository { client, project_id }) + } } #[async_trait] impl Repository for GitLabRepository { - async fn get_pull_request(&self, id: usize) -> Result { - let res = self.client - .call(Method::GET, &format!("/api/v4/projects/{}/merge_requests/{}", self.project_id, id)) - .send() - .await?; - - let text = res.text().await?; - - debug!("{:#?}", text); - - serde_json::from_str::>(&text)? - .map(|data| Ok(PullRequest::from(data.to_owned()))) - } - - async fn list_pull_requests(&self, opt: ListPullRequestOpt) -> Result> { - let mut req = self - .client - .call( - Method::GET, - &format!("/api/v4/projects/{}/merge_requests", self.project_id), - ) - .query(&[("state", "opened"), ("per_page", "10")]) - .query(&[("page", opt.get_page())]); - - if let Some(username) = opt.author { - let user = self.get_user_by_username(&username).await?; - req = req.query(&[("author_id", user.id)]); - } - - if opt.me { - req = req.query(&[("scope", "created-by-me")]); - } - - let res = req - .send() - .await?; - - debug!("{:#?}", res); - - let total = res.headers() - .get("x-total") - .map(|v| v.to_str().ok()) - .flatten() - .map(|v| v.parse::().ok()) - .flatten() - .ok_or(anyhow!("fail to get total"))?; - - let text = res.text().await?; - debug!("{:#?}", text); - - let result = serde_json::from_str::>>(&text)? - .map(|mr| { - Ok(mr.iter() - .map(|mr| mr.to_owned()) - .map(PullRequest::from) - .collect()) - })?; - - Ok(PaginationResult::new(result, total)) - } - - async fn create_pull_request( - &self, - source_branch: &str, - target_branch: &str, - title: &str, - ) -> Result { - let res = self - .client - .call( - Method::POST, - &format!("/api/v4/projects/{}/merge_requests", self.project_id), - ) - .header("Content-Type", "application/json") - .body( - json!({ - "source_branch": source_branch, - "target_branch": target_branch, - "title": title, - }) - .to_string(), - ) - .send() - .await?; - - let text = res.text().await?; - - debug!("{:#?}", text); - - serde_json::from_str::>(&text)? - .map(|data| Ok(PullRequest::from(data.to_owned()))) - } - - async fn close_pull_request(&self, id: usize) -> Result { - let res = self.client - .call(Method::PUT, &format!("/api/v4/projects/{}/merge_requests/{}", self.project_id, id)) - .header("Content-Type", "application/json") - .body( - json!({ - "state_event": "close", - }).to_string(), - ) - .send() - .await?; - - let text = res.text().await?; - debug!("{:#?}", text); - - serde_json::from_str::>(&text)? - .map(|data| Ok(PullRequest::from(data.to_owned()))) - } + async fn get_pull_request(&self, id: usize) -> Result { + let res = self + .client + .call( + Method::GET, + &format!("/api/v4/projects/{}/merge_requests/{}", self.project_id, id), + ) + .send() + .await?; + + let text = res.text().await?; + + debug!("{:#?}", text); + + serde_json::from_str::>(&text)? + .map(|data| Ok(PullRequest::from(data.to_owned()))) + } + + async fn list_pull_requests( + &self, + opt: ListPullRequestOpt, + ) -> Result> { + let mut req = self + .client + .call( + Method::GET, + &format!("/api/v4/projects/{}/merge_requests", self.project_id), + ) + .query(&[("state", "opened"), ("per_page", "10")]) + .query(&[("page", opt.get_page())]); + + if let Some(username) = opt.author { + let user = self.get_user_by_username(&username).await?; + req = req.query(&[("author_id", user.id)]); + } + + if opt.me { + req = req.query(&[("scope", "created-by-me")]); + } + + let res = req.send().await?; + + debug!("{:#?}", res); + + let total = res + .headers() + .get("x-total") + .map(|v| v.to_str().ok()) + .flatten() + .map(|v| v.parse::().ok()) + .flatten() + .ok_or(anyhow!("fail to get total"))?; + + let text = res.text().await?; + debug!("{:#?}", text); + + let result = + serde_json::from_str::>>(&text)?.map(|mr| { + Ok(mr + .iter() + .map(|mr| mr.to_owned()) + .map(PullRequest::from) + .collect()) + })?; + + Ok(PaginationResult::new(result, total)) + } + + async fn create_pull_request( + &self, + source_branch: &str, + target_branch: &str, + title: &str, + ) -> Result { + let res = self + .client + .call( + Method::POST, + &format!("/api/v4/projects/{}/merge_requests", self.project_id), + ) + .header("Content-Type", "application/json") + .body( + json!({ + "source_branch": source_branch, + "target_branch": target_branch, + "title": title, + }) + .to_string(), + ) + .send() + .await?; + + let text = res.text().await?; + + debug!("{:#?}", text); + + serde_json::from_str::>(&text)? + .map(|data| Ok(PullRequest::from(data.to_owned()))) + } + + async fn close_pull_request(&self, id: usize) -> Result { + let res = self + .client + .call( + Method::PUT, + &format!("/api/v4/projects/{}/merge_requests/{}", self.project_id, id), + ) + .header("Content-Type", "application/json") + .body( + json!({ + "state_event": "close", + }) + .to_string(), + ) + .send() + .await?; + + let text = res.text().await?; + debug!("{:#?}", text); + + serde_json::from_str::>(&text)? + .map(|data| Ok(PullRequest::from(data.to_owned()))) + } } impl GitLabRepository { - async fn get_user_by_username(&self, username: &str) -> Result { - let res = self.client - .call(Method::GET, &format!("/api/users")) - .query(&[("username", username)]) - .send() - .await?; - - let text = res.text().await?; - - serde_json::from_str::>>(&text)? - .map(|data| { - data.first().cloned().ok_or(anyhow!("unexpected empty response")) - }) - } + async fn get_user_by_username(&self, username: &str) -> Result { + let res = self + .client + .call(Method::GET, &format!("/api/users")) + .query(&[("username", username)]) + .send() + .await?; + + let text = res.text().await?; + + serde_json::from_str::>>(&text)?.map(|data| { + data.first() + .cloned() + .ok_or(anyhow!("unexpected empty response")) + }) + } } diff --git a/src/gitlab/structs.rs b/src/gitlab/structs.rs index 3dd397d..687cee4 100644 --- a/src/gitlab/structs.rs +++ b/src/gitlab/structs.rs @@ -45,9 +45,15 @@ pub enum GitLabResponse { }, } -impl GitLabResponse where T: fmt::Debug { +impl GitLabResponse +where + T: fmt::Debug, +{ #[inline] - pub fn map(&self, f: F) -> Result where F: FnOnce(&T) -> Result { + pub fn map(&self, f: F) -> Result + where + F: FnOnce(&T) -> Result, + { match self { GitLabResponse::Data(data) => f(data), GitLabResponse::Error { error, message } => { @@ -55,26 +61,24 @@ impl GitLabResponse where T: fmt::Debug { let message = message .clone() - .and_then(|message| { - match message { - Value::String(message) => Some(message), - Value::Array(messages) => { - let message = messages - .iter() - .filter_map(|i| i.as_str()) - .map(|i| i.to_string()) - .collect::>() - .join("\n"); - Some(message) - }, - _ => None, + .and_then(|message| match message { + Value::String(message) => Some(message), + Value::Array(messages) => { + let message = messages + .iter() + .filter_map(|i| i.as_str()) + .map(|i| i.to_string()) + .collect::>() + .join("\n"); + Some(message) } + _ => None, }) .or_else(move || error.clone().map(|i| i.join("\n"))) .unwrap_or("unknown error".to_string()); bail!(message) - }, + } } } } @@ -82,16 +86,15 @@ impl GitLabResponse where T: fmt::Debug { impl fmt::Display for GitLabResponse { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> fmt::Result { match &*self { - GitLabResponse::Error { - error, - message, - } => { - let msg = message.as_ref().and_then(|m| Some(format!("{:#}", m))) + GitLabResponse::Error { error, message } => { + let msg = message + .as_ref() + .and_then(|m| Some(format!("{:#}", m))) .or_else(|| error.as_ref().and_then(|err| Some(err.join("\n")))) .unwrap_or("unknown error".to_string()); write!(f, "{}", msg) - }, - _ => write!(f, "[data]") + } + _ => write!(f, "[data]"), } } } @@ -102,8 +105,8 @@ impl From for PullRequest { id: mr.iid, title: mr.title, author: mr.author.username, - base: mr.target_branch, - head: mr.source_branch, + base: Some(mr.target_branch), + head: Some(mr.source_branch), updated_at: mr.updated_at, url: mr.web_url, } diff --git a/src/logger.rs b/src/logger.rs index d6ea045..6609149 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -1,6 +1,6 @@ use anyhow::Result; -use log::{LevelFilter, Log, Metadata, Level, Record, max_level, set_boxed_logger, set_max_level}; use colored::*; +use log::{max_level, set_boxed_logger, set_max_level, Level, LevelFilter, Log, Metadata, Record}; pub struct Logger {} diff --git a/src/main.rs b/src/main.rs index ac31148..99811c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod command; mod gitlab; +mod github; mod logger; mod profile; mod repository; diff --git a/src/profile.rs b/src/profile.rs index f7ebecd..facdb49 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -1,69 +1,44 @@ -use anyhow::{Result, anyhow}; +use crate::github::profile::{GitHubConfig, GitHubPrompter}; +use crate::gitlab::profile::{GitLabSelfHostedConfig, GitLabSelfHostedPrompter}; +use crate::utils; +use anyhow::{anyhow, Result}; +use async_trait::async_trait; use log::debug; use serde_derive::*; use std::env; use std::fs; use std::path::Path; -use crate::utils; pub trait ProfileConfig { fn fill_profile(&self, profile: &mut Profile); } +#[async_trait] pub trait Prompter { fn display_name(&self) -> String; - fn prompt(&self) -> Result>; -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct GitLabSelfHostedConfig { - host: String, - token: String, -} - -impl ProfileConfig for GitLabSelfHostedConfig { - fn fill_profile(&self, profile: &mut Profile) { - let mut default = vec![]; - let configs = profile.gitlab_self_hosted.as_mut().unwrap_or(&mut default); - configs.push(self.to_owned()); - profile.gitlab_self_hosted = Some(configs.to_owned()); - } -} - -#[derive(Default)] -pub struct GitLabSelfHostedPrompter; - -impl Prompter for GitLabSelfHostedPrompter { - fn display_name(&self) -> String { - "GitLab (self-hosted)".to_string() - } - fn prompt(&self) -> Result> { - Ok(Box::new(GitLabSelfHostedConfig { - host: utils::user_input("host: ")?, - token: utils::user_input("token: ")?, - })) - } + async fn prompt(&self) -> Result>; } #[derive(Serialize, Deserialize, Debug)] pub struct Profile { - gitlab_self_hosted: Option>, + pub gitlab_self_hosted: Option>, + pub github: Option, } impl Profile { - fn new() -> Profile { - Profile { + fn new() -> Self { + Self { gitlab_self_hosted: None, + github: None, } } - pub fn get_token_by_host(&self, host: &str) -> Option { + pub fn get_gitlab_token_by_host(&self, host: &str) -> Option { self.gitlab_self_hosted.as_ref().and_then(|configs| { - configs.iter().find(|config| { - config.host.eq(host) - }).and_then(|config| { - Some(config.token.clone()) - }) + configs + .iter() + .find(|config| config.host.eq(host)) + .and_then(|config| Some(config.token.clone())) }) } } @@ -76,7 +51,7 @@ fn profile_exists() -> bool { Path::new(&get_profile_path()).exists() } -async fn write_profile(profile: &Profile) -> Result<()> { +pub async fn write_profile(profile: &Profile) -> Result<()> { fs::create_dir_all(Path::new(&get_profile_path()).parent().unwrap())?; fs::write(get_profile_path(), toml::to_vec(profile)?)?; Ok(()) @@ -88,7 +63,10 @@ fn migrate_profile(value: &mut toml::Value) { if let Some(config) = v.get("gitlab_self_hosted") { if !config.is_array() { let config = config.clone(); - v.insert("gitlab_self_hosted".to_string(), toml::Value::Array(vec![config])); + v.insert( + "gitlab_self_hosted".to_string(), + toml::Value::Array(vec![config]), + ); } } }); @@ -100,7 +78,10 @@ pub async fn load_profile() -> Result { let data = fs::read_to_string(get_profile_path())?; toml::from_str::(&data).or_else(|_| { let mut value = toml::from_str::(&data)?; - debug!("failed to parse profile. attempt to migrate\nraw data: {:#?}", value); + debug!( + "failed to parse profile. attempt to migrate\nraw data: {:#?}", + value + ); migrate_profile(&mut value); value.try_into::() })? @@ -117,20 +98,25 @@ pub async fn load_profile() -> Result { pub async fn prompt_add_profile(profile: &mut Profile) -> Result<()> { let prompters: Vec> = vec![ Box::new(GitLabSelfHostedPrompter::default()), + Box::new(GitHubPrompter::default()), ]; for (i, prompter) in prompters.iter().enumerate() { - println!("{:>3}: {}", i+1, prompter.display_name()); + println!("{:>3}: {}", i + 1, prompter.display_name()); } let choice_range = 1..=prompters.len(); - let profile_prompter = utils::user_input(&format!("select which type profile you want to create ({}-{}): ", choice_range.start(), choice_range.end()))? - .parse::() - .ok() - .filter(|index| choice_range.contains(&index)) - .and_then(|index| prompters.get(index-1)) - .ok_or( anyhow!("invalid choice"))?; - - let profile_config = profile_prompter.prompt()?; + let profile_prompter = utils::user_input(&format!( + "select which type profile you want to create ({}-{}): ", + choice_range.start(), + choice_range.end(), + ))? + .parse::() + .ok() + .filter(|index| choice_range.contains(&index)) + .and_then(|index| prompters.get(index - 1)) + .ok_or(anyhow!("invalid choice"))?; + + let profile_config = profile_prompter.prompt().await?; profile_config.fill_profile(profile); println!("{} profile added!", profile_prompter.display_name()); Ok(()) @@ -143,8 +129,8 @@ mod tests { use libc::STDIN_FILENO; use utils::user_input; - use std::{fs::File, io::Write}; use std::os::unix::io::FromRawFd; + use std::{fs::File, io::Write}; #[tokio::test] async fn test_profile() -> Result<()> { diff --git a/src/repository.rs b/src/repository.rs index 6fafb39..a70987a 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -1,10 +1,12 @@ -use crate::structs::{PaginationResult, PullRequest}; +use crate::github::repository::GitHubRepository; use crate::gitlab::repository::GitLabRepository; +use crate::structs::{PaginationResult, PullRequest}; use crate::utils::spawn; use anyhow::*; use async_trait::async_trait; use clap::ArgMatches; use git_url_parse::GitUrl; +use log::debug; #[derive(Debug, Default)] pub struct ListPullRequestOpt { @@ -15,9 +17,12 @@ pub struct ListPullRequestOpt { impl<'a> From> for ListPullRequestOpt { fn from(matches: ArgMatches<'a>) -> Self { + debug!("matches: {:#?}", matches); Self { author: matches.value_of("author").and_then(|s| Some(s.to_string())), - page: matches.value_of("page").and_then(|s| s.parse::().ok()), + page: matches + .value_of("page") + .and_then(|s| s.parse::().ok()), me: matches.is_present("me"), } } @@ -32,7 +37,10 @@ impl ListPullRequestOpt { #[async_trait] pub trait Repository { async fn get_pull_request(&self, id: usize) -> Result; - async fn list_pull_requests(&self, opt: ListPullRequestOpt) -> Result>; + async fn list_pull_requests( + &self, + opt: ListPullRequestOpt, + ) -> Result>; async fn create_pull_request( &self, source_branch: &str, @@ -48,18 +56,20 @@ pub fn get_remote_url() -> Result { } pub async fn get_repo() -> Result> { - let remote_url: GitUrl = get_remote_url().ok().ok_or(anyhow!("no remote is set for current repository"))?; + let remote_url: GitUrl = get_remote_url() + .ok() + .ok_or(anyhow!("no remote is set for current repository"))?; + let remote_host = remote_url .clone() .host .ok_or(Error::msg("cannot resolve host of remote url"))?; - let repo = match remote_host.as_ref() { - "github.com" => bail!("WIP: unsupported repo type"), + let repo: Box = match remote_host.as_ref() { + "github.com" => Box::new(GitHubRepository::init(&remote_url).await?), "gitlab.com" => bail!("WIP: unsupported repo type"), - - _ => GitLabRepository::init(&remote_host, &remote_url).await?, + _ => Box::new(GitLabRepository::init(&remote_host, &remote_url).await?), }; - Ok(Box::new(repo)) + Ok(repo) } diff --git a/src/structs.rs b/src/structs.rs index 58947e0..d2c2095 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -16,7 +16,7 @@ impl PaginationResult { } } -impl Display for PaginationResult where T: Display { +impl Display for PaginationResult { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { for item in self.result.iter() { write!(f, "{}", item)?; @@ -26,11 +26,24 @@ impl Display for PaginationResult where T: Display { } } +impl PaginationResult { + #[inline] + pub fn map(&self, f: F) -> PaginationResult + where + F: FnMut(&T) -> R, + { + PaginationResult { + total: self.total, + result: self.result.iter().map(f).collect::>(), + } + } +} + pub struct PullRequest { pub id: u64, pub title: String, - pub base: String, - pub head: String, + pub base: Option, + pub head: Option, pub author: String, pub updated_at: String, pub url: String, @@ -41,10 +54,15 @@ impl Display for PullRequest { let id = format!("#{}", self.id).green().bold(); let title = self.title.white(); let author = format!("<{}>", self.author).blue().bold(); - let head = format!("[{}]", self.head).cyan(); - writeln!(f, "{:>6} {} {} {}", id, title, author, head)?; + let head = self + .head + .to_owned() + .map(|head| format!("[{}]", head).cyan()) + .unwrap_or_default(); + + write!(f, "{:>6} {} {} {}", id, title, author, head)?; if f.alternate() { - writeln!(f, " {} {}", "link:".bold(), self.url)?; + write!(f, "\n {} {}", "link:".bold(), self.url)?; } Ok(()) }