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