1use anyhow::Result;
20use serde::Deserialize;
21use serde_json::Value;
22use std::collections::{BTreeMap, BTreeSet};
23use std::path::PathBuf;
24
25pub const BELOW_CHILD: &[&str] = &["nit", "wording", "style"];
27const DISSENT_TITLE_LIMIT: usize = 120;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Severity {
34 Nit,
35 Wording,
36 Style,
37 Blocking,
38}
39
40impl Severity {
41 #[must_use]
42 pub fn parse(word: &str) -> Option<Self> {
43 match word.trim().to_ascii_lowercase().as_str() {
44 "nit" => Some(Self::Nit),
45 "wording" => Some(Self::Wording),
46 "style" => Some(Self::Style),
47 "blocking" => Some(Self::Blocking),
48 _ => None,
49 }
50 }
51
52 #[must_use]
53 pub fn word(self) -> &'static str {
54 match self {
55 Self::Nit => "nit",
56 Self::Wording => "wording",
57 Self::Style => "style",
58 Self::Blocking => "blocking",
59 }
60 }
61
62 #[must_use]
64 pub fn is_note(self) -> bool {
65 !matches!(self, Self::Blocking)
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Objection {
72 pub persona: String,
73 pub severity: Severity,
74 pub reason: String,
77 pub text: String,
78 pub target: String,
79 pub held: bool,
82}
83
84impl Objection {
85 #[must_use]
86 pub fn nit(persona: &str, text: &str, target: &str) -> Self {
87 Self::noted(persona, Severity::Nit, "", text, target)
88 }
89
90 #[must_use]
91 pub fn wording(persona: &str, text: &str, target: &str) -> Self {
92 Self::noted(persona, Severity::Wording, "", text, target)
93 }
94
95 #[must_use]
96 pub fn style(persona: &str, text: &str, target: &str) -> Self {
97 Self::noted(persona, Severity::Style, "", text, target)
98 }
99
100 #[must_use]
101 pub fn blocking(persona: &str, reason: &str, text: &str, target: &str) -> Self {
102 Self::noted(persona, Severity::Blocking, reason, text, target)
103 }
104
105 fn noted(persona: &str, severity: Severity, reason: &str, text: &str, target: &str) -> Self {
106 let reason = reason.trim().to_string();
107 let severity = if severity == Severity::Blocking && reason.is_empty() {
108 Severity::Nit
109 } else {
110 severity
111 };
112 Self {
113 persona: persona.trim().to_string(),
114 severity,
115 reason,
116 text: text.trim().to_string(),
117 target: target.trim().to_string(),
118 held: false,
119 }
120 }
121
122 #[must_use]
124 pub fn files_as_child(&self) -> bool {
125 self.severity == Severity::Blocking && !self.reason.trim().is_empty()
126 }
127
128 #[must_use]
131 pub fn may_file(&self, passed: bool, depth: usize) -> bool {
132 if self.files_as_child() {
133 return true;
134 }
135 self.held && !passed && depth == 0
136 }
137
138 #[must_use]
141 pub fn key(&self) -> (String, String) {
142 let text = if self.files_as_child() || self.held {
143 dissent_key_text(&self.text)
144 } else {
145 normalize(&self.text)
146 };
147 (text, normalize(&self.target))
148 }
149}
150
151#[must_use]
154pub fn normalize(text: &str) -> String {
155 text.chars()
156 .map(|c| {
157 if c.is_ascii_alphanumeric() {
158 c.to_ascii_lowercase()
159 } else {
160 ' '
161 }
162 })
163 .collect::<String>()
164 .split_whitespace()
165 .collect::<Vec<_>>()
166 .join(" ")
167}
168
169fn dissent_key_text(text: &str) -> String {
170 normalize(&clip(text, DISSENT_TITLE_LIMIT))
171}
172
173#[must_use]
178pub fn parse_objection(line: &str, default_target: &str) -> Option<Objection> {
179 let line = line.trim().trim_start_matches('-').trim();
180 let rest = line
181 .strip_prefix("finding:")
182 .or_else(|| line.strip_prefix("objection:"))
183 .map(str::trim)
184 .filter(|s| !s.is_empty())?;
185 let (persona, rest) = rest.split_once(':')?;
186 let persona = persona.trim();
187 if persona.is_empty() {
188 return None;
189 }
190 let rest = rest.trim();
191 let (severity, rest) = match rest.split_once(':') {
192 Some((word, after)) => match Severity::parse(word) {
193 Some(severity) => (severity, after.trim()),
194 None => (Severity::Nit, rest),
195 },
196 None => (Severity::Nit, rest),
197 };
198 let (reason, rest) = if severity == Severity::Blocking {
199 match rest.split_once("::") {
200 Some((reason, text)) => (reason.trim().to_string(), text.trim()),
201 None => (String::new(), rest),
202 }
203 } else {
204 (String::new(), rest)
205 };
206 let (text, target) = match rest.rsplit_once(" @ ") {
207 Some((text, target)) if !target.trim().is_empty() => (text.trim(), target.trim()),
208 _ => (rest, default_target),
209 };
210 if text.is_empty() {
211 return None;
212 }
213 Some(Objection::noted(persona, severity, &reason, text, target))
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct ScopeBudget {
220 pub max_children: usize,
221 pub max_descendants: usize,
222 pub max_wall_secs: Option<u64>,
223 pub max_tokens: Option<u64>,
224}
225
226impl ScopeBudget {
227 #[must_use]
229 pub fn panel() -> Self {
230 Self {
231 max_children: 8,
232 max_descendants: 12,
233 max_wall_secs: None,
234 max_tokens: None,
235 }
236 }
237
238 #[must_use]
240 pub fn workflow() -> Self {
241 Self {
242 max_children: 24,
243 max_descendants: 48,
244 max_wall_secs: None,
245 max_tokens: None,
246 }
247 }
248
249 fn overlay(&mut self, file: &ScopeFile) {
250 if let Some(n) = file.max_children {
251 self.max_children = n;
252 }
253 if let Some(n) = file.max_descendants {
254 self.max_descendants = n;
255 }
256 if file.max_wall_secs.is_some() {
257 self.max_wall_secs = file.max_wall_secs;
258 }
259 if file.max_tokens.is_some() {
260 self.max_tokens = file.max_tokens;
261 }
262 }
263}
264
265#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct PanelBudget {
269 pub panel: ScopeBudget,
270 pub workflow: ScopeBudget,
271}
272
273impl Default for PanelBudget {
274 fn default() -> Self {
275 Self {
276 panel: ScopeBudget::panel(),
277 workflow: ScopeBudget::workflow(),
278 }
279 }
280}
281
282#[derive(Debug, Clone, Deserialize, Default)]
283struct BudgetFile {
284 #[serde(default)]
285 panel: ScopeFile,
286 #[serde(default)]
287 workflow: ScopeFile,
288}
289
290#[derive(Debug, Clone, Deserialize, Default)]
291struct ScopeFile {
292 max_children: Option<usize>,
293 max_descendants: Option<usize>,
294 max_wall_secs: Option<u64>,
295 max_tokens: Option<u64>,
296}
297
298#[must_use]
302pub fn budget_from_toml(text: &str) -> PanelBudget {
303 let mut budget = PanelBudget::default();
304 let Ok(file) = toml::from_str::<BudgetFile>(text) else {
305 return budget;
306 };
307 budget.panel.overlay(&file.panel);
308 budget.workflow.overlay(&file.workflow);
309 budget
310}
311
312#[must_use]
316pub fn load_budget() -> PanelBudget {
317 let mut budget = PanelBudget::default();
318 for path in config_paths(None) {
319 let Ok(text) = std::fs::read_to_string(&path) else {
320 continue;
321 };
322 let Ok(file) = toml::from_str::<BudgetFile>(&text) else {
323 continue;
324 };
325 budget.panel.overlay(&file.panel);
326 budget.workflow.overlay(&file.workflow);
327 }
328 budget
329}
330
331pub(crate) fn config_paths(own: Option<&str>) -> Vec<PathBuf> {
334 let mut paths = Vec::new();
335 if let Some(base) = config_base() {
336 paths.push(base.join("vissue").join("config.toml"));
337 paths.push(base.join("ljos").join("ljos.toml"));
338 if let Some(name) = own {
339 paths.push(base.join("ljos").join(name));
340 }
341 }
342 paths.push(PathBuf::from("vissue.toml"));
343 paths.push(PathBuf::from("ljos.toml"));
344 if let Some(path) = std::env::var_os("VISSUE_CONFIG").filter(|p| !p.is_empty()) {
345 paths.push(PathBuf::from(path));
346 }
347 if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|p| !p.is_empty()) {
348 paths.push(PathBuf::from(path));
349 }
350 paths
351}
352
353pub(crate) fn config_base() -> Option<PathBuf> {
354 std::env::var_os("XDG_CONFIG_HOME")
355 .map(PathBuf::from)
356 .filter(|p| p.is_absolute())
357 .or_else(|| {
358 std::env::var_os("HOME")
359 .map(|h| PathBuf::from(h).join(".config"))
360 .filter(|p| p.is_absolute())
361 })
362}
363
364#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct PanelState {
367 pub round: usize,
368 pub passed: bool,
369 pub dissent_depth: usize,
371 pub children_filed: usize,
372 pub open_children: usize,
373 pub descendants: usize,
375 pub awaiting_ballots: bool,
377 pub seen: Vec<(String, String)>,
378 pub wall_secs: Option<u64>,
379 pub tokens: Option<u64>,
380 pub objections: Vec<Objection>,
381}
382
383impl PanelState {
384 #[must_use]
385 pub fn at_round(round: usize) -> Self {
386 Self {
387 round,
388 passed: false,
389 dissent_depth: 0,
390 children_filed: 0,
391 open_children: 0,
392 descendants: 0,
393 awaiting_ballots: false,
394 seen: Vec::new(),
395 wall_secs: None,
396 tokens: None,
397 objections: Vec::new(),
398 }
399 }
400}
401
402#[derive(Debug, Clone, PartialEq, Eq)]
404pub enum Fate {
405 Note(Objection),
406 File(Objection),
407 Duplicate(Objection),
408 Listed(Objection),
411}
412
413#[derive(Debug, Clone, PartialEq, Eq)]
415pub struct Disposal {
416 pub fates: Vec<Fate>,
417 pub close: bool,
418 pub summary: String,
419}
420
421#[derive(Debug, Clone, PartialEq, Eq)]
424pub enum RoundPlan {
425 OpenRound {
426 n: usize,
427 },
428 FileBlocking {
431 objections: Vec<Objection>,
432 left_out: Vec<Objection>,
433 },
434 Hold {
435 why: String,
436 },
437 Passed,
438}
439
440fn over_budget(state: &PanelState, limits: &ScopeBudget) -> bool {
441 if state.children_filed >= limits.max_children {
442 return true;
443 }
444 if let (Some(used), Some(max)) = (state.wall_secs, limits.max_wall_secs) {
445 if used >= max {
446 return true;
447 }
448 }
449 if let (Some(used), Some(max)) = (state.tokens, limits.max_tokens) {
450 if used >= max {
451 return true;
452 }
453 }
454 false
455}
456
457fn name_objections(objections: &[Objection]) -> String {
458 objections
459 .iter()
460 .map(|objection| objection.text.as_str())
461 .collect::<Vec<_>>()
462 .join("; ")
463}
464
465fn clip(text: &str, n: usize) -> String {
466 let mut out: String = text.chars().take(n).collect();
467 if text.chars().count() > n {
468 out.push('…');
469 }
470 out
471}
472
473#[must_use]
477pub fn dispose(state: &PanelState, limits: &ScopeBudget) -> Disposal {
478 let mut fates = Vec::new();
479 let mut batch = state.seen.iter().cloned().collect::<BTreeSet<_>>();
480 let mut fileable = Vec::new();
481 for objection in &state.objections {
482 let key = objection.key();
483 if batch.contains(&key) {
484 fates.push(Fate::Duplicate(objection.clone()));
485 continue;
486 }
487 batch.insert(key);
488 if objection.may_file(state.passed, state.dissent_depth) {
489 fileable.push(objection.clone());
490 } else {
491 fates.push(Fate::Note(objection.clone()));
492 }
493 }
494 if over_budget(state, limits) {
495 for objection in &fileable {
496 fates.push(Fate::Listed(objection.clone()));
497 }
498 return Disposal {
499 fates,
500 close: false,
501 summary: String::new(),
502 };
503 }
504 let room = limits.max_children.saturating_sub(state.children_filed);
505 let mut listed = Vec::new();
506 for (i, objection) in fileable.into_iter().enumerate() {
507 if i < room {
508 fates.push(Fate::File(objection));
509 } else {
510 listed.push(objection);
511 }
512 }
513 for objection in listed {
514 fates.push(Fate::Listed(objection));
515 }
516 Disposal {
517 fates,
518 close: false,
519 summary: String::new(),
520 }
521}
522
523#[must_use]
530pub fn plan_round(state: &PanelState, limits: &ScopeBudget) -> RoundPlan {
531 if state.open_children > 0 {
532 return RoundPlan::Hold {
533 why: format!(
534 "{} {} still open",
535 state.open_children,
536 if state.open_children == 1 {
537 "child is"
538 } else {
539 "children are"
540 }
541 ),
542 };
543 }
544 if state.awaiting_ballots {
545 return RoundPlan::Hold {
546 why: "the panel has not all voted".into(),
547 };
548 }
549 let mut fileable = Vec::new();
550 let mut seen = state.seen.iter().cloned().collect::<BTreeSet<_>>();
551 for objection in &state.objections {
552 let key = objection.key();
553 if seen.contains(&key) {
554 continue;
555 }
556 seen.insert(key);
557 if objection.may_file(state.passed, state.dissent_depth) {
558 fileable.push(objection.clone());
559 }
560 }
561 if state.descendants >= limits.max_descendants && fileable.is_empty() {
562 return RoundPlan::Hold {
563 why: format!(
564 "{} descendants reached the limit of {}. A person has to act",
565 state.descendants, limits.max_descendants
566 ),
567 };
568 }
569 if !fileable.is_empty() {
570 let child_room = limits.max_children.saturating_sub(state.children_filed);
571 let desc_room = limits.max_descendants.saturating_sub(state.descendants);
572 let room = child_room.min(desc_room);
573 if room == 0 {
574 let named = name_objections(&fileable);
575 let why = if state.descendants >= limits.max_descendants {
576 format!(
577 "{} descendants reached the limit of {}. A person has to act. Not filed: {named}",
578 state.descendants, limits.max_descendants
579 )
580 } else {
581 format!(
582 "{} children filed, at the limit of {}. Not filed: {named}",
583 state.children_filed, limits.max_children
584 )
585 };
586 return RoundPlan::Hold { why };
587 }
588 let left_out = if fileable.len() > room {
589 fileable.split_off(room)
590 } else {
591 Vec::new()
592 };
593 return RoundPlan::FileBlocking {
594 objections: fileable,
595 left_out,
596 };
597 }
598 if state.passed {
599 return RoundPlan::Passed;
600 }
601 let n = if state.round == 0 { 1 } else { state.round + 1 };
602 RoundPlan::OpenRound { n }
603}
604
605#[must_use]
608pub fn substance_passed(choices: &[(String, String)]) -> bool {
609 if choices.is_empty() {
610 return false;
611 }
612 let norm = |s: &str| s.trim().to_ascii_lowercase();
613 let pass = choices.iter().filter(|(_, c)| norm(c) == "pass").count();
614 let revise = choices.iter().filter(|(_, c)| norm(c) == "revise").count();
615 if pass + revise == choices.len() {
616 return pass > revise && pass * 2 > choices.len();
617 }
618 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
619 for (_, choice) in choices {
620 *counts.entry(normalize(choice)).or_default() += 1;
621 }
622 let mut ranked: Vec<(String, usize)> = counts.into_iter().collect();
623 ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
624 ranked.len() == 1 || (ranked[0].1 > ranked[1].1 && ranked[0].1 * 2 > choices.len())
625}
626
627fn ballot_tie(choices: &[(String, String)]) -> bool {
630 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
631 for (_, choice) in choices {
632 *counts.entry(normalize(choice)).or_default() += 1;
633 }
634 let Some(max) = counts.values().copied().max() else {
635 return false;
636 };
637 counts.values().filter(|n| **n == max).count() > 1
638}
639
640#[must_use]
642pub fn objections_in(v: &Value, default_target: &str) -> Vec<Objection> {
643 let mut out = Vec::new();
644 let mut push = |text: &str| {
645 if let Some(objection) = parse_objection(text, default_target) {
646 out.push(objection);
647 }
648 };
649 for entry in v["logbook"].as_array().into_iter().flatten() {
650 if let Some(note) = entry["note"].as_str() {
651 push(note);
652 }
653 if let Some(raw) = entry["raw"].as_str() {
654 push(raw);
655 }
656 }
657 if let Some(body) = v["body"].as_str() {
658 for line in body.lines() {
659 push(line);
660 }
661 }
662 out
663}
664
665#[must_use]
668pub fn dissent_depth(v: &Value) -> usize {
669 for entry in v["logbook"].as_array().into_iter().flatten() {
670 let Some(note) = entry["note"].as_str() else {
671 continue;
672 };
673 if let Some(rest) = note.trim().strip_prefix("dissent-depth:") {
674 if let Ok(n) = rest.trim().parse::<usize>() {
675 return n;
676 }
677 }
678 }
679 let title = v["title"].as_str().unwrap_or("");
680 if title.trim_start().starts_with("Dissent holds") {
681 1
682 } else {
683 0
684 }
685}
686
687#[must_use]
689pub fn filed_key(title: &str, target: &str) -> Option<(String, String)> {
690 let title = title.trim();
691 let text = title
692 .strip_prefix("Dissent holds ")
693 .or_else(|| title.strip_prefix("fix: "))
694 .or_else(|| title.strip_prefix("fix:"))?;
695 let text = text.trim();
696 if text.is_empty() {
697 return None;
698 }
699 Some((dissent_key_text(text), normalize(target)))
700}
701
702fn project_of(v: &Value, id: &str) -> String {
703 v["project"]
704 .as_str()
705 .filter(|s| !s.trim().is_empty())
706 .map(str::to_string)
707 .or_else(|| id.rsplit_once('-').map(|(p, _)| p.to_string()))
708 .unwrap_or_default()
709}
710
711fn minority_options(choices: &[(String, String)]) -> Vec<String> {
715 let mut counts: BTreeMap<String, (String, usize)> = BTreeMap::new();
716 for (_, choice) in choices {
717 let shown = choice.trim();
718 let key = normalize(shown);
719 if key.is_empty() {
720 continue;
721 }
722 let row = counts.entry(key).or_insert_with(|| (shown.to_string(), 0));
723 row.1 += 1;
724 }
725 let Some(max) = counts.values().map(|(_, n)| *n).max() else {
726 return Vec::new();
727 };
728 if counts.values().filter(|(_, n)| *n == max).count() != 1 {
729 return Vec::new();
730 }
731 let mut out: Vec<String> = counts
732 .into_values()
733 .filter(|(_, n)| *n < max)
734 .map(|(shown, _)| shown)
735 .collect();
736 out.sort();
737 out
738}
739
740fn file_settled_minority(
743 issue: &str,
744 project: &str,
745 choices: &[(String, String)],
746 children: &[String],
747 depth: usize,
748) -> Vec<String> {
749 let mut lines = Vec::new();
750 for option in minority_options(choices) {
751 let title = format!("Dissent holds {option}");
752 if children.iter().any(|t| normalize(t) == normalize(&title)) {
753 lines.push(format!("{issue}: dissent {option} already filed"));
754 continue;
755 }
756 match file_child(issue, project, &title, depth) {
757 Ok(id) => lines.push(format!("filed {id} TODO [#C] {title}")),
758 Err(e) => lines.push(format!("dissent: file refused: {e:#}")),
759 }
760 }
761 lines
762}
763
764pub const ROUND_NOTE: &str = "panel-round:";
766
767#[must_use]
769pub fn recorded_round(v: &Value) -> usize {
770 v["logbook"]
771 .as_array()
772 .into_iter()
773 .flatten()
774 .filter_map(|e| e["note"].as_str())
775 .filter_map(|n| {
776 n.trim()
777 .strip_prefix(ROUND_NOTE)?
778 .trim()
779 .parse::<usize>()
780 .ok()
781 })
782 .max()
783 .unwrap_or(0)
784}
785
786fn is_closed(v: &Value) -> bool {
787 matches!(v["state"].as_str(), Some("DONE" | "CANCELLED"))
788}
789
790#[must_use]
795pub fn sitting_round(v: &Value) -> (usize, bool) {
796 if let Some(n) = crate::panel_round_number(v["title"].as_str().unwrap_or("")) {
797 return (n, false);
798 }
799 let last = recorded_round(v);
800 if is_closed(v) {
801 return (last, false);
802 }
803 (last + 1, true)
804}
805
806fn open_objection_children(issue: &str) -> Vec<(String, String)> {
810 let Ok(said) = crate::run_captured("vissue", &["children", issue, "--json"]) else {
811 return Vec::new();
812 };
813 let Ok(rows) = serde_json::from_str::<Vec<Value>>(&said.stdout) else {
814 return Vec::new();
815 };
816 let mut out = Vec::new();
817 for row in rows {
818 let Some(id) = row["id"].as_str() else {
819 continue;
820 };
821 let title = row["title"].as_str().map(str::to_string).or_else(|| {
822 crate::issue_record(id)
823 .ok()
824 .and_then(|v| v["title"].as_str().map(str::to_string))
825 });
826 let Some(title) = title else {
827 continue;
828 };
829 let titled = title.trim_start();
830 if !titled.starts_with("Dissent holds") && !titled.starts_with("fix:") {
831 continue;
832 }
833 let state = row["state"]
834 .as_str()
835 .map(str::to_string)
836 .or_else(|| {
837 crate::issue_record(id)
838 .ok()
839 .and_then(|v| v["state"].as_str().map(str::to_string))
840 })
841 .unwrap_or_default();
842 if matches!(state.as_str(), "DONE" | "CANCELLED") {
843 continue;
844 }
845 out.push((id.to_string(), title));
846 }
847 out
848}
849
850fn split_advice(issue: &str, round: usize) -> String {
853 let open = open_objection_children(issue);
854 if open.is_empty() {
855 return format!("seats still split at round {round}; open objections: none");
856 }
857 let named = open
858 .iter()
859 .map(|(id, title)| format!("{id} {title}"))
860 .collect::<Vec<_>>()
861 .join("; ");
862 format!("seats still split at round {round}; open objections: {named}")
863}
864
865#[must_use]
875pub fn record_dissents(issue: &str) -> Vec<String> {
876 let Ok(v) = crate::issue_record(issue) else {
877 return Vec::new();
878 };
879 let closed = is_closed(&v);
880 let (round, note_round) = sitting_round(&v);
881 let mut round_lines = Vec::new();
882 if note_round {
883 if let Err(e) = note_issue(issue, &format!("{ROUND_NOTE} {round}")) {
884 round_lines.push(format!("dissent: round note refused: {e:#}"));
885 }
886 }
887 let choices: Vec<(String, String)> = crate::ballot_rows(issue)
888 .into_iter()
889 .filter_map(|row| {
890 let agent = row.get("agent")?.as_str()?.to_string();
891 let choice = row.get("choice")?.as_str()?.to_string();
892 Some((agent, choice))
893 })
894 .collect();
895 let passed = substance_passed(&choices);
896 let tied = ballot_tie(&choices);
899 let mut objections = objections_in(&v, issue);
900 let noted: BTreeSet<(String, String)> = objections.iter().map(Objection::key).collect();
901 if !passed && !tied {
902 for (persona, choice) in &choices {
903 if normalize(choice) == "pass" {
904 continue;
905 }
906 let mut held = Objection::nit(persona, &format!("holds {choice}"), issue);
907 held.held = true;
908 if !noted.contains(&held.key())
909 && !objections
910 .iter()
911 .any(|o| o.persona == *persona && o.files_as_child())
912 {
913 objections.push(held);
914 }
915 }
916 }
917 let children = child_titles(issue);
918 let mut seen = Vec::new();
919 let mut children_filed = 0;
920 for title in &children {
921 if let Some(key) = filed_key(title, issue) {
922 seen.push(key);
923 children_filed += 1;
924 }
925 }
926 let state = PanelState {
927 round,
928 passed,
929 dissent_depth: dissent_depth(&v),
930 children_filed,
931 open_children: 0,
932 descendants: descendant_count(&dissent_tree_top(issue)),
935 awaiting_ballots: false,
936 seen,
937 wall_secs: None,
938 tokens: None,
939 objections,
940 };
941 let limits = load_budget().panel;
942 let mut descendants = state.descendants;
943 let mut filed_now = 0usize;
944 let disposal = dispose(&state, &limits);
945 let mut lines = round_lines;
946 let project = project_of(&v, issue);
947 let depth = state.dissent_depth;
948 for fate in disposal.fates {
949 match fate {
950 Fate::Duplicate(_) => {}
951 Fate::Note(objection) if noted.contains(&objection.key()) => {}
952 Fate::Note(objection) => match note_issue(issue, &format_note(&objection)) {
953 Ok(()) => lines.push(format!(
954 "noted {} from {} on {}: {}",
955 objection.severity.word(),
956 objection.persona,
957 objection.target,
958 objection.text
959 )),
960 Err(e) => lines.push(format!("dissent: note refused: {e:#}")),
961 },
962 Fate::File(objection) => {
963 let over_children = state.children_filed + filed_now >= limits.max_children;
964 let over_descendants = descendants >= limits.max_descendants;
965 if over_children || over_descendants {
966 say_unfiled(issue, &objection, ¬ed, &mut lines);
967 } else {
968 let title = dissent_title(&objection);
969 match file_child(issue, &project, &title, depth + 1) {
970 Ok(id) => {
971 filed_now += 1;
972 descendants += 1;
973 lines.push(format!("filed {id} TODO [#C] {title}"));
974 }
975 Err(e) => lines.push(format!("dissent: file refused: {e:#}")),
976 }
977 }
978 }
979 Fate::Listed(objection) => say_unfiled(issue, &objection, ¬ed, &mut lines),
980 }
981 }
982 if !passed && !closed {
986 lines.push(split_advice(issue, round));
987 }
988 if passed {
992 if descendants >= limits.max_descendants {
993 let pause = "paused for a person; the descendant limit is reached";
994 if !ticket_mentions(issue, pause) {
995 let _ = note_issue(issue, pause);
996 }
997 lines.push(pause.to_string());
998 } else {
999 lines.extend(file_settled_minority(
1000 issue,
1001 &project,
1002 &choices,
1003 &children,
1004 depth + 1,
1005 ));
1006 }
1007 }
1008 lines
1009}
1010
1011fn say_unfiled(
1012 issue: &str,
1013 objection: &Objection,
1014 noted: &BTreeSet<(String, String)>,
1015 lines: &mut Vec<String>,
1016) {
1017 if !noted.contains(&objection.key()) {
1018 match note_issue(issue, &format_note(objection)) {
1019 Ok(()) => lines.push(format!(
1020 "noted {} from {} on {}: {}",
1021 objection.severity.word(),
1022 objection.persona,
1023 objection.target,
1024 objection.text
1025 )),
1026 Err(e) => lines.push(format!("dissent: note refused: {e:#}")),
1027 }
1028 }
1029 let pause = format!(
1030 "paused for a person; {} from {} was not filed: {}",
1031 objection.severity.word(),
1032 objection.persona,
1033 objection.text
1034 );
1035 if !ticket_mentions(issue, &pause) {
1036 if let Err(e) = note_issue(issue, &pause) {
1037 lines.push(format!("dissent: pause note refused: {e:#}"));
1038 }
1039 }
1040 lines.push(pause);
1041}
1042
1043fn ticket_mentions(issue: &str, text: &str) -> bool {
1045 crate::issue_record(issue)
1046 .ok()
1047 .is_some_and(|v| v.to_string().contains(text))
1048}
1049
1050fn dissent_tree_top(issue: &str) -> String {
1053 let mut current = issue.to_string();
1054 for _ in 0..64 {
1055 let Ok(v) = crate::issue_record(¤t) else {
1056 return current;
1057 };
1058 let title = v["title"].as_str().unwrap_or("");
1059 if !(title.starts_with("Dissent holds") || title.starts_with("fix:")) {
1060 return current;
1061 }
1062 let Some(parent) = v["parent"]
1063 .as_str()
1064 .map(str::trim)
1065 .filter(|s| !s.is_empty())
1066 else {
1067 return current;
1068 };
1069 current = parent.to_string();
1070 }
1071 current
1072}
1073
1074#[must_use]
1076pub fn descendant_count(issue: &str) -> usize {
1077 fn walk(id: &str, seen: &mut BTreeSet<String>) -> usize {
1078 let mut n = 0;
1079 for child in child_ids(id) {
1080 if !seen.insert(child.clone()) {
1081 continue;
1082 }
1083 n += 1 + walk(&child, seen);
1084 }
1085 n
1086 }
1087 let mut seen = BTreeSet::new();
1088 seen.insert(issue.to_string());
1089 walk(issue, &mut seen)
1090}
1091
1092fn child_ids(issue: &str) -> Vec<String> {
1093 let Ok(said) = crate::run_captured("vissue", &["children", issue, "--json"]) else {
1094 return Vec::new();
1095 };
1096 let Ok(rows) = serde_json::from_str::<Vec<Value>>(&said.stdout) else {
1097 return Vec::new();
1098 };
1099 rows.into_iter()
1100 .filter_map(|row| row["id"].as_str().map(str::to_string))
1101 .collect()
1102}
1103
1104fn format_note(objection: &Objection) -> String {
1105 if objection.files_as_child() {
1106 format!(
1107 "finding: {}: blocking: {} :: {} @ {}",
1108 objection.persona, objection.reason, objection.text, objection.target
1109 )
1110 } else {
1111 format!(
1112 "finding: {}: {}: {} @ {}",
1113 objection.persona,
1114 objection.severity.word(),
1115 objection.text,
1116 objection.target
1117 )
1118 }
1119}
1120
1121fn dissent_title(objection: &Objection) -> String {
1122 let text = clip(&objection.text, DISSENT_TITLE_LIMIT);
1123 format!("Dissent holds {text}")
1124}
1125
1126fn child_titles(issue: &str) -> Vec<String> {
1127 let Ok(said) = crate::run_captured("vissue", &["children", issue, "--json"]) else {
1128 return Vec::new();
1129 };
1130 let Ok(rows) = serde_json::from_str::<Vec<Value>>(&said.stdout) else {
1131 return Vec::new();
1132 };
1133 rows.into_iter()
1134 .filter_map(|row| {
1135 let title = row["title"].as_str().map(str::to_string);
1136 if title.is_some() {
1137 return title;
1138 }
1139 let id = row["id"].as_str()?;
1140 crate::issue_record(id)
1141 .ok()
1142 .and_then(|v| v["title"].as_str().map(str::to_string))
1143 })
1144 .collect()
1145}
1146
1147fn file_child(parent: &str, project: &str, title: &str, depth: usize) -> Result<String> {
1148 let mut args = vec!["q".to_string()];
1149 if !project.is_empty() {
1150 args.push("-p".into());
1151 args.push(project.to_string());
1152 }
1153 args.extend([
1154 "--parent".into(),
1155 parent.to_string(),
1156 "-t".into(),
1157 "task".into(),
1158 title.to_string(),
1159 ]);
1160 let said = crate::run_captured("vissue", &args)?;
1161 let id = said.stdout.trim().to_string();
1162 if id.is_empty() {
1163 anyhow::bail!("vissue q printed no id");
1164 }
1165 let _ = crate::run_captured("vissue", &["note", &id, &format!("dissent-depth: {depth}")]);
1166 Ok(id)
1167}
1168
1169fn note_issue(id: &str, text: &str) -> Result<()> {
1170 crate::run_captured("vissue", &["note", id, text])?;
1171 Ok(())
1172}
1173
1174pub fn file_fix(parent: &str, project: &str, objection: &Objection) -> Result<String> {
1179 let title = format!("fix: {}", clip(&objection.text, DISSENT_TITLE_LIMIT));
1180 let id = crate::workflow_child(parent, project, "task", crate::Stage::Implement, &title)?;
1181 let _ = note_issue(
1182 &id,
1183 &format!(
1184 "finding: {}: blocking: {} :: {} @ {}",
1185 objection.persona, objection.reason, objection.text, parent
1186 ),
1187 );
1188 Ok(id)
1189}
1190
1191#[derive(Debug, Clone, PartialEq, Eq)]
1193struct Node {
1194 id: String,
1195 parent: Option<String>,
1196 title: String,
1197 open: bool,
1198 blocking: usize,
1199}
1200
1201#[must_use]
1206pub fn reap_runaways(apply: bool) -> String {
1207 let Ok(said) = crate::run_captured("vissue", &["export"]) else {
1208 return String::new();
1209 };
1210 let budget = load_budget();
1211 let mut out = String::new();
1212 for (id, why) in flag_runaways(&said.stdout, &budget) {
1213 out.push_str(&format!("runaway: {id} {why}\n"));
1214 if apply {
1215 out.push_str(&format!("reap: {id} stays open; a person closes it\n"));
1216 }
1217 }
1218 out
1219}
1220
1221#[must_use]
1223pub fn flag_runaways(export: &str, budget: &PanelBudget) -> Vec<(String, String)> {
1224 let nodes = nodes_of(export);
1225 let children = children_of(&nodes);
1226 let mut desc_memo: BTreeMap<String, usize> = BTreeMap::new();
1227 let mut round_memo: BTreeMap<String, usize> = BTreeMap::new();
1228 let mut block_memo: BTreeMap<String, usize> = BTreeMap::new();
1229 let mut flags = Vec::new();
1230 for node in &nodes {
1231 if !node.open {
1232 continue;
1233 }
1234 let limits = if is_workflow(&nodes, &node.id) {
1235 &budget.workflow
1236 } else {
1237 &budget.panel
1238 };
1239 let desc = descendants(&node.id, &children, &mut desc_memo, &mut BTreeSet::new());
1240 let parent_over = node.parent.as_ref().is_some_and(|p| {
1241 let parent_limits = if is_workflow(&nodes, p) {
1242 &budget.workflow
1243 } else {
1244 &budget.panel
1245 };
1246 descendants(p, &children, &mut desc_memo, &mut BTreeSet::new())
1247 > parent_limits.max_descendants
1248 });
1249 if desc > limits.max_descendants && !parent_over {
1250 flags.push((
1251 node.id.clone(),
1252 format!(
1253 "has {desc} descendants, over the limit of {}",
1254 limits.max_descendants
1255 ),
1256 ));
1257 }
1258 let round = max_round(
1259 &node.id,
1260 &nodes,
1261 &children,
1262 &mut round_memo,
1263 &mut BTreeSet::new(),
1264 );
1265 let blocking = blocking_under(
1266 &node.id,
1267 &nodes,
1268 &children,
1269 &mut block_memo,
1270 &mut BTreeSet::new(),
1271 );
1272 let grew = round >= 2 && round > blocking + 1;
1273 let parent_grew = node.parent.as_ref().is_some_and(|p| {
1274 let parent_round =
1275 max_round(p, &nodes, &children, &mut round_memo, &mut BTreeSet::new());
1276 let parent_blocking =
1277 blocking_under(p, &nodes, &children, &mut block_memo, &mut BTreeSet::new());
1278 parent_round >= round && parent_round >= 2 && parent_round > parent_blocking + 1
1279 });
1280 if grew && !parent_grew {
1281 flags.push((
1282 node.id.clone(),
1283 format!(
1284 "reached round {round} with {blocking} blocking {}; rounds grew without a new blocking objection",
1285 if blocking == 1 { "objection" } else { "objections" }
1286 ),
1287 ));
1288 }
1289 }
1290 flags
1291}
1292
1293fn is_workflow(nodes: &[Node], id: &str) -> bool {
1294 nodes.iter().any(|n| {
1295 n.id == id
1296 && (n.title.contains("workflow")
1297 || n.title.starts_with("design:")
1298 || n.title.starts_with("panel r"))
1299 }) || nodes
1300 .iter()
1301 .any(|n| n.parent.as_deref() == Some(id) && crate::panel_round_number(&n.title).is_some())
1302}
1303
1304fn nodes_of(export: &str) -> Vec<Node> {
1305 let mut nodes = Vec::new();
1306 for line in export.lines() {
1307 let Ok(v) = serde_json::from_str::<Value>(line) else {
1308 continue;
1309 };
1310 let Some(id) = v["id"].as_str().filter(|s| !s.is_empty()) else {
1311 continue;
1312 };
1313 let state = v["state"].as_str().unwrap_or("");
1314 let open = !matches!(state, "DONE" | "CANCELLED");
1315 let blocking = objections_in(&v, id)
1316 .iter()
1317 .filter(|o| o.files_as_child())
1318 .count();
1319 nodes.push(Node {
1320 id: id.to_string(),
1321 parent: v["parent"]
1322 .as_str()
1323 .filter(|s| !s.is_empty())
1324 .map(str::to_string),
1325 title: v["title"].as_str().unwrap_or("").to_string(),
1326 open,
1327 blocking,
1328 });
1329 }
1330 nodes
1331}
1332
1333fn children_of(nodes: &[Node]) -> BTreeMap<String, Vec<String>> {
1334 let mut map: BTreeMap<String, Vec<String>> = BTreeMap::new();
1335 for node in nodes {
1336 if let Some(parent) = &node.parent {
1337 map.entry(parent.clone()).or_default().push(node.id.clone());
1338 }
1339 }
1340 map
1341}
1342
1343fn descendants(
1344 id: &str,
1345 children: &BTreeMap<String, Vec<String>>,
1346 memo: &mut BTreeMap<String, usize>,
1347 stack: &mut BTreeSet<String>,
1348) -> usize {
1349 if let Some(n) = memo.get(id) {
1350 return *n;
1351 }
1352 if !stack.insert(id.to_string()) {
1353 return 0;
1354 }
1355 let n = children.get(id).map_or(0, |kids| {
1356 kids.iter()
1357 .map(|kid| 1 + descendants(kid, children, memo, stack))
1358 .sum()
1359 });
1360 stack.remove(id);
1361 memo.insert(id.to_string(), n);
1362 n
1363}
1364
1365fn max_round(
1366 id: &str,
1367 nodes: &[Node],
1368 children: &BTreeMap<String, Vec<String>>,
1369 memo: &mut BTreeMap<String, usize>,
1370 stack: &mut BTreeSet<String>,
1371) -> usize {
1372 if let Some(n) = memo.get(id) {
1373 return *n;
1374 }
1375 if !stack.insert(id.to_string()) {
1376 return 0;
1377 }
1378 let own = nodes
1379 .iter()
1380 .find(|n| n.id == id)
1381 .map(|n| crate::panel_round_number(&n.title).unwrap_or(0))
1382 .unwrap_or(0);
1383 let below = children.get(id).map_or(0, |kids| {
1384 kids.iter()
1385 .map(|kid| max_round(kid, nodes, children, memo, stack))
1386 .max()
1387 .unwrap_or(0)
1388 });
1389 stack.remove(id);
1390 let n = own.max(below);
1391 memo.insert(id.to_string(), n);
1392 n
1393}
1394
1395fn blocking_under(
1396 id: &str,
1397 nodes: &[Node],
1398 children: &BTreeMap<String, Vec<String>>,
1399 memo: &mut BTreeMap<String, usize>,
1400 stack: &mut BTreeSet<String>,
1401) -> usize {
1402 if let Some(n) = memo.get(id) {
1403 return *n;
1404 }
1405 if !stack.insert(id.to_string()) {
1406 return 0;
1407 }
1408 let own = nodes
1409 .iter()
1410 .find(|n| n.id == id)
1411 .map(|n| n.blocking)
1412 .unwrap_or(0);
1413 let below: usize = children.get(id).map_or(0, |kids| {
1414 kids.iter()
1415 .map(|kid| blocking_under(kid, nodes, children, memo, stack))
1416 .sum()
1417 });
1418 stack.remove(id);
1419 let n = own + below;
1420 memo.insert(id.to_string(), n);
1421 n
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426 use super::*;
1427
1428 fn nit(n: usize) -> Objection {
1429 Objection::nit("ceo", &format!("comma in note {n}"), "anneal-z64x")
1430 }
1431
1432 #[test]
1433 fn the_round_comes_from_the_tracker_not_only_the_title() {
1434 let mut v = serde_json::json!({
1437 "id": "panel-a7o4",
1438 "title": "log-sink: where the log goes",
1439 "state": "TODO",
1440 "logbook": [],
1441 });
1442 assert_eq!(sitting_round(&v), (1, true));
1443 v["logbook"] = serde_json::json!([
1444 {"note": "panel-round: 2"},
1445 {"note": "finding: keep: blocking: stderr is what ops reads :: keep stderr"},
1446 {"note": "panel-round: 1"},
1447 ]);
1448 assert_eq!(recorded_round(&v), 2);
1449 assert_eq!(sitting_round(&v), (3, true));
1450 assert!(objections_in(&v, "panel-a7o4")
1451 .iter()
1452 .all(|o| !o.text.contains("panel-round")));
1453 let mut state = PanelState::at_round(sitting_round(&v).0);
1454 state.passed = true;
1455 let disposal = dispose(&state, &ScopeBudget::panel());
1456 assert!(!disposal.close, "{disposal:?}");
1457 assert!(
1458 disposal.summary.is_empty(),
1459 "a round count does not close: {disposal:?}"
1460 );
1461 v["state"] = serde_json::json!("DONE");
1463 assert_eq!(sitting_round(&v), (2, false));
1464 let titled = serde_json::json!({
1466 "id": "panel-hrd5",
1467 "title": "panel r3: cache place",
1468 "state": "TODO",
1469 "logbook": [{"note": "panel-round: 7"}],
1470 });
1471 assert_eq!(sitting_round(&titled), (3, false));
1472 }
1473
1474 #[test]
1475 fn a_blocking_mark_without_a_reason_is_a_nit() {
1476 let line = "finding: tester: blocking: the comma is wrong";
1477 let got = parse_objection(line, "anneal-z64x").unwrap();
1478 assert_eq!(got.severity, Severity::Nit);
1479 assert!(!got.files_as_child());
1480 let marked = parse_objection(
1481 "finding: tester: blocking: the exit status is dropped :: the gate ignores a failure @ anneal-z64x",
1482 "other",
1483 )
1484 .unwrap();
1485 assert!(marked.files_as_child());
1486 assert_eq!(marked.reason, "the exit status is dropped");
1487 assert_eq!(marked.text, "the gate ignores a failure");
1488 assert_eq!(marked.target, "anneal-z64x");
1489 let plain = parse_objection("finding: cto: heading tone", "anneal-z64x").unwrap();
1490 assert_eq!(plain.severity, Severity::Nit);
1491 assert_eq!(plain.text, "heading tone");
1492 let wording =
1493 parse_objection("finding: cto: wording: heading tone", "anneal-z64x").unwrap();
1494 assert_eq!(wording.severity, Severity::Wording);
1495 assert!(parse_objection("not a finding", "x").is_none());
1496 }
1497
1498 #[test]
1499 fn the_same_text_and_target_are_one_objection() {
1500 let a = Objection::nit("ceo", "Heading, tone!", "anneal-z64x");
1501 let b = Objection::wording("cto", "heading tone", "Anneal-z64x");
1502 assert_eq!(a.key(), b.key());
1503 let other = Objection::nit("ceo", "heading tone", "anneal-other");
1504 assert_ne!(a.key(), other.key());
1505 }
1506
1507 #[test]
1508 fn an_endless_stream_of_nits_after_a_pass_files_nothing() {
1509 let limits = ScopeBudget::panel();
1510 let mut state = PanelState::at_round(1);
1511 state.passed = true;
1512 let mut filed = 0;
1513 let mut opened = 0;
1514 let mut passed = 0;
1515 for n in 0..10 {
1516 state.objections.push(nit(n));
1517 state
1518 .objections
1519 .push(Objection::wording("cto", "heading tone", "anneal-z64x"));
1520 state.objections.push(Objection::style(
1521 "junior-dev",
1522 "whitespace in the comment",
1523 "anneal-z64x",
1524 ));
1525 match plan_round(&state, &limits) {
1526 RoundPlan::FileBlocking { objections, .. } => filed += objections.len(),
1527 RoundPlan::OpenRound { n } => {
1528 opened += 1;
1529 state.round = n;
1530 }
1531 RoundPlan::Passed => passed += 1,
1532 RoundPlan::Hold { why } => panic!("a pass does not hold: {why}"),
1533 }
1534 }
1535 assert_eq!(filed, 0, "nits after a pass are notes");
1536 assert_eq!(opened, 0, "a pass does not open another round");
1537 assert_eq!(passed, 10);
1538 assert_eq!(state.round, 1);
1539
1540 let mut state = PanelState::at_round(1);
1543 let mut filed = 0;
1544 let mut opened = 0;
1545 for n in 0..10 {
1546 state.objections.push(nit(n));
1547 state
1548 .objections
1549 .push(Objection::wording("cto", "heading tone", "anneal-z64x"));
1550 match plan_round(&state, &limits) {
1551 RoundPlan::FileBlocking { objections, .. } => filed += objections.len(),
1552 RoundPlan::OpenRound { n } => {
1553 opened += 1;
1554 state.round = n;
1555 }
1556 RoundPlan::Passed => panic!("wording alone is not a pass"),
1557 RoundPlan::Hold { why } => panic!("nits do not hold the round: {why}"),
1558 }
1559 }
1560 assert_eq!(filed, 0);
1561 assert_eq!(opened, 10);
1562 assert!(state.round > 3, "round {}", state.round);
1563 }
1564
1565 #[test]
1566 fn a_blocking_dissent_files_a_child_and_a_nit_does_not_file_a_grandchild() {
1567 let limits = ScopeBudget::panel();
1568 let mut state = PanelState::at_round(1);
1569 state.passed = true;
1570 state.objections.push(Objection::blocking(
1571 "tester",
1572 "the gate drops a real failure",
1573 "the exit status is ignored",
1574 "anneal-z64x",
1575 ));
1576 state
1577 .objections
1578 .push(Objection::nit("ceo", "comma in note 0", "anneal-z64x"));
1579 let plan = plan_round(&state, &limits);
1580 let RoundPlan::FileBlocking { objections, .. } = plan else {
1581 panic!("a blocking dissent is a child: {plan:?}");
1582 };
1583 assert_eq!(objections.len(), 1);
1584 assert!(objections[0].text.contains("exit status"));
1585
1586 let mut child = PanelState::at_round(1);
1587 child.dissent_depth = 1;
1588 child.passed = true;
1589 child
1590 .objections
1591 .push(Objection::nit("ceo", "rename the local", "anneal-child"));
1592 child
1593 .objections
1594 .push(Objection::wording("cto", "heading tone", "anneal-child"));
1595 match plan_round(&child, &limits) {
1596 RoundPlan::Passed => {}
1597 other => panic!("a dissent child does not file a nit grandchild: {other:?}"),
1598 }
1599 let mut deeper = child.clone();
1600 deeper.passed = true;
1601 deeper.objections.push(Objection::blocking(
1602 "tester",
1603 "the child still drops the status",
1604 "the wait is unchecked",
1605 "anneal-child",
1606 ));
1607 match plan_round(&deeper, &limits) {
1608 RoundPlan::FileBlocking { objections, .. } => {
1609 assert_eq!(objections.len(), 1);
1610 assert!(objections[0].text.contains("unchecked"));
1611 }
1612 other => panic!("a blocking objection on a dissent child is still a child: {other:?}"),
1613 }
1614 }
1615
1616 #[test]
1617 fn new_blocking_objections_keep_filing_past_round_three() {
1618 let limits = ScopeBudget::panel();
1619 let mut state = PanelState::at_round(1);
1620 let mut filed = Vec::new();
1621 for n in 0..6 {
1622 state.objections.push(Objection::blocking(
1623 "tester",
1624 &format!("failure {n} escapes"),
1625 &format!("bug {n} is still open"),
1626 "anneal-z64x",
1627 ));
1628 state.open_children = 0;
1629 match plan_round(&state, &limits) {
1630 RoundPlan::FileBlocking { objections, .. } => {
1631 assert_eq!(objections.len(), 1, "{objections:?}");
1632 let objection = &objections[0];
1633 filed.push(objection.text.clone());
1634 state.seen.push(objection.key());
1635 state.children_filed += 1;
1636 match plan_round(&state, &limits) {
1637 RoundPlan::OpenRound { n } => state.round = n,
1638 other => panic!("after the fix the next round opens: {other:?}"),
1639 }
1640 }
1641 other => panic!("step {n}: {other:?}"),
1642 }
1643 }
1644 assert!(state.round > 3, "round {}", state.round);
1645 assert_eq!(filed.len(), 6, "{filed:?}");
1646 assert!(filed.iter().any(|text| text.contains("bug 5")), "{filed:?}");
1647 }
1648
1649 #[test]
1650 fn a_wall_budget_does_not_skip_a_blocking_fix() {
1651 let mut limits = ScopeBudget::panel();
1652 limits.max_wall_secs = Some(60);
1653 let mut state = PanelState::at_round(1);
1654 state.wall_secs = Some(120);
1655 state.objections.push(Objection::blocking(
1656 "tester",
1657 "a real failure",
1658 "the exit status is ignored",
1659 "anneal-z64x",
1660 ));
1661 match plan_round(&state, &limits) {
1662 RoundPlan::FileBlocking { objections, .. } => {
1663 assert_eq!(objections.len(), 1, "{objections:?}");
1664 assert!(objections[0].text.contains("exit status"));
1665 }
1666 other => panic!("a spent wall budget does not close the parent: {other:?}"),
1667 }
1668 }
1669
1670 #[test]
1671 fn a_descendant_limit_pauses_for_a_person() {
1672 let limits = ScopeBudget::panel();
1673 let mut state = PanelState::at_round(2);
1674 state.descendants = limits.max_descendants;
1675 match plan_round(&state, &limits) {
1676 RoundPlan::Hold { why } => {
1677 assert!(why.contains("person has to act"), "{why}");
1678 assert!(why.contains("descendants"), "{why}");
1679 }
1680 other => panic!("the descendant limit does not close the parent: {other:?}"),
1681 }
1682 state.descendants = limits.max_descendants - 1;
1683 state.passed = false;
1684 assert!(matches!(
1685 plan_round(&state, &limits),
1686 RoundPlan::OpenRound { .. }
1687 ));
1688 }
1689
1690 #[test]
1691 fn a_full_child_list_holds_and_leaves_the_parent_open() {
1692 let limits = ScopeBudget::panel();
1693 let mut state = PanelState::at_round(4);
1694 state.children_filed = limits.max_children;
1695 state.objections.push(Objection::blocking(
1696 "tester",
1697 "a real failure",
1698 "one more bug",
1699 "anneal-z64x",
1700 ));
1701 match plan_round(&state, &limits) {
1702 RoundPlan::Hold { why } => assert!(why.contains("children filed"), "{why}"),
1703 other => panic!("a full child list does not close the parent: {other:?}"),
1704 }
1705 }
1706
1707 #[test]
1708 fn a_settled_majority_names_only_the_minority() {
1709 let majority = vec![
1710 ("alice".to_string(), "drop".to_string()),
1711 ("bob".to_string(), "drop".to_string()),
1712 ("keeper".to_string(), "keep".to_string()),
1713 ];
1714 assert_eq!(minority_options(&majority), vec!["keep".to_string()]);
1715 let tie = vec![
1716 ("alice".to_string(), "yes".to_string()),
1717 ("bob".to_string(), "no".to_string()),
1718 ];
1719 assert!(minority_options(&tie).is_empty());
1720 let unanimous = vec![
1721 ("alice".to_string(), "ship".to_string()),
1722 ("bob".to_string(), "ship".to_string()),
1723 ];
1724 assert!(minority_options(&unanimous).is_empty());
1725 }
1726
1727 #[test]
1728 fn a_duplicate_is_not_filed_again() {
1729 let limits = ScopeBudget::panel();
1730 let mut state = PanelState::at_round(1);
1731 let objection = Objection::blocking(
1732 "tester",
1733 "the gate drops a real failure",
1734 "the exit status is ignored",
1735 "anneal-z64x",
1736 );
1737 state.seen.push(objection.key());
1738 state.objections.push(objection);
1739 state.objections.push(Objection::blocking(
1740 "cto",
1741 "the gate drops a real failure",
1742 "The exit status is ignored.",
1743 "anneal-z64x",
1744 ));
1745 let disposal = dispose(&state, &limits);
1746 assert!(disposal
1747 .fates
1748 .iter()
1749 .all(|f| matches!(f, Fate::Duplicate(_))));
1750 assert!(!disposal.close);
1751 }
1752
1753 #[test]
1754 fn a_long_blocking_finding_read_three_times_files_one_child() {
1755 let finding = "x".repeat(181);
1756 assert_eq!(finding.chars().count(), 181);
1757 let objection = Objection::blocking("tester", "this blocks merge", &finding, "panel-long");
1758 let title = dissent_title(&objection);
1759 let key = filed_key(&title, "panel-long").expect("dissent title yields a filed key");
1760 assert_eq!(key, objection.key());
1761
1762 let mut state = PanelState::at_round(1);
1763 state.objections = vec![objection.clone()];
1764 let limits = ScopeBudget::panel();
1765 let mut filed = 0;
1766 for read in 0..3 {
1767 state.seen = if read == 0 {
1768 Vec::new()
1769 } else {
1770 vec![key.clone()]
1771 };
1772 let disposal = dispose(&state, &limits);
1773 filed += disposal
1774 .fates
1775 .iter()
1776 .filter(|f| matches!(f, Fate::File(_)))
1777 .count();
1778 }
1779 assert_eq!(filed, 1);
1780 }
1781
1782 #[test]
1783 fn a_spent_budget_lists_and_does_not_ask_to_close() {
1784 let mut limits = ScopeBudget::panel();
1785 limits.max_wall_secs = Some(60);
1786 limits.max_tokens = Some(10);
1787 let mut state = PanelState::at_round(4);
1788 state.wall_secs = Some(120);
1789 state.tokens = Some(40);
1790 state.children_filed = limits.max_children;
1791 state.objections.push(Objection::blocking(
1792 "tester",
1793 "a real failure",
1794 "the exit status is ignored",
1795 "anneal-z64x",
1796 ));
1797 let disposal = dispose(&state, &limits);
1798 assert!(!disposal.close, "{disposal:?}");
1799 assert!(disposal.summary.is_empty(), "{disposal:?}");
1800 assert!(
1801 disposal
1802 .fates
1803 .iter()
1804 .any(|fate| matches!(fate, Fate::Listed(_))),
1805 "{disposal:?}"
1806 );
1807 }
1808
1809 #[test]
1810 fn toml_overlays_the_defaults_and_leaves_the_other_scope() {
1811 let budget = budget_from_toml(
1812 "[panel]\nmax_rounds = 2\nmax_tokens = 1000\n[workflow]\nmax_children = 4\n",
1813 );
1814 assert_eq!(budget.panel.max_children, ScopeBudget::panel().max_children);
1815 assert_eq!(budget.panel.max_tokens, Some(1000));
1816 assert_eq!(budget.workflow.max_children, 4);
1817 assert_eq!(
1818 budget.workflow.max_tokens,
1819 ScopeBudget::workflow().max_tokens
1820 );
1821 let ignored = budget_from_toml("[panel]\nmax_rounds = 1\n[workflow]\nmax_rounds = 9\n");
1822 assert_eq!(ignored, PanelBudget::default());
1823 assert_eq!(budget_from_toml("not toml"), PanelBudget::default());
1824 }
1825
1826 #[test]
1827 fn reap_flags_a_wide_tree_and_rounds_with_no_new_blocking_objection() {
1828 let budget = PanelBudget::default();
1829 let mut lines = Vec::new();
1830 lines.push(
1831 r#"{"id":"anneal-z64x","title":"anneal the notes","state":"STARTED","parent":""}"#
1832 .to_string(),
1833 );
1834 for n in 1..=10 {
1835 lines.push(format!(
1836 r#"{{"id":"anneal-r{n}","title":"panel r{n}: anneal","state":"DONE","parent":"anneal-z64x"}}"#
1837 ));
1838 }
1839 for n in 0..60 {
1840 lines.push(format!(
1841 r#"{{"id":"anneal-c{n}","title":"fix: comma {n}","state":"TODO","parent":"anneal-z64x"}}"#
1842 ));
1843 }
1844 let flags = flag_runaways(&lines.join("\n"), &budget);
1846 let ids: Vec<&str> = flags.iter().map(|(id, _)| id.as_str()).collect();
1847 assert!(ids.contains(&"anneal-z64x"), "{flags:?}");
1848 let why = flags
1849 .iter()
1850 .filter(|(id, _)| id == "anneal-z64x")
1851 .map(|(_, why)| why.as_str())
1852 .collect::<Vec<_>>()
1853 .join(" ");
1854 assert!(why.contains("descendants"), "{why}");
1855 assert!(why.contains("round 10"), "{why}");
1856 assert!(why.contains("without a new blocking"), "{why}");
1857
1858 let small = [
1861 r#"{"id":"p","title":"widget","state":"STARTED"}"#,
1862 r#"{"id":"p-r1","title":"panel r1: widget","state":"DONE","parent":"p","logbook":[{"note":"finding: tester: blocking: drops a failure :: exit status @ p"}]}"#,
1863 r#"{"id":"p-r2","title":"panel r2: widget","state":"DONE","parent":"p"}"#,
1864 ]
1865 .join("\n");
1866 let flags = flag_runaways(&small, &budget);
1867 assert!(
1868 flags.iter().all(|(id, _)| id != "p"),
1869 "round 2 with one blocking objection is the fix, not a runaway: {flags:?}"
1870 );
1871 let wide_round = [
1872 r#"{"id":"p","title":"widget","state":"STARTED"}"#,
1873 r#"{"id":"p-r1","title":"panel r1: widget","state":"DONE","parent":"p"}"#,
1874 r#"{"id":"p-r2","title":"panel r2: widget","state":"DONE","parent":"p"}"#,
1875 r#"{"id":"p-r3","title":"panel r3: widget","state":"DONE","parent":"p"}"#,
1876 ]
1877 .join("\n");
1878 let flags = flag_runaways(&wide_round, &budget);
1879 assert!(
1880 flags
1881 .iter()
1882 .any(|(id, why)| id == "p" && why.contains("round 3")),
1883 "{flags:?}"
1884 );
1885 }
1886}