diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..85a6ae2 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.x86_64-unknown-linux-gnu] +linker = "clang" +# TODO: cannot use mold due to libssl +# rustflags = ["-C", "link-arg=-fuse-ld=/nix/store/qcwcg3q98cm6pvyfnpn4apfh5w3xvy1d-mold-2.4.1/bin/mold"] + diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..28b0796 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Debug", + "program": "${workspaceFolder}/target/debug/tasks", + "args": [], + "cwd": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index ebaa871..0d56490 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,16 @@ serde_json = "1.0.114" tokio = { version = "1.36.0", features = ["full"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } + +[profile.dev] +opt-level = 1 + +[profile.dev.package."*"] +opt-level = 3 + +[profile.release] +strip = true +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" diff --git a/flake.nix b/flake.nix index 24f2a85..882c015 100644 --- a/flake.nix +++ b/flake.nix @@ -41,6 +41,14 @@ rustPackages.clippy rust-analyzer + # linker + clang + mold + + # debugger + lldb + + # libs openssl pkg-config ]; diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..1a5150e --- /dev/null +++ b/src/client.rs @@ -0,0 +1,13 @@ +use reqwest::Client; +use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; +use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; +use reqwest_tracing::TracingMiddleware; + +pub fn wrap_with_middleware(client: Client) -> ClientWithMiddleware { + let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3); + + ClientBuilder::new(client) + .with(TracingMiddleware::default()) + .with(RetryTransientMiddleware::new_with_policy(retry_policy)) + .build() +} diff --git a/src/gitlab.rs b/src/gitlab.rs index 86b57f8..5541306 100644 --- a/src/gitlab.rs +++ b/src/gitlab.rs @@ -1,59 +1,16 @@ -use color_eyre::owo_colors::OwoColorize; +use crate::result::Result; use reqwest::{ header::{HeaderMap, HeaderValue}, Client, Url, }; -use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; -use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; -use reqwest_tracing::TracingMiddleware; +use reqwest_middleware::ClientWithMiddleware; use serde::Deserialize; -use tracing::trace; pub struct GitLab { url: Url, client: ClientWithMiddleware, } -// --- -/* "avatar_url": "http://localhost:3000/uploads/user/avatar/1/index.jpg", -// "web_url": "http://localhost:3000/john_smith", -// "created_at": "2012-05-23T08:00:58Z", -// "bio": "", -// "location": null, -// "skype": "", -// "linkedin": "", -// "twitter": "", -// "discord": "", -// "website_url": "", -// "organization": "", -// "job_title": "", -// "pronouns": "he/him", -// "bot": false, -// "work_information": null, -// "followers": 0, -// "following": 0, -// "local_time": "3:38 PM", -// "last_sign_in_at": "2012-06-01T11:41:01Z", -// "confirmed_at": "2012-05-23T09:05:22Z", -// "theme_id": 1, -// "last_activity_on": "2012-05-23", -// "color_scheme_id": 2, -// "projects_limit": 100, -// "current_sign_in_at": "2012-06-02T06:36:55Z", -// "identities": [ -// {"provider": "github", "extern_uid": "2435223452345"}, -// {"provider": "bitbucket", "extern_uid": "john_smith"}, -// {"provider": "google_oauth2", "extern_uid": "8776128412476123468721346"} -// ], -// "can_create_group": true, -// "can_create_project": true, -// "two_factor_enabled": true, -// "external": false, -// "private_profile": false, -// "commit_email": "admin@example.com", -// } - */ - #[derive(Deserialize, Debug)] pub struct User { pub id: u64, @@ -67,35 +24,25 @@ pub struct User { } impl GitLab { - pub fn try_new(url: &str, token: &str) -> Result { - let url = Url::parse(url)?; + pub fn try_new(url: &str, token: &str) -> Result { + let url = Url::parse(&format!("{}{}", url.trim_end_matches('/'), "/"))?; let mut headers = HeaderMap::new(); headers.insert("PRIVATE-TOKEN", HeaderValue::from_str(token)?); - let proxy = reqwest::Proxy::all("socks5h://localhost:9982")?; + headers.insert("content-type", HeaderValue::from_str("application/json")?); + headers.insert("accepts", HeaderValue::from_str("application/json")?); - let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3); - let base_client = Client::builder() - .default_headers(headers) - .proxy(proxy) - .build()?; - let client = ClientBuilder::new(base_client) - .with(TracingMiddleware::default()) - .with(RetryTransientMiddleware::new_with_policy(retry_policy)) - .build(); + let base_client = Client::builder().default_headers(headers).build()?; + let client = crate::client::wrap_with_middleware(base_client); Ok(Self { url, client }) } - pub fn url(&self, path: &str) -> Result { - Ok(self.url.join(path)?) + pub fn url(&self, path: &str) -> Result { + Ok(self.url.join(path.trim_start_matches('/'))?) } - pub async fn me(&self) -> Result { + pub async fn me(&self) -> Result { let res = self.client.get(self.url("/user")?).send().await?; - trace!("{res:?}"); - let body = res.text().await?; - trace!("{body:?}"); - Ok(serde_json::from_str::(&body)?) - // Ok(res.json::().await?) + Ok(res.json().await?) } } diff --git a/src/jira.rs b/src/jira.rs index 8b13789..9a9db67 100644 --- a/src/jira.rs +++ b/src/jira.rs @@ -1 +1,117 @@ +use crate::result::Result; +use reqwest::{ + header::{HeaderMap, HeaderValue}, + Client, Url, +}; +use reqwest_middleware::ClientWithMiddleware; +use serde::Deserialize; +use tokio::spawn; +use tracing::debug; +pub struct Jira { + url: Url, + client: ClientWithMiddleware, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct User { + pub account_id: String, + pub email_address: String, + pub account_type: String, + pub display_name: String, + pub active: bool, + pub time_zone: String, + pub locale: String, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Issue { + pub id: String, + pub key: String, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct IssueSearch { + issues: Vec, + max_results: usize, + pub start_at: usize, + total: usize, +} + +impl Jira { + pub fn try_new(url: &str, token: &str) -> Result { + let url = Url::parse(&format!("{}{}", url.trim_end_matches('/'), "/"))?; + let mut headers = HeaderMap::new(); + headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Basic {}", token.trim()))?, + ); + headers.insert("content-type", HeaderValue::from_str("application/json")?); + headers.insert("accepts", HeaderValue::from_str("application/json")?); + + let base_client = Client::builder().default_headers(headers).build()?; + let client = crate::client::wrap_with_middleware(base_client); + + Ok(Self { url, client }) + } + + pub fn url(&self, path: &str) -> Result { + Ok(self.url.join(path.trim_start_matches('/'))?) + } + + pub async fn me(&self) -> Result { + let res = self + .client + .get(self.url("/rest/api/3/myself")?) + .send() + .await?; + Ok(res.json().await?) + } + + pub async fn jql(&self, jql: &str) -> Result> { + debug!("Fetching issues for jql {}", jql); + let mut results = self + .client + .get(self.url("/rest/api/3/search")?) + .query(&[("jql", jql)]) + .send() + .await? + .json::() + .await?; + + // TODO: parallelize after first page + // TODO: are there rate limits to be concerned about + + let mut issues = Vec::with_capacity(results.total); + issues.append(&mut results.issues); + + let mut futures = Vec::with_capacity((results.total / results.max_results) + 1); + + for i in 1..futures.capacity() { + let start_at = i * results.max_results; + debug!("Fetching issues for jql {} (page {})", jql, i); + futures.push(spawn( + self.client + .get(self.url("/rest/api/3/search")?) + .query(&[("jql", jql), ("startAt", &start_at.to_string())]) + .send(), + )); + } + + for task in futures { + let mut result = task.await.unwrap()?.json::().await?; + issues.append(&mut result.issues); + } + + Ok(issues) + } + + pub async fn assigned_open_issues(&self) -> Result> { + let me = self.me().await?; + let jql = format!("assignee = {0} and statusCategory != Done", me.account_id); + self.jql(&jql).await + } +} diff --git a/src/main.rs b/src/main.rs index f874e08..94bbb87 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,37 +1,44 @@ +mod client; +mod config; +mod gitlab; +mod jira; +mod result; +mod tasks; + +use crate::result::Result; use tasks::Tasks; -use tracing::{info, instrument}; +use tracing::{error, info}; use tracing_subscriber::{filter::LevelFilter, EnvFilter}; const ANSI_CLEAR: &'static str = "\x1b[2J\x1b[1;1H"; -mod config; -mod gitlab; -mod jira; -mod tasks; - -#[instrument] -pub fn init() { +#[tokio::main] +async fn main() -> Result<()> { color_eyre::install().expect("Failed to install color_eyre"); - setup_trace_logger(); - info!("Instrumentation initialized."); -} - -#[instrument] -pub fn setup_trace_logger() { let filter = EnvFilter::builder() .with_default_directive(LevelFilter::TRACE.into()) - .parse_lossy("trace,tasks=trace"); + .parse_lossy("info,tasks=trace"); tracing_subscriber::fmt().with_env_filter(filter).init(); + + match run().await { + Ok(()) => Ok(()), + Err(err) => { + error!("{err}"); + Err(err) + } + } } -#[tokio::main] -async fn main() -> Result<(), anyhow::Error> { - init(); - - tracing::info!("{ANSI_CLEAR}Hallo, mate!"); +async fn run() -> Result<()> { + print!("{ANSI_CLEAR}"); let tasks = Tasks::try_new()?; let gitlab_user = tasks.gitlab.me().await?; info!("{gitlab_user:#?}"); + let jira_user = tasks.jira.me().await?; + info!("{jira_user:#?}"); + let issues = tasks.jira.assigned_open_issues().await?; + info!("{issues:#?}"); + info!("{}", issues.len()); Ok(()) } diff --git a/src/result.rs b/src/result.rs new file mode 100644 index 0000000..0d6435f --- /dev/null +++ b/src/result.rs @@ -0,0 +1,3 @@ +use anyhow::Error; + +pub type Result = std::result::Result; diff --git a/src/tasks.rs b/src/tasks.rs index 57d1c3a..853a831 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -1,14 +1,32 @@ -use std::env; +use std::{env, process::Command}; -use crate::gitlab::GitLab; +use crate::{gitlab::GitLab, jira::Jira, result::Result}; pub struct Tasks { pub gitlab: GitLab, + pub jira: Jira, } impl Tasks { - pub fn try_new() -> Result { - let gitlab = GitLab::try_new("https://git.hq.bill.com/api/v4", &env::var("GITLAB_TOKEN")?)?; - Ok(Self { gitlab }) + pub fn try_new() -> Result { + let gl_token = env::var("GITLAB_TOKEN").or_else(|_| -> Result { + let output = Command::new("pass") + .arg("client/divvy/gitlab-glpat") + .output()?; + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + })?; + let gitlab = GitLab::try_new("https://git.hq.bill.com/api/v4", &gl_token)?; + + let jira_token = env::var("JIRA_TOKEN").or_else(|_| -> Result { + let output = Command::new("pass") + .arg("client/divvy/jira-api-token-with-email") + .output()?; + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + })?; + let jira = Jira::try_new("https://billcom.atlassian.net", &jira_token)?; + + Ok(Self { gitlab, jira }) } }