Skip to main content

max / makenotwork

9.0 KB · 304 lines History Blame Raw
1 //! wam (Whack-a-Mole) — distributed ticket manager binary.
2 //!
3 //! # Design
4 //!
5 //! The painhours urgency model, the TUI/CLI/API surfaces, and the SQLite storage
6 //! are described in the maintainer wiki.
7 //! <!-- wiki: wam-overview -->
8
9 mod api;
10 mod cli;
11 mod db;
12 mod types;
13
14 use std::io::Write;
15
16 use clap::Parser;
17 use cli::{Command, ExportFormat};
18 use color_eyre::eyre::{Result, WrapErr};
19 use db::ListFilter;
20 use types::{NewTicket, Status};
21
22 fn main() -> Result<()> {
23 color_eyre::install()?;
24 let cli = cli::Cli::parse();
25 let conn = db::open_db()?;
26 let node_id = db::get_or_create_node_id(&conn)?;
27
28 match cli.command {
29 // No subcommand: print the open-ticket list. The interactive TUI was
30 // removed (2026-07-23) as wam thins toward an automated-ingest store;
31 // the CLI is the reader for now.
32 None => {
33 let tickets = db::list_tickets(&conn, &ListFilter::default())?;
34 print_ticket_list(&tickets);
35 }
36
37 Some(Command::Create {
38 title,
39 body,
40 pain,
41 scale,
42 channel,
43 source,
44 source_ref,
45 }) => {
46 let ticket = db::create_ticket(
47 &conn,
48 &NewTicket {
49 title,
50 body,
51 pain,
52 scale,
53 channel,
54 source: Some(source),
55 source_ref,
56 },
57 &node_id,
58 )?;
59 println!(
60 "created {} [{}] ph:{} ({})",
61 ticket.short_id(),
62 ticket.channel,
63 ticket.painhours(),
64 ticket.title,
65 );
66 }
67
68 Some(Command::List {
69 status,
70 priority,
71 channel,
72 source,
73 }) => {
74 let tickets = db::list_tickets(
75 &conn,
76 &ListFilter {
77 status,
78 priority,
79 channel,
80 source: source.as_deref(),
81 ..Default::default()
82 },
83 )?;
84 print_ticket_list(&tickets);
85 }
86
87 Some(Command::Show { id }) => {
88 let t = db::get_ticket(&conn, &id)?;
89 println!("ID: {}", t.id);
90 println!("Title: {}", t.title);
91 println!("Channel: {}", t.channel);
92 println!(
93 "Painhours: {} ({} band) [pain {} x scale {} x {}wk]",
94 t.painhours(),
95 t.band(),
96 t.pain,
97 t.scale,
98 t.age_weeks(),
99 );
100 println!("Status: {} {}", t.status.indicator(), t.status);
101 println!("Node: {}", t.node_id);
102 println!("Source: {}", t.source.as_deref().unwrap_or("-"));
103 println!("Ref: {}", t.source_ref.as_deref().unwrap_or("-"));
104 println!("Created: {}", t.created_at.format("%Y-%m-%d %H:%M UTC"));
105 println!("Updated: {}", t.updated_at.format("%Y-%m-%d %H:%M UTC"));
106 if let Some(ref resolved) = t.resolved_at {
107 println!("Resolved: {}", resolved.format("%Y-%m-%d %H:%M UTC"));
108 }
109 if let Some(ref body) = t.body {
110 println!("\n{body}");
111 }
112 }
113
114 Some(Command::Resolve { id }) => {
115 let t = db::get_ticket(&conn, &id)?;
116 db::update_status(&conn, &t.id, Status::Resolved)?;
117 println!("resolved {} ({})", t.short_id(), t.title);
118 }
119
120 Some(Command::Close { id }) => {
121 let t = db::get_ticket(&conn, &id)?;
122 db::update_status(&conn, &t.id, Status::Closed)?;
123 println!("closed {} ({})", t.short_id(), t.title);
124 }
125
126 Some(Command::Serve { port, peer, token }) => {
127 let rt = tokio::runtime::Runtime::new()?;
128 rt.block_on(api::serve(conn, port, peer, token))?;
129 }
130
131 Some(Command::Stats) => print_stats(&conn)?,
132
133 Some(Command::Export { format, output }) => {
134 let tickets = db::list_tickets(&conn, &ListFilter::default())?;
135 let mut writer: Box<dyn Write> = match output {
136 Some(path) => Box::new(
137 std::fs::File::create(&path)
138 .wrap_err_with(|| format!("create {}", path.display()))?,
139 ),
140 None => Box::new(std::io::stdout().lock()),
141 };
142 match format {
143 ExportFormat::Json => {
144 serde_json::to_writer_pretty(&mut writer, &tickets)?;
145 writeln!(writer)?;
146 }
147 ExportFormat::Csv => write_csv(&mut writer, &tickets)?,
148 }
149 }
150
151 Some(Command::Prune {
152 older_than,
153 status,
154 dry_run,
155 }) => {
156 let dur = cli::parse_duration(&older_than).map_err(|e| color_eyre::eyre::eyre!(e))?;
157 if dry_run {
158 let cutoff = chrono::Utc::now() - dur;
159 let candidates: Vec<_> = db::list_tickets(
160 &conn,
161 &ListFilter {
162 status: Some(status),
163 ..Default::default()
164 },
165 )?
166 .into_iter()
167 .filter(|t| t.updated_at < cutoff)
168 .collect();
169 if candidates.is_empty() {
170 println!("no tickets to prune");
171 } else {
172 for t in &candidates {
173 println!("{} {} ({})", t.short_id(), t.title, t.age());
174 }
175 println!("\nwould delete {} ticket(s)", candidates.len());
176 }
177 } else {
178 let n = db::prune_tickets(&conn, dur, status)?;
179 println!("pruned {n} ticket(s)");
180 }
181 }
182 }
183
184 Ok(())
185 }
186
187 /// Print a ticket list as an aligned table, or "no tickets" when empty. Shared
188 /// by `wam list` and the no-subcommand default view.
189 fn print_ticket_list(tickets: &[types::Ticket]) {
190 if tickets.is_empty() {
191 println!("no tickets");
192 return;
193 }
194
195 println!(
196 "{:<10} {:<8} {:<5} {:<12} {:<30} Node",
197 "ID", "Channel", "PH", "Status", "Title"
198 );
199 println!("{}", "-".repeat(80));
200 for t in tickets {
201 println!(
202 "{:<10} {:<8} {:<5} {:<12} {:<30} {}",
203 t.short_id(),
204 t.channel,
205 t.painhours(),
206 t.status,
207 truncate(&t.title, 30),
208 t.short_node(),
209 );
210 }
211 println!("\n{} ticket(s)", tickets.len());
212 }
213
214 fn print_stats(conn: &rusqlite::Connection) -> Result<()> {
215 let s = db::stats(conn)?;
216 println!("Total: {}", s.total);
217
218 if !s.by_status.is_empty() {
219 println!("\nBy status:");
220 for (st, n) in &s.by_status {
221 println!(" {st:<12} {n}");
222 }
223 }
224
225 if !s.open_by_priority.is_empty() {
226 println!("\nOpen by painhours band:");
227 for (p, n) in &s.open_by_priority {
228 println!(" {p:<9} {n}");
229 }
230 }
231
232 if !s.open_by_source.is_empty() {
233 println!("\nOpen by source:");
234 for (src, n) in &s.open_by_source {
235 println!(" {src:<20} {n}");
236 }
237 }
238
239 if let Some(avg) = s.avg_resolution_seconds {
240 println!("\nAverage resolution time: {}", format_duration(avg));
241 } else {
242 println!("\nAverage resolution time: -");
243 }
244 Ok(())
245 }
246
247 fn format_duration(secs: i64) -> String {
248 let d = secs / 86_400;
249 let h = (secs % 86_400) / 3_600;
250 let m = (secs % 3_600) / 60;
251 if d > 0 {
252 format!("{d}d {h}h")
253 } else if h > 0 {
254 format!("{h}h {m}m")
255 } else {
256 format!("{m}m")
257 }
258 }
259
260 fn write_csv<W: Write>(w: &mut W, tickets: &[types::Ticket]) -> Result<()> {
261 writeln!(
262 w,
263 "id,title,body,pain,scale,painhours,status,channel,node_id,source,source_ref,created_at,updated_at,resolved_at"
264 )?;
265 for t in tickets {
266 writeln!(
267 w,
268 "{},{},{},{},{},{},{},{},{},{},{},{},{},{}",
269 csv_escape(&t.id),
270 csv_escape(&t.title),
271 csv_escape(t.body.as_deref().unwrap_or("")),
272 t.pain,
273 t.scale,
274 t.painhours(),
275 t.status,
276 t.channel,
277 csv_escape(&t.node_id),
278 csv_escape(t.source.as_deref().unwrap_or("")),
279 csv_escape(t.source_ref.as_deref().unwrap_or("")),
280 t.created_at.to_rfc3339(),
281 t.updated_at.to_rfc3339(),
282 t.resolved_at.map(|d| d.to_rfc3339()).unwrap_or_default(),
283 )?;
284 }
285 Ok(())
286 }
287
288 fn csv_escape(s: &str) -> String {
289 if s.contains(',') || s.contains('"') || s.contains('\n') {
290 let escaped = s.replace('"', "\"\"");
291 format!("\"{escaped}\"")
292 } else {
293 s.to_string()
294 }
295 }
296
297 fn truncate(s: &str, max: usize) -> String {
298 if s.len() <= max {
299 s.to_string()
300 } else {
301 format!("{}...", &s[..max - 3])
302 }
303 }
304