Skip to main content

max / makenotwork

7.6 KB · 241 lines History Blame Raw
1 mod api;
2 mod cli;
3 mod db;
4 mod tui;
5 mod types;
6
7 use std::io::Write;
8
9 use clap::Parser;
10 use cli::{Command, ExportFormat};
11 use color_eyre::eyre::{Result, WrapErr};
12 use db::ListFilter;
13 use types::{NewTicket, Status};
14
15 fn main() -> Result<()> {
16 color_eyre::install()?;
17 let cli = cli::Cli::parse();
18 let conn = db::open_db()?;
19 let node_id = db::get_or_create_node_id(&conn)?;
20
21 match cli.command {
22 None => tui::run(conn, node_id)?,
23
24 Some(Command::Create { title, body, priority, channel, source, source_ref }) => {
25 let ticket = db::create_ticket(&conn, &NewTicket {
26 title,
27 body,
28 priority,
29 channel,
30 source: Some(source),
31 source_ref,
32 }, &node_id)?;
33 println!("created {} [{}] ({})", ticket.short_id(), ticket.channel, ticket.title);
34 }
35
36 Some(Command::List { status, priority, channel, source }) => {
37 let tickets = db::list_tickets(&conn, &ListFilter {
38 status,
39 priority,
40 channel,
41 source: source.as_deref(),
42 ..Default::default()
43 })?;
44
45 if tickets.is_empty() {
46 println!("no tickets");
47 return Ok(());
48 }
49
50 println!("{:<10} {:<8} {:<5} {:<12} {:<30} {}", "ID", "Channel", "Pri", "Status", "Title", "Node");
51 println!("{}", "-".repeat(80));
52 for t in &tickets {
53 println!(
54 "{:<10} {:<8} {:<5} {:<12} {:<30} {}",
55 t.short_id(),
56 t.channel,
57 t.priority,
58 t.status,
59 truncate(&t.title, 30),
60 t.short_node(),
61 );
62 }
63 println!("\n{} ticket(s)", tickets.len());
64 }
65
66 Some(Command::Show { id }) => {
67 let t = db::get_ticket(&conn, &id)?;
68 println!("ID: {}", t.id);
69 println!("Title: {}", t.title);
70 println!("Channel: {}", t.channel);
71 println!("Priority: {}", t.priority);
72 println!("Status: {} {}", t.status.indicator(), t.status);
73 println!("Node: {}", t.node_id);
74 println!("Source: {}", t.source.as_deref().unwrap_or("-"));
75 println!("Ref: {}", t.source_ref.as_deref().unwrap_or("-"));
76 println!("Created: {}", t.created_at.format("%Y-%m-%d %H:%M UTC"));
77 println!("Updated: {}", t.updated_at.format("%Y-%m-%d %H:%M UTC"));
78 if let Some(ref resolved) = t.resolved_at {
79 println!("Resolved: {}", resolved.format("%Y-%m-%d %H:%M UTC"));
80 }
81 if let Some(ref body) = t.body {
82 println!("\n{body}");
83 }
84 }
85
86 Some(Command::Resolve { id }) => {
87 let t = db::get_ticket(&conn, &id)?;
88 db::update_status(&conn, &t.id, Status::Resolved)?;
89 println!("resolved {} ({})", t.short_id(), t.title);
90 }
91
92 Some(Command::Close { id }) => {
93 let t = db::get_ticket(&conn, &id)?;
94 db::update_status(&conn, &t.id, Status::Closed)?;
95 println!("closed {} ({})", t.short_id(), t.title);
96 }
97
98 Some(Command::Serve { port, peer }) => {
99 let rt = tokio::runtime::Runtime::new()?;
100 rt.block_on(api::serve(conn, port, peer))?;
101 }
102
103 Some(Command::Stats) => print_stats(&conn)?,
104
105 Some(Command::Export { format, output }) => {
106 let tickets = db::list_tickets(&conn, &ListFilter::default())?;
107 let mut writer: Box<dyn Write> = match output {
108 Some(path) => Box::new(
109 std::fs::File::create(&path)
110 .wrap_err_with(|| format!("create {}", path.display()))?,
111 ),
112 None => Box::new(std::io::stdout().lock()),
113 };
114 match format {
115 ExportFormat::Json => {
116 serde_json::to_writer_pretty(&mut writer, &tickets)?;
117 writeln!(writer)?;
118 }
119 ExportFormat::Csv => write_csv(&mut writer, &tickets)?,
120 }
121 }
122
123 Some(Command::Prune { older_than, status, dry_run }) => {
124 let dur = cli::parse_duration(&older_than)
125 .map_err(|e| color_eyre::eyre::eyre!(e))?;
126 if dry_run {
127 let cutoff = chrono::Utc::now() - dur;
128 let candidates: Vec<_> = db::list_tickets(&conn, &ListFilter {
129 status: Some(status),
130 ..Default::default()
131 })?
132 .into_iter()
133 .filter(|t| t.updated_at < cutoff)
134 .collect();
135 if candidates.is_empty() {
136 println!("no tickets to prune");
137 } else {
138 for t in &candidates {
139 println!("{} {} ({})", t.short_id(), t.title, t.age());
140 }
141 println!("\nwould delete {} ticket(s)", candidates.len());
142 }
143 } else {
144 let n = db::prune_tickets(&conn, dur, status)?;
145 println!("pruned {n} ticket(s)");
146 }
147 }
148 }
149
150 Ok(())
151 }
152
153 fn print_stats(conn: &rusqlite::Connection) -> Result<()> {
154 let s = db::stats(conn)?;
155 println!("Total: {}", s.total);
156
157 if !s.by_status.is_empty() {
158 println!("\nBy status:");
159 for (st, n) in &s.by_status {
160 println!(" {st:<12} {n}");
161 }
162 }
163
164 if !s.open_by_priority.is_empty() {
165 println!("\nOpen by priority:");
166 for (p, n) in &s.open_by_priority {
167 println!(" {p:<9} {n}");
168 }
169 }
170
171 if !s.open_by_source.is_empty() {
172 println!("\nOpen by source:");
173 for (src, n) in &s.open_by_source {
174 println!(" {src:<20} {n}");
175 }
176 }
177
178 if let Some(avg) = s.avg_resolution_seconds {
179 println!("\nAverage resolution time: {}", format_duration(avg));
180 } else {
181 println!("\nAverage resolution time: -");
182 }
183 Ok(())
184 }
185
186 fn format_duration(secs: i64) -> String {
187 let d = secs / 86_400;
188 let h = (secs % 86_400) / 3_600;
189 let m = (secs % 3_600) / 60;
190 if d > 0 {
191 format!("{d}d {h}h")
192 } else if h > 0 {
193 format!("{h}h {m}m")
194 } else {
195 format!("{m}m")
196 }
197 }
198
199 fn write_csv<W: Write>(w: &mut W, tickets: &[types::Ticket]) -> Result<()> {
200 writeln!(
201 w,
202 "id,title,body,priority,status,channel,node_id,source,source_ref,created_at,updated_at,resolved_at"
203 )?;
204 for t in tickets {
205 writeln!(
206 w,
207 "{},{},{},{},{},{},{},{},{},{},{},{}",
208 csv_escape(&t.id),
209 csv_escape(&t.title),
210 csv_escape(t.body.as_deref().unwrap_or("")),
211 t.priority,
212 t.status,
213 t.channel,
214 csv_escape(&t.node_id),
215 csv_escape(t.source.as_deref().unwrap_or("")),
216 csv_escape(t.source_ref.as_deref().unwrap_or("")),
217 t.created_at.to_rfc3339(),
218 t.updated_at.to_rfc3339(),
219 t.resolved_at.map(|d| d.to_rfc3339()).unwrap_or_default(),
220 )?;
221 }
222 Ok(())
223 }
224
225 fn csv_escape(s: &str) -> String {
226 if s.contains(',') || s.contains('"') || s.contains('\n') {
227 let escaped = s.replace('"', "\"\"");
228 format!("\"{escaped}\"")
229 } else {
230 s.to_string()
231 }
232 }
233
234 fn truncate(s: &str, max: usize) -> String {
235 if s.len() <= max {
236 s.to_string()
237 } else {
238 format!("{}...", &s[..max - 3])
239 }
240 }
241