Initial commit

This commit is contained in:
Sijmen 2019-10-27 13:54:01 +01:00
commit b250b96ef0
5 changed files with 1960 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
**/*.rs.bk

1664
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

23
Cargo.toml Normal file
View 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
View 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
View 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(())
}