Merge remote-tracking branch 'origin/main'

This commit is contained in:
Daniel Flanagan 2024-04-17 14:07:08 -05:00
commit 424520b2c1
9 changed files with 241 additions and 114 deletions

View file

@ -30,15 +30,22 @@ pub struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum Commands { pub enum Commands {
/// Lists tasks with options for syncing /// List local tasks with options for syncing
#[clap(visible_alias = "ls")]
List(list::Args), List(list::Args),
/// Sync local tasks with GitLab and Jira /// Sync local tasks with GitLab and Jira
Sync(sync::Args), 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) /// Run the interactive terminal user interface (TUI)
#[command(subcommand)] #[clap(visible_alias = "tui")]
Ui(ui::Command), Ui(ui::Args),
/// Interact with the configured Jira endpoint /// Interact with the configured Jira endpoint
#[command(subcommand)] #[command(subcommand)]
@ -49,11 +56,38 @@ pub enum Commands {
Gitlab(gitlab::Command), Gitlab(gitlab::Command),
} }
mod ui { #[derive(Args)]
use clap::Subcommand; pub struct PurgeArgs {}
impl PurgeArgs {
pub async fn run(&self, tasks: SharedTasks) -> Result<()> {
tasks.purge_all()?;
println!("Local data purged!");
Ok(())
}
}
#[derive(Subcommand)] #[derive(Args)]
pub enum Command {} 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 { mod list {

View file

@ -18,7 +18,7 @@ pub struct MeArgs {}
impl MeArgs { impl MeArgs {
pub async fn me(&self, tasks: SharedTasks) -> Result<()> { pub async fn me(&self, tasks: SharedTasks) -> Result<()> {
println!("{:?}", tasks.gitlab.me().await?); println!("{:?}", tasks.gitlab()?.me().await?);
Ok(()) Ok(())
} }
} }

View file

@ -21,7 +21,7 @@ pub struct IssueArgs {
impl IssueArgs { impl IssueArgs {
pub async fn issue(&self, tasks: SharedTasks) -> Result<()> { 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:?}"); println!("{issue:?}");
Ok(()) Ok(())
} }

View file

View file

@ -19,16 +19,20 @@ use cli::{Cli, Commands};
async fn main() -> Result<()> { async fn main() -> Result<()> {
let cli = Cli::new(); 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 // 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)?; let _log_guard = observe::setup_logging(cli.logs_directory, cli.tracing_env_filter)?;
info!("Initializing taskr...");
println!("Initializing taskr..."); println!("Initializing taskr...");
let tasks = Arc::new(crate::tasks::Tasks::try_new(cli.data_directory)?); let tasks = Arc::new(crate::tasks::Tasks::try_new(cli.data_directory)?);
let result = match cli.command { 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::Sync(args) => args.sync(tasks).await,
Commands::Ui(_args) => tui::run(tasks).await,
Commands::List(args) => args.list(tasks).await, Commands::List(args) => args.list(tasks).await,
Commands::Jira(jira) => jira.exec(tasks).await, Commands::Jira(jira) => jira.exec(tasks).await,
Commands::Gitlab(gitlab) => gitlab.exec(tasks).await, Commands::Gitlab(gitlab) => gitlab.exec(tasks).await,

View file

@ -30,7 +30,7 @@ where
std::fs::create_dir_all(&logs_dir)?; 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 (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let env_filter = env_filter_directive let env_filter = env_filter_directive

View file

@ -68,8 +68,18 @@ impl Display for Task {
status, status,
tags, tags,
} = self; } = self;
let tags_text = tags
.iter()
.map(String::as_str)
.collect::<Vec<&str>>()
.join(", ");
let mr_text = if merge_requests.len() > 0 {
format!(" {} MRs", merge_requests.len())
} else {
"".into()
};
f.write_fmt(format_args!( f.write_fmt(format_args!(
"{jira_key} {status:>10} {jira_priority} {description}", "{jira_key} {status:>10} {jira_priority} {description} [{tags_text}]{mr_text}",
)) ))
} }
} }

View file

@ -4,7 +4,7 @@ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
env, env,
hash::RandomState, hash::RandomState,
path::Path, path::{Path, PathBuf},
process::Command, process::Command,
sync::OnceLock, sync::OnceLock,
}; };
@ -16,11 +16,14 @@ use sled::IVec;
use crate::{gitlab::GitLab, jira::Jira, result::Result}; use crate::{gitlab::GitLab, jira::Jira, result::Result};
pub struct Tasks { pub type Db = sled::Db;
pub gitlab: GitLab,
pub jira: Jira,
db: sled::Db, pub struct Tasks {
data_dir: PathBuf,
gitlab: OnceLock<GitLab>,
jira: OnceLock<Jira>,
db: OnceLock<Db>,
} }
const MY_DONE_STATUS_CATEGORY_KEY: &str = "done"; const MY_DONE_STATUS_CATEGORY_KEY: &str = "done";
@ -30,6 +33,48 @@ impl Tasks {
where where
D: AsRef<Path>, D: AsRef<Path>,
{ {
let gitlab = OnceLock::new();
let jira = OnceLock::new();
let mut default_data_dir = Default::default();
let data_dir = data_dir
.as_ref()
.map(D::as_ref)
.map(Result::Ok)
.unwrap_or_else(|| {
// really want to avoid calling this so it's put in here to force it to be lazy
// also have to shove it in a function-scoped variable so the
// reference we create in this closure has the same lifetime as the function
default_data_dir =
xdg::BaseDirectories::new()?.create_data_directory("taskr/data")?;
Ok(default_data_dir.as_ref())
})?;
let db = OnceLock::new();
let data_dir: PathBuf = data_dir.to_owned();
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? // TODO: find a way to more-lazily load the token?
let gitlab_token_entry = keyring::Entry::new("taskr", "gitlab_token")?; let gitlab_token_entry = keyring::Entry::new("taskr", "gitlab_token")?;
@ -49,7 +94,8 @@ impl Tasks {
.arg("client/divvy/gitlab-glpat") .arg("client/divvy/gitlab-glpat")
.output()?; .output()?;
let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); let result =
String::from_utf8_lossy(&output.stdout).trim().to_string();
info!("GitLab token loaded from password-store entry client/divvy/gitlab-glpat"); info!("GitLab token loaded from password-store entry client/divvy/gitlab-glpat");
result result
} }
@ -59,41 +105,60 @@ impl Tasks {
token token
} }
}; };
let gitlab = GitLab::try_new("https://git.hq.bill.com/api/v4", &gl_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? // TODO: ensure the token works?
}
}
}
// TODO: cache, use keyring, talk to a daemon, or otherwise cache this safely pub fn jira(&self) -> Result<&Jira> {
// TODO: or find a way to more-lazily load the token? match self.jira.get() {
let jira_token = env::var("JIRA_TOKEN").or_else(|_| -> Result<String> { 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") let output = Command::new("pass")
.arg("client/divvy/jira-api-token-with-email") .arg("client/divvy/jira-api-token-with-email")
.output()?; .output()?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) let result =
})?; String::from_utf8_lossy(&output.stdout).trim().to_string();
let jira = Jira::try_new("https://billcom.atlassian.net", &jira_token)?; 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 mut default_data_dir = Default::default(); let result = Jira::try_new("https://billcom.atlassian.net", &jira_token)?;
let data_dir = data_dir let _ = self.jira.set(result);
.as_ref() Ok(self.jira.get().unwrap())
.map(D::as_ref) // TODO: ensure the token works?
.map(Result::Ok) }
.unwrap_or_else(|| { }
// really want to avoid calling this so it's put in here to force it to be lazy
// also have to shove it in a function-scoped variable so the
// reference we create in this closure has the same lifetime as the function
default_data_dir =
xdg::BaseDirectories::new()?.create_data_directory("taskr/data")?;
Ok(default_data_dir.as_ref())
})?;
let db = sled::open(data_dir)?;
Ok(Self { gitlab, jira, db })
} }
pub fn all(&self) -> Result<HashMap<String, Task>> { pub fn all(&self) -> Result<HashMap<String, Task>> {
let mut result = HashMap::new(); 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 { for entry in scan {
match entry { match entry {
Ok((_key, val)) => { Ok((_key, val)) => {
@ -106,8 +171,9 @@ impl Tasks {
Ok(result) Ok(result)
} }
#[allow(dead_code)]
pub fn get(&self, key: &str) -> Result<Option<Task>> { pub fn get(&self, key: &str) -> Result<Option<Task>> {
let ivec = self.db.open_tree("tasks")?.get(key)?; let ivec = self.db()?.open_tree("tasks")?.get(key)?;
match ivec { match ivec {
Some(v) => Ok(Some(Task::try_from(v)?)), Some(v) => Ok(Some(Task::try_from(v)?)),
None => Ok(None), None => Ok(None),
@ -116,14 +182,17 @@ impl Tasks {
pub fn save(&self, task: &Task) -> Result<()> { pub fn save(&self, task: &Task) -> Result<()> {
let ivec: IVec = task.try_into()?; let ivec: IVec = task.try_into()?;
let _previous_value = self.db.open_tree("tasks")?.insert(&task.jira_key, ivec)?; let _previous_value = self
self.db.flush()?; .db()?
.open_tree("tasks")?
.insert(&task.jira_key, ivec)?;
self.db()?.flush()?;
Ok(()) Ok(())
} }
pub fn delete(&self, task: &Task) -> Result<()> { pub fn delete(&self, task: &Task) -> Result<()> {
let _previous_value = self.db.open_tree("tasks")?.remove(&task.jira_key)?; let _previous_value = self.db()?.open_tree("tasks")?.remove(&task.jira_key)?;
self.db.flush()?; self.db()?.flush()?;
Ok(()) Ok(())
} }
@ -132,7 +201,7 @@ impl Tasks {
for (_, t) in self.all()? { for (_, t) in self.all()? {
self.delete(&t)?; self.delete(&t)?;
} }
self.db.flush()?; self.db()?.flush()?;
Ok(()) Ok(())
} }
@ -162,14 +231,14 @@ impl Tasks {
for (_, mut t) in self.all()? { for (_, mut t) in self.all()? {
self.cleanup(&mut t)?; self.cleanup(&mut t)?;
} }
self.db.flush()?; self.db()?.flush()?;
Ok(()) Ok(())
} }
/// for a task that has no associated open and appropriately assigned jira issue /// for a task that has no associated open and appropriately assigned jira issue
async fn fix_dangling_task(&self, task: &Task) -> Result<()> { async fn fix_dangling_task(&self, task: &Task) -> Result<()> {
// check if closed, if it is, delete the task // 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 { if issue.fields.status.status_category.key == MY_DONE_STATUS_CATEGORY_KEY {
info!( info!(
"Deleting task {} due to being marked {} in Jira", "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) /// for use when sync'ing local tasks to remote state (jira, gitlab)
pub async fn sync(&self) -> Result<()> { pub async fn sync(&self) -> Result<()> {
let mut tasks = self.all()?; 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<String, RandomState> = let task_keys: HashSet<String, RandomState> =
HashSet::from_iter(tasks.keys().map(|s| s.to_owned())); HashSet::from_iter(tasks.keys().map(|s| s.to_owned()));
let issue_keys: HashSet<String, RandomState> = let issue_keys: HashSet<String, RandomState> =
@ -242,9 +311,9 @@ impl Tasks {
// save last sync date // save last sync date
let now = chrono::Local::now(); let now = chrono::Local::now();
self.db self.db()?
.open_tree("task:meta")? .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(()) Ok(())
} }

View file

@ -12,16 +12,25 @@ use ratatui::{
}; };
use std::io::stdout; use std::io::stdout;
pub async fn run(t: SharedTasks) -> Result<()> { pub struct Tui {
tasks: SharedTasks,
}
impl Tui {
pub fn new(tasks: SharedTasks) -> Self {
Self { tasks }
}
pub async fn run(&self) -> Result<()> {
// print!("{ANSI_CLEAR}"); // print!("{ANSI_CLEAR}");
// let gitlab_user = tasks.gitlab.me().await?; // let gitlab_user = tasks.gitlab.me().await?;
// info!("{gitlab_user:#?}"); // info!("{gitlab_user:#?}");
// let jira_user = tasks.jira.me().await?; // let jira_user = tasks.jira.me().await?;
// tasks.purge_all()?; // tasks.purge_all()?;
let tasks = t.all()?; let tasks = self.tasks.all()?;
if tasks.len() < 1 { if tasks.len() < 1 {
info!("{:?}", t.sync().await?); info!("{:?}", self.tasks.sync().await?);
} }
let mut vtasks: Vec<&task::Task> = tasks.values().collect(); let mut vtasks: Vec<&task::Task> = tasks.values().collect();
vtasks.sort_unstable(); vtasks.sort_unstable();
@ -29,10 +38,10 @@ pub async fn run(t: SharedTasks) -> Result<()> {
info!("{}", t); info!("{}", t);
} }
info!("Number of tasks: {}", vtasks.len()); info!("Number of tasks: {}", vtasks.len());
tui().await self.tui().await
} }
async fn tui() -> Result<()> { async fn tui(&self) -> Result<()> {
stdout().execute(EnterAlternateScreen)?; stdout().execute(EnterAlternateScreen)?;
enable_raw_mode()?; enable_raw_mode()?;
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
@ -59,4 +68,5 @@ async fn tui() -> Result<()> {
stdout().execute(LeaveAlternateScreen)?; stdout().execute(LeaveAlternateScreen)?;
disable_raw_mode()?; disable_raw_mode()?;
Ok(()) Ok(())
}
} }