Skip to main content

max / makenotwork

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