Initial commit
This commit is contained in:
commit
b250b96ef0
5 changed files with 1960 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
**/*.rs.bk
|
1664
Cargo.lock
generated
Normal file
1664
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
23
Cargo.toml
Normal file
23
Cargo.toml
Normal file
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "bitbucketcli"
|
||||
version = "0.1.0"
|
||||
authors = ["Sijmen Schoon <me@sijmenschoon.nl>"]
|
||||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
reqwest = "~0.9.21"
|
||||
rand = "~0.7.2"
|
||||
base64-url = "~1.1.12"
|
||||
regex = "~1.3.1"
|
||||
lazy_static = "~1.4.0"
|
||||
clap = "~2.33.0"
|
||||
toml = "~0.5.3"
|
||||
directories = "~2.0.2"
|
||||
|
||||
[dependencies.chrono]
|
||||
version = "~0.4"
|
||||
features = ["serde"]
|
||||
|
||||
[dependencies.serde]
|
||||
version = "~1.0.101"
|
||||
features = ["derive"]
|
86
src/api.rs
Normal file
86
src/api.rs
Normal file
|
@ -0,0 +1,86 @@
|
|||
use chrono::{DateTime, Local};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Branch {
|
||||
pub name: String,
|
||||
pub merge_strategies: Option<Vec<String>>,
|
||||
pub default_merge_strategy: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct PullRequestEndpoint {
|
||||
pub branch: Branch,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ErrorInner {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Error {
|
||||
pub error: ErrorInner,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Account {
|
||||
pub username: Option<String>,
|
||||
pub nickname: String,
|
||||
pub account_status: Option<String>,
|
||||
pub display_name: String,
|
||||
pub website: Option<String>,
|
||||
pub created_on: Option<DateTime<Local>>,
|
||||
pub uuid: String,
|
||||
pub has_2fa_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct User {
|
||||
#[serde(flatten)]
|
||||
pub account: Account,
|
||||
|
||||
pub is_staff: bool,
|
||||
pub account_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Repository {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Link {
|
||||
pub href: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct PullRequestLinks {
|
||||
#[serde(rename = "self")]
|
||||
pub api: Link,
|
||||
|
||||
pub html: Link,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct PullRequest {
|
||||
pub id: usize,
|
||||
pub links: PullRequestLinks,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub author: Account,
|
||||
pub updated_on: DateTime<Local>,
|
||||
pub state: String,
|
||||
|
||||
pub source: PullRequestEndpoint,
|
||||
pub destination: PullRequestEndpoint,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Pagination<T> {
|
||||
pub pagelen: usize,
|
||||
pub size: usize,
|
||||
pub values: Vec<T>,
|
||||
pub page: usize,
|
||||
pub next: Option<String>,
|
||||
}
|
185
src/main.rs
Normal file
185
src/main.rs
Normal file
|
@ -0,0 +1,185 @@
|
|||
mod api;
|
||||
use api::*;
|
||||
|
||||
use clap::{App, Arg};
|
||||
use directories::ProjectDirs;
|
||||
use serde::Deserialize;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn truncate(s: String, size: usize) -> String {
|
||||
if s.len() > size {
|
||||
let mut s = s;
|
||||
s.truncate(size - 3);
|
||||
format!("{}...", s)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn get_pull_requests(
|
||||
config: &Config,
|
||||
client: &reqwest::Client,
|
||||
repo: &str,
|
||||
state: &str,
|
||||
) -> Result<Vec<PullRequest>, reqwest::Error> {
|
||||
let mut pull_requests = Vec::new();
|
||||
|
||||
for filter in &[
|
||||
format!(
|
||||
r#"author.nickname = "{nickname}" AND state = "{state}""#,
|
||||
nickname = config.username.as_ref().unwrap(),
|
||||
state = state,
|
||||
),
|
||||
format!(
|
||||
r#"reviewers.nickname = "{nickname}" AND state = "{state}""#,
|
||||
nickname = config.username.as_ref().unwrap(),
|
||||
state = state,
|
||||
),
|
||||
] {
|
||||
let mut next = Some(format!(
|
||||
"{}/repositories/{}/pullrequests?q={}",
|
||||
config.base_url, repo, filter
|
||||
));
|
||||
|
||||
while let Some(url) = &next {
|
||||
let response = client
|
||||
.get(url)
|
||||
.basic_auth(config.username.as_ref().unwrap(), config.password.as_ref())
|
||||
.send()?;
|
||||
let mut body: Pagination<PullRequest> = response.error_for_status()?.json()?;
|
||||
pull_requests.append(&mut body.values);
|
||||
next = body.next;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(pull_requests)
|
||||
}
|
||||
|
||||
fn get_pull_request(
|
||||
config: &Config,
|
||||
client: &reqwest::Client,
|
||||
repo: &str,
|
||||
id: &str,
|
||||
) -> Result<PullRequest, reqwest::Error> {
|
||||
let url = format!(
|
||||
"{}/repositories/{}/pullrequests/{}",
|
||||
config.base_url, repo, id
|
||||
);
|
||||
let response = client
|
||||
.get(&url)
|
||||
.basic_auth(config.username.as_ref().unwrap(), config.password.as_ref())
|
||||
.send()?;
|
||||
response.error_for_status()?.json()
|
||||
}
|
||||
|
||||
fn print_pull_request(pull_request: &PullRequest) {
|
||||
// Print the ID, title and author name.
|
||||
let title = truncate(pull_request.title.clone(), 50);
|
||||
println!(
|
||||
"[#{}] \x1b[93;1m{}\x1b[0m by \x1b[97;1m{}\x1b[0m",
|
||||
pull_request.id, title, pull_request.author.display_name,
|
||||
);
|
||||
|
||||
// Print the URL.
|
||||
println!(" \x1b[1m{}\x1b[0m", pull_request.links.html.href);
|
||||
|
||||
// Print the branches and the state.
|
||||
println!(
|
||||
" \x1b[97;1m{}\x1b[0m -> \x1b[97;1m{}\x1b[0m (\x1b[97;1m{}\x1b[0m)",
|
||||
pull_request.source.branch.name, pull_request.destination.branch.name, pull_request.state,
|
||||
);
|
||||
|
||||
// Print when the PR was last updated.
|
||||
let updated_on = pull_request
|
||||
.updated_on
|
||||
.format("%d-%m-%Y %H:%M:%S")
|
||||
.to_string();
|
||||
println!(" Last updated at \x1b[97;1m{}\x1b[0m", updated_on);
|
||||
}
|
||||
|
||||
fn print_pull_requests(pull_requests: &Vec<PullRequest>) {
|
||||
if pull_requests.is_empty() {
|
||||
println!("\x1b[31;1mNo pull requests\x1b[0m");
|
||||
}
|
||||
for pull_request in pull_requests {
|
||||
println!();
|
||||
print_pull_request(&pull_request);
|
||||
}
|
||||
}
|
||||
|
||||
fn default_base_url() -> String {
|
||||
return String::from("https://api.bitbucket.org/2.0");
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
struct Config {
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
|
||||
#[serde(default = "default_base_url")]
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
fn config_path() -> PathBuf {
|
||||
let directories = ProjectDirs::from("nl", "sijmenschoon", "bitbucketcli")
|
||||
.expect("Could not determine configuration path");
|
||||
let directory = directories.config_dir();
|
||||
std::fs::create_dir_all(directory).expect(&format!(
|
||||
"Could not create configuration directory {:?}",
|
||||
directory
|
||||
));
|
||||
|
||||
directory.join("config.toml")
|
||||
}
|
||||
|
||||
fn read_config() -> Config {
|
||||
let path = &config_path();
|
||||
let mut file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.open(path)
|
||||
.unwrap();
|
||||
|
||||
let mut contents = String::new();
|
||||
if let Ok(_) = file.read_to_string(&mut contents) {
|
||||
toml::from_str(&contents).unwrap()
|
||||
} else {
|
||||
Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), reqwest::Error> {
|
||||
let config = read_config();
|
||||
if config.username.is_none() || config.password.is_none() {
|
||||
println!(
|
||||
"No username/password specified. Please edit {:?} and add 'username = \"USERNAME\"' \
|
||||
and 'password = \"PASSWORD\"'.",
|
||||
config_path()
|
||||
);
|
||||
println!(
|
||||
"This needs to be an app password, which you can generate in the Bitbucket settings."
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let matches = App::new("Bitbucket CLI")
|
||||
.arg(Arg::with_name("REPOSITORY").required(true).index(1))
|
||||
.arg(Arg::with_name("ID").index(2))
|
||||
.get_matches();
|
||||
|
||||
let repository = matches.value_of("REPOSITORY").unwrap();
|
||||
|
||||
if let Some(id) = matches.value_of("ID") {
|
||||
let pull_request = get_pull_request(&config, &client, repository, id);
|
||||
dbg!(&pull_request);
|
||||
} else {
|
||||
let pull_requests = get_pull_requests(&config, &client, repository, "OPEN")?;
|
||||
print_pull_requests(&pull_requests);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
Loading…
Reference in a new issue