1use anyhow::{bail, Result};
6use chrono::{DateTime, Local, NaiveDateTime, TimeZone};
7use serde_json::Value;
8use std::time::Duration;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Held {
13 pub id: String,
14 pub holder: String,
15 pub title: String,
16 pub last: Option<i64>,
18}
19
20pub fn parse_idle(s: &str) -> Result<Duration> {
22 let s = s.trim();
23 let (n, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()));
24 let n: u64 = n
25 .parse()
26 .map_err(|_| anyhow::anyhow!("reap: --idle {s:?} wants a number and s, m, h or d"))?;
27 let secs = match unit {
28 "s" => n,
29 "m" | "" => n * 60,
30 "h" => n * 3600,
31 "d" => n * 86400,
32 _ => bail!("reap: --idle {s:?} wants a number and s, m, h or d"),
33 };
34 Ok(Duration::from_secs(secs))
35}
36
37pub fn org_ts(s: &str) -> Option<i64> {
39 let t = s
40 .trim()
41 .trim_matches(|c| c == '[' || c == '<' || c == ']' || c == '>');
42 let mut parts = t.split_whitespace();
43 let date = parts.next()?;
44 let time = parts.find(|p| p.contains(':')).unwrap_or("00:00");
45 let naive = NaiveDateTime::parse_from_str(&format!("{date} {time}"), "%Y-%m-%d %H:%M").ok()?;
46 Local
47 .from_local_datetime(&naive)
48 .earliest()
49 .map(|d| d.timestamp())
50}
51
52#[must_use]
55pub fn seat_of(holder: &str) -> String {
56 let seat = crate::canonical_seat_name(holder);
57 if seat.is_empty() {
58 holder
59 .split('@')
60 .next()
61 .unwrap_or(holder)
62 .trim()
63 .to_string()
64 } else {
65 seat
66 }
67}
68
69pub fn held_from_json(json: &str) -> Result<Vec<Held>> {
71 let v: Value = serde_json::from_str(json)?;
72 Ok(v.as_array()
73 .into_iter()
74 .flatten()
75 .filter_map(|c| {
76 Some(Held {
77 id: c["id"].as_str()?.to_string(),
78 holder: c["holder"].as_str()?.to_string(),
79 title: c["title"].as_str().unwrap_or("").to_string(),
80 last: c["claimed_at"].as_str().and_then(org_ts),
81 })
82 })
83 .collect())
84}
85
86pub fn idle_claims(held: &[Held], idle: Duration, now: i64) -> Vec<Held> {
88 let cut = now - idle.as_secs() as i64;
89 held.iter()
90 .filter(|h| h.last.is_none_or(|t| t < cut))
91 .cloned()
92 .collect()
93}
94
95fn last_commit(id: &str) -> Option<i64> {
97 let root = std::env::var("VISSUE_ROOT").ok()?;
98 let out = std::process::Command::new("git")
99 .args(["-C", &root, "log", "-1", "--format=%ct", "-F", "--grep", id])
100 .output()
101 .ok()?;
102 String::from_utf8_lossy(&out.stdout).trim().parse().ok()
103}
104
105fn when(t: Option<i64>) -> String {
106 t.and_then(|t| DateTime::from_timestamp(t, 0))
107 .map(|d| d.with_timezone(&Local).format("%Y-%m-%d %H:%M").to_string())
108 .unwrap_or_else(|| "never".to_string())
109}
110
111pub fn reap(idle: Duration, apply: bool) -> Result<String> {
114 let said = crate::run_captured("vissue", &["claims", "--json"])?;
115 let mut held = held_from_json(&said.stdout)?;
116 for h in &mut held {
117 if let Some(c) = last_commit(&h.id) {
118 h.last = Some(h.last.map_or(c, |t| t.max(c)));
119 }
120 }
121 let now = Local::now().timestamp();
122 let idle_ones = idle_claims(&held, idle, now);
123 let mut out = String::new();
124 let hours = idle.as_secs() as f64 / 3600.0;
125 for h in &idle_ones {
126 let seat = seat_of(&h.holder);
127 let note = format!(
128 "auto-released {}: idle since {} (over {hours:.1}h). Run `ljos claim {}` to pick it up again, or `ljos finish {} --status done` when it is done.",
129 h.id, when(h.last), h.id, h.id
130 );
131 if !apply {
132 out.push_str(&format!(
133 "would release {} held by {} (last {}) and mail {seat}\n",
134 h.id,
135 h.holder,
136 when(h.last)
137 ));
138 continue;
139 }
140 match crate::release(&h.id, &h.holder) {
141 Ok(s) => {
142 out.push_str(&s);
143 out.push_str(&crate::persist_tracker(&h.id, "released"));
144 }
145 Err(e) => {
146 out.push_str(&format!(
147 "reap: {} held by {}: release refused: {e}\n",
148 h.id, h.holder
149 ));
150 continue;
151 }
152 }
153 let mail = crate::mail::send(&crate::mail::Send {
154 seat: Some(&seat),
155 group: None,
156 text: ¬e,
157 interrupt: false,
158 issue: Some(&h.id),
159 new_seat: true,
160 });
161 match mail {
162 Ok(s) => out.push_str(&s),
163 Err(e) => out.push_str(&format!("reap: {}: mail to {seat} failed: {e}\n", h.id)),
164 }
165 }
166 out.push_str(&enforce_quorum_deadlines(apply));
167 out.push_str(&crate::converge::reap_runaways(apply));
168 out.push_str(&format!(
169 "reap: {} of {} claims idle over {hours:.1}h{}\n",
170 idle_ones.len(),
171 held.len(),
172 if apply {
173 ""
174 } else {
175 "; dry run, pass --apply to release"
176 }
177 ));
178 Ok(out)
179}
180
181pub const QUORUM_NOTE: &str = "quorum: ";
183
184#[must_use]
188pub fn due_quorums(export: &str, now: DateTime<chrono::Utc>) -> Vec<(String, usize, String)> {
189 let mut due = Vec::new();
190 for line in export.lines() {
191 let Ok(v) = serde_json::from_str::<Value>(line) else {
192 continue;
193 };
194 if matches!(v["state"].as_str(), Some("DONE" | "CANCELLED")) {
195 continue;
196 }
197 let notes: Vec<&str> = v["logbook"]
198 .as_array()
199 .into_iter()
200 .flatten()
201 .filter_map(|e| e["note"].as_str())
202 .collect();
203 if notes.iter().any(|n| n.contains("quorum timed out")) {
204 continue;
205 }
206 let Some(rest) = notes
208 .iter()
209 .find_map(|n| n.trim().strip_prefix(QUORUM_NOTE))
210 else {
211 continue;
212 };
213 let Some((n, at)) = rest.split_once(" by ") else {
214 continue;
215 };
216 let (Ok(n), Ok(at_t)) = (
217 n.trim().parse::<usize>(),
218 DateTime::parse_from_rfc3339(at.trim()),
219 ) else {
220 continue;
221 };
222 if at_t < now {
223 due.push((
224 v["id"].as_str().unwrap_or("").to_string(),
225 n,
226 at.trim().to_string(),
227 ));
228 }
229 }
230 due
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum QuorumGate {
237 Met { have: usize },
239 Short { have: usize, needed: usize },
241 Lapsed { have: usize, needed: usize },
244}
245
246pub fn gate_quorum(id: &str, needed: usize, deadline: Option<&str>) -> Result<QuorumGate> {
254 let raw = crate::raw_ballot_rows(id)
255 .map_err(|e| anyhow::anyhow!("quorum: the tracker did not list ballots on {id}: {e:#}"))?;
256 let have = crate::panel_gate::counted_ballots(id, &raw).len();
257 if have >= needed {
258 return Ok(QuorumGate::Met { have });
259 }
260 let past = deadline
261 .map(str::trim)
262 .filter(|s| !s.is_empty())
263 .and_then(|d| DateTime::parse_from_rfc3339(d).ok())
264 .is_some_and(|at| at < chrono::Utc::now());
265 if !past {
266 return Ok(QuorumGate::Short { have, needed });
267 }
268 if !quorum_already_noted(id) {
269 let note = format!("quorum timed out: {have} of {needed}");
270 crate::run_captured("vissue", &["note", id, ¬e])
271 .map_err(|e| anyhow::anyhow!("quorum: the lapse on {id} was not noted: {e:#}"))?;
272 }
273 Ok(QuorumGate::Lapsed { have, needed })
274}
275
276fn quorum_already_noted(id: &str) -> bool {
277 crate::issue_record(id)
278 .ok()
279 .into_iter()
280 .flat_map(|v| crate::ballot_sig::notes_of(&v))
281 .any(|note| note.contains("quorum timed out"))
282}
283
284fn enforce_quorum_deadlines(apply: bool) -> String {
288 let Ok(said) = crate::run_captured("vissue", &["export"]) else {
289 return String::new();
290 };
291 let mut out = String::new();
292 for (id, n, at) in due_quorums(&said.stdout, chrono::Utc::now()) {
293 if !apply {
294 out.push_str(&format!("would check {id}: quorum {n} was due {at}\n"));
295 continue;
296 }
297 match gate_quorum(&id, n, Some(&at)) {
298 Ok(QuorumGate::Lapsed { .. }) => {
299 out.push_str(&format!(
300 "lapsed {id}: fewer than {n} ballots by {at}; noted, nothing decided\n"
301 ));
302 out.push_str(&crate::persist_tracker(&id, "quorum timed out"));
303 }
304 Ok(QuorumGate::Met { .. }) => {
305 out.push_str(&format!("{id}: quorum {n} met by {at}\n"));
306 }
307 Ok(QuorumGate::Short { have, needed }) => {
308 out.push_str(&format!("{id}: quorum short, {have} of {needed} by {at}\n"))
309 }
310 Err(e) => out.push_str(&format!("reap: {id}: quorum gate did not run: {e:#}\n")),
311 }
312 }
313 out
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn a_vote_past_its_deadline_is_due_once() {
322 let past = r#"{"id":"d-1","state":"TODO","logbook":[{"note":"quorum: 3 by 2000-01-01T00:00:00Z"}]}"#;
323 let future = r#"{"id":"d-2","state":"TODO","logbook":[{"note":"quorum: 3 by 2999-01-01T00:00:00Z"}]}"#;
324 let lapsed = r#"{"id":"d-3","state":"TODO","logbook":[{"note":"quorum timed out: 1 of 3"},{"note":"quorum: 3 by 2000-01-01T00:00:00Z"}]}"#;
325 let closed = r#"{"id":"d-4","state":"DONE","logbook":[{"note":"quorum: 3 by 2000-01-01T00:00:00Z"}]}"#;
326 let plain = r#"{"id":"d-5","state":"TODO","logbook":[]}"#;
327 let all = [past, future, lapsed, closed, plain].join("\n");
328 let due = due_quorums(&all, chrono::Utc::now());
329 assert_eq!(
330 due,
331 vec![("d-1".to_string(), 3, "2000-01-01T00:00:00Z".to_string())]
332 );
333 }
334
335 #[test]
336 fn idle_parses_units() {
337 assert_eq!(parse_idle("6h").unwrap().as_secs(), 21600);
338 assert_eq!(parse_idle("90m").unwrap().as_secs(), 5400);
339 assert_eq!(parse_idle("2d").unwrap().as_secs(), 172800);
340 assert!(parse_idle("6x").is_err());
341 assert!(parse_idle("h").is_err());
342 }
343
344 #[test]
345 fn holder_names_its_seat() {
346 assert_eq!(seat_of("eessi@main"), "eessi");
347 assert_eq!(seat_of("inky"), "inky");
348 assert_eq!(seat_of("inky-n9o"), "inky");
349 assert_eq!(seat_of("ljos-bot@q4s23e2cxd"), "ljosbot");
350 }
351
352 #[test]
353 fn only_old_claims_are_idle() {
354 let json = r#"[
355 {"id":"a-1","holder":"inky","title":"old","claimed_at":"[2026-10-13 Tue 06:00]"},
356 {"id":"a-2","holder":"eessi@main","title":"new","claimed_at":"[2026-10-13 Tue 16:00]"},
357 {"id":"a-3","holder":"travel","title":"no time"}
358 ]"#;
359 let held = held_from_json(json).unwrap();
360 let now = org_ts("[2026-10-13 Tue 19:00]").unwrap();
361 let ids: Vec<_> = idle_claims(&held, parse_idle("6h").unwrap(), now)
362 .into_iter()
363 .map(|h| h.id)
364 .collect();
365 assert_eq!(ids, ["a-1", "a-3"]);
366 }
367}
368
369#[cfg(test)]
370mod log_tests {
371 #[test]
372 fn recall_log_appends_one_line_per_call() {
373 let dir = tempfile::tempdir().unwrap();
376 let logs = dir.path().join("ljos");
377 crate::log_recall_in(&logs, "recall", "a-1");
378 crate::log_recall_in(&logs, "search", "two\nlines");
379 let log = std::fs::read_to_string(logs.join("recall.log")).unwrap();
380 let lines: Vec<_> = log.lines().collect();
381 assert_eq!(lines.len(), 2);
382 assert!(lines[0].ends_with(" recall a-1"));
383 assert!(lines[1].ends_with(" search two lines"));
384 }
385}