diff --git a/src/cli.rs b/src/cli.rs index 04cd817..eba9572 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -30,15 +30,22 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { - /// Lists tasks with options for syncing + /// List local tasks with options for syncing + #[clap(visible_alias = "ls")] List(list::Args), /// Sync local tasks with GitLab and Jira Sync(sync::Args), + /// Purge all local data + Purge(PurgeArgs), + + /// Performs various cleanup operations on local data + Cleanup(CleanupArgs), + /// Run the interactive terminal user interface (TUI) - #[command(subcommand)] - Ui(ui::Command), + #[clap(visible_alias = "tui")] + Ui(ui::Args), /// Interact with the configured Jira endpoint #[command(subcommand)] @@ -49,11 +56,38 @@ pub enum Commands { Gitlab(gitlab::Command), } -mod ui { - use clap::Subcommand; +#[derive(Args)] +pub struct PurgeArgs {} +impl PurgeArgs { + pub async fn run(&self, tasks: SharedTasks) -> Result<()> { + tasks.purge_all()?; + println!("Local data purged!"); + Ok(()) + } +} - #[derive(Subcommand)] - pub enum Command {} +#[derive(Args)] +pub struct CleanupArgs {} +impl CleanupArgs { + pub async fn run(&self, tasks: SharedTasks) -> Result<()> { + tasks.cleanup_all()?; + println!("Local data cleaned up!"); + Ok(()) + } +} + +mod ui { + use crate::cli::prelude::*; + + #[derive(Parser)] + pub struct Args {} + + impl Args { + pub async fn run(&self, tasks: SharedTasks) -> Result<()> { + let tui = crate::tui::Tui::new(tasks); + tui.run().await + } + } } mod list { diff --git a/src/cli/gitlab.rs b/src/cli/gitlab.rs index 5197ac1..081a50c 100644 --- a/src/cli/gitlab.rs +++ b/src/cli/gitlab.rs @@ -18,7 +18,7 @@ pub struct MeArgs {} impl MeArgs { pub async fn me(&self, tasks: SharedTasks) -> Result<()> { - println!("{:?}", tasks.gitlab.me().await?); + println!("{:?}", tasks.gitlab()?.me().await?); Ok(()) } } diff --git a/src/cli/jira.rs b/src/cli/jira.rs index 4ca6a90..4ccb8cd 100644 --- a/src/cli/jira.rs +++ b/src/cli/jira.rs @@ -21,7 +21,7 @@ pub struct IssueArgs { impl IssueArgs { pub async fn issue(&self, tasks: SharedTasks) -> Result<()> { - let issue = tasks.jira.issue(&self.key).await?; + let issue = tasks.jira()?.issue(&self.key).await?; println!("{issue:?}"); Ok(()) } diff --git a/src/cli/ui.rs b/src/cli/ui.rs deleted file mode 100644 index e69de29..0000000 diff --git a/src/main.rs b/src/main.rs index 17355d8..c094ca4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,16 +19,20 @@ use cli::{Cli, Commands}; async fn main() -> Result<()> { let cli = Cli::new(); - // this guard causes logs to be flushed when dropped + // this guard causes logs to be flushed when dropped (which is at the end of + // this function which would be the end of our program) // https://docs.rs/tracing-appender/latest/tracing_appender/non_blocking/struct.WorkerGuard.html let _log_guard = observe::setup_logging(cli.logs_directory, cli.tracing_env_filter)?; + info!("Initializing taskr..."); println!("Initializing taskr..."); let tasks = Arc::new(crate::tasks::Tasks::try_new(cli.data_directory)?); let result = match cli.command { + Commands::Ui(args) => args.run(tasks).await, + Commands::Purge(args) => args.run(tasks).await, + Commands::Cleanup(args) => args.run(tasks).await, Commands::Sync(args) => args.sync(tasks).await, - Commands::Ui(_args) => tui::run(tasks).await, Commands::List(args) => args.list(tasks).await, Commands::Jira(jira) => jira.exec(tasks).await, Commands::Gitlab(gitlab) => gitlab.exec(tasks).await, diff --git a/src/observe.rs b/src/observe.rs index 91ced19..f5178ee 100644 --- a/src/observe.rs +++ b/src/observe.rs @@ -30,7 +30,7 @@ where std::fs::create_dir_all(&logs_dir)?; } - let file_appender = tracing_appender::rolling::hourly(logs_dir, "log"); + let file_appender = tracing_appender::rolling::daily(logs_dir, "log"); let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); let env_filter = env_filter_directive diff --git a/src/task.rs b/src/task.rs index 18658da..ef6af1e 100644 --- a/src/task.rs +++ b/src/task.rs @@ -68,8 +68,18 @@ impl Display for Task { status, tags, } = self; + let tags_text = tags + .iter() + .map(String::as_str) + .collect::>() + .join(", "); + let mr_text = if merge_requests.len() > 0 { + format!(" {} MRs", merge_requests.len()) + } else { + "".into() + }; f.write_fmt(format_args!( - "{jira_key} {status:>10} {jira_priority} {description}", + "{jira_key} {status:>10} {jira_priority} {description} [{tags_text}]{mr_text}", )) } } diff --git a/src/tasks.rs b/src/tasks.rs index b1cdb72..609af2b 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -4,7 +4,7 @@ use std::{ collections::{HashMap, HashSet}, env, hash::RandomState, - path::Path, + path::{Path, PathBuf}, process::Command, sync::OnceLock, }; @@ -16,11 +16,14 @@ use sled::IVec; use crate::{gitlab::GitLab, jira::Jira, result::Result}; -pub struct Tasks { - pub gitlab: GitLab, - pub jira: Jira, +pub type Db = sled::Db; - db: sled::Db, +pub struct Tasks { + data_dir: PathBuf, + gitlab: OnceLock, + jira: OnceLock, + + db: OnceLock, } const MY_DONE_STATUS_CATEGORY_KEY: &str = "done"; @@ -30,48 +33,8 @@ impl Tasks { where D: AsRef, { - // TODO: find a way to more-lazily load the token? - let gitlab_token_entry = keyring::Entry::new("taskr", "gitlab_token")?; - - let gl_token = match gitlab_token_entry.get_password() { - Ok(token) => { - info!("GitLab token loaded from keyring"); - token - } - Err(_) => { - let token = match env::var("GITLAB_TOKEN") { - Ok(token) => { - info!("GitLab token loaded from environment variable GITLAB_TOKEN"); - token - } - Err(_) => { - let output = Command::new("pass") - .arg("client/divvy/gitlab-glpat") - .output()?; - - let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); - info!("GitLab token loaded from password-store entry client/divvy/gitlab-glpat"); - result - } - }; - info!("GitLab token stored in keyring"); - gitlab_token_entry.set_password(&token)?; - token - } - }; - let gitlab = GitLab::try_new("https://git.hq.bill.com/api/v4", &gl_token)?; - // TODO: ensure the token works? - - // TODO: cache, use keyring, talk to a daemon, or otherwise cache this safely - // TODO: or find a way to more-lazily load the 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)?; + let gitlab = OnceLock::new(); + let jira = OnceLock::new(); let mut default_data_dir = Default::default(); let data_dir = data_dir @@ -86,14 +49,116 @@ impl Tasks { xdg::BaseDirectories::new()?.create_data_directory("taskr/data")?; Ok(default_data_dir.as_ref()) })?; - let db = sled::open(data_dir)?; + let db = OnceLock::new(); + let data_dir: PathBuf = data_dir.to_owned(); - Ok(Self { gitlab, jira, db }) + Ok(Self { + gitlab, + jira, + data_dir, + db, + }) + } + + pub fn db(&self) -> Result<&Db> { + match self.db.get() { + Some(d) => Ok(d), + None => { + let result = sled::open(&self.data_dir)?; + let _ = self.db.set(result); + Ok(self.db.get().unwrap()) + } + } + } + + pub fn gitlab(&self) -> Result<&GitLab> { + match self.gitlab.get() { + Some(g) => Ok(g), + None => { + // TODO: find a way to more-lazily load the token? + let gitlab_token_entry = keyring::Entry::new("taskr", "gitlab_token")?; + + let gl_token = match gitlab_token_entry.get_password() { + Ok(token) => { + info!("GitLab token loaded from keyring"); + token + } + Err(_) => { + let token = match env::var("GITLAB_TOKEN") { + Ok(token) => { + info!("GitLab token loaded from environment variable GITLAB_TOKEN"); + token + } + Err(_) => { + let output = Command::new("pass") + .arg("client/divvy/gitlab-glpat") + .output()?; + + let result = + String::from_utf8_lossy(&output.stdout).trim().to_string(); + info!("GitLab token loaded from password-store entry client/divvy/gitlab-glpat"); + result + } + }; + info!("GitLab token stored in keyring"); + gitlab_token_entry.set_password(&token)?; + token + } + }; + let result = GitLab::try_new("https://git.hq.bill.com/api/v4", &gl_token)?; + let _ = self.gitlab.set(result); + Ok(self.gitlab.get().unwrap()) + // TODO: ensure the token works? + } + } + } + + pub fn jira(&self) -> Result<&Jira> { + match self.jira.get() { + Some(j) => Ok(j), + None => { + // TODO: find a way to more-lazily load the token? + let jira_token_entry = keyring::Entry::new("taskr", "jira_token")?; + + let jira_token = match jira_token_entry.get_password() { + Ok(token) => { + info!("Jira token loaded from keyring"); + token + } + Err(_) => { + let token = match env::var("JIRA_TOKEN") { + Ok(token) => { + info!("Jira token loaded from environment variable JIRA_TOKEN"); + token + } + Err(_) => { + let output = Command::new("pass") + .arg("client/divvy/jira-api-token-with-email") + .output()?; + + let result = + String::from_utf8_lossy(&output.stdout).trim().to_string(); + info!("Jira token loaded from password-store entry client/divvy/jira-api-token-with-email"); + result + } + }; + info!("GitLab token stored in keyring"); + jira_token_entry.set_password(&token)?; + token + } + }; + + let result = Jira::try_new("https://billcom.atlassian.net", &jira_token)?; + let _ = self.jira.set(result); + Ok(self.jira.get().unwrap()) + // TODO: ensure the token works? + } + } } pub fn all(&self) -> Result> { let mut result = HashMap::new(); - let scan = self.db.open_tree("tasks")?.scan_prefix(&[]); + let scan = self.db()?.open_tree("tasks")?.scan_prefix(&[]); for entry in scan { match entry { Ok((_key, val)) => { @@ -106,8 +171,9 @@ impl Tasks { Ok(result) } + #[allow(dead_code)] pub fn get(&self, key: &str) -> Result> { - let ivec = self.db.open_tree("tasks")?.get(key)?; + let ivec = self.db()?.open_tree("tasks")?.get(key)?; match ivec { Some(v) => Ok(Some(Task::try_from(v)?)), None => Ok(None), @@ -116,14 +182,17 @@ impl Tasks { pub fn save(&self, task: &Task) -> Result<()> { let ivec: IVec = task.try_into()?; - let _previous_value = self.db.open_tree("tasks")?.insert(&task.jira_key, ivec)?; - self.db.flush()?; + let _previous_value = self + .db()? + .open_tree("tasks")? + .insert(&task.jira_key, ivec)?; + self.db()?.flush()?; Ok(()) } pub fn delete(&self, task: &Task) -> Result<()> { - let _previous_value = self.db.open_tree("tasks")?.remove(&task.jira_key)?; - self.db.flush()?; + let _previous_value = self.db()?.open_tree("tasks")?.remove(&task.jira_key)?; + self.db()?.flush()?; Ok(()) } @@ -132,7 +201,7 @@ impl Tasks { for (_, t) in self.all()? { self.delete(&t)?; } - self.db.flush()?; + self.db()?.flush()?; Ok(()) } @@ -162,14 +231,14 @@ impl Tasks { for (_, mut t) in self.all()? { self.cleanup(&mut t)?; } - self.db.flush()?; + self.db()?.flush()?; Ok(()) } /// for a task that has no associated open and appropriately assigned jira issue async fn fix_dangling_task(&self, task: &Task) -> Result<()> { // check if closed, if it is, delete the task - let issue = self.jira.issue(&task.jira_key).await?; + let issue = self.jira()?.issue(&task.jira_key).await?; if issue.fields.status.status_category.key == MY_DONE_STATUS_CATEGORY_KEY { info!( "Deleting task {} due to being marked {} in Jira", @@ -185,7 +254,7 @@ impl Tasks { /// for use when sync'ing local tasks to remote state (jira, gitlab) pub async fn sync(&self) -> Result<()> { let mut tasks = self.all()?; - let issues = crate::jira::Jira::by_key(self.jira.assigned_open_issues().await?); + let issues = crate::jira::Jira::by_key(self.jira()?.assigned_open_issues().await?); let task_keys: HashSet = HashSet::from_iter(tasks.keys().map(|s| s.to_owned())); let issue_keys: HashSet = @@ -242,9 +311,9 @@ impl Tasks { // save last sync date let now = chrono::Local::now(); - self.db + self.db()? .open_tree("task:meta")? - .insert(b"last_sync_date", serde_json::to_vec(&now)?); + .insert(b"last_sync_date", serde_json::to_vec(&now)?)?; Ok(()) } diff --git a/src/tui.rs b/src/tui.rs index c2bcd43..b03238e 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -12,51 +12,61 @@ use ratatui::{ }; use std::io::stdout; -pub async fn run(t: SharedTasks) -> Result<()> { - // print!("{ANSI_CLEAR}"); - // let gitlab_user = tasks.gitlab.me().await?; - // info!("{gitlab_user:#?}"); - // let jira_user = tasks.jira.me().await?; - // tasks.purge_all()?; - let tasks = t.all()?; - - if tasks.len() < 1 { - info!("{:?}", t.sync().await?); - } - let mut vtasks: Vec<&task::Task> = tasks.values().collect(); - vtasks.sort_unstable(); - for t in &vtasks { - info!("{}", t); - } - info!("Number of tasks: {}", vtasks.len()); - tui().await +pub struct Tui { + tasks: SharedTasks, } -async fn tui() -> Result<()> { - stdout().execute(EnterAlternateScreen)?; - enable_raw_mode()?; - let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; - terminal.clear()?; +impl Tui { + pub fn new(tasks: SharedTasks) -> Self { + Self { tasks } + } - loop { - terminal.draw(|frame| { - let area = frame.size(); - frame.render_widget( - Paragraph::new("Hello Ratatui! (press 'q' to quit)").white(), - area, - ); - })?; - if event::poll(std::time::Duration::from_millis(10))? { - if let event::Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { - break; + pub async fn run(&self) -> Result<()> { + // print!("{ANSI_CLEAR}"); + // let gitlab_user = tasks.gitlab.me().await?; + // info!("{gitlab_user:#?}"); + // let jira_user = tasks.jira.me().await?; + // tasks.purge_all()?; + let tasks = self.tasks.all()?; + + if tasks.len() < 1 { + info!("{:?}", self.tasks.sync().await?); + } + let mut vtasks: Vec<&task::Task> = tasks.values().collect(); + vtasks.sort_unstable(); + for t in &vtasks { + info!("{}", t); + } + info!("Number of tasks: {}", vtasks.len()); + self.tui().await + } + + async fn tui(&self) -> Result<()> { + stdout().execute(EnterAlternateScreen)?; + enable_raw_mode()?; + let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; + terminal.clear()?; + + loop { + terminal.draw(|frame| { + let area = frame.size(); + frame.render_widget( + Paragraph::new("Hello Ratatui! (press 'q' to quit)").white(), + area, + ); + })?; + if event::poll(std::time::Duration::from_millis(10))? { + if let event::Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { + break; + } } } } - } - // TODO main loop + // TODO main loop - stdout().execute(LeaveAlternateScreen)?; - disable_raw_mode()?; - Ok(()) + stdout().execute(LeaveAlternateScreen)?; + disable_raw_mode()?; + Ok(()) + } }