Skip to main content

pma_core/
next.rs

1//! `pma next`: what should happen next, and who does it. One ordered list for
2//! agents, in the order `pma dispatch --auto` takes it, and one for you: runs
3//! waiting on a decision, then tasks no agent may take that are critical or
4//! urgent.
5//!
6//! Nothing here ranks anew. The order is the matrix's, and what an agent may
7//! take is what dispatch decides, so this view and `--auto` cannot disagree.
8
9use crate::rank::Placed;
10use crate::report;
11use crate::store::{Run, RunState};
12use crate::todo::Priority;
13
14/// Where an eligible task stands with dispatch, as `--auto` sees it.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Taken {
17    /// Nothing holds it: `--auto` would take it.
18    Free,
19    /// A run that is not final holds it. It shows as that run.
20    Held,
21    /// Its attempts were used without an accepted result.
22    Exhausted(i64),
23    /// No scan finds its project, so nothing can be dispatched against it.
24    Absent,
25}
26
27/// One row of either list: its cells, the command that acts on it, and what
28/// it is about, for a front end that acts on it directly.
29#[derive(Debug, Clone, PartialEq)]
30pub struct Row {
31    pub cells: Vec<String>,
32    pub command: Option<String>,
33    pub subject: Subject,
34}
35
36#[derive(Debug, Clone, PartialEq)]
37pub enum Subject {
38    /// A task, by its dispatch target; `retry` once its attempts are used.
39    Task {
40        project: String,
41        target: String,
42        retry: bool,
43    },
44    /// A task no agent may take.
45    Manual { project: String },
46    /// A run, with the state this row showed and its project.
47    Run {
48        id: i64,
49        state: String,
50        project: String,
51    },
52}
53
54#[derive(Debug, Default)]
55pub struct Next {
56    /// Tasks an agent may take, in dispatch order.
57    pub agents: Vec<Row>,
58    /// Runs waiting on you: to review, to publish, or a pull request to merge.
59    pub runs: Vec<Row>,
60    /// Tasks no agent may take, or may take no more, that are critical or
61    /// urgent.
62    pub tasks: Vec<Row>,
63}
64
65/// The two lists. `placed` is in matrix order; `taken` says what dispatch
66/// would do with an eligible task.
67pub fn build(
68    placed: &[Placed],
69    runs: &[Run],
70    today: i64,
71    taken: impl Fn(&Placed) -> Taken,
72) -> Next {
73    let mut next = Next::default();
74    for p in placed {
75        let t = &p.task;
76        let at = match t.line {
77            Some(line) => format!("{}:{line}", t.project),
78            None => format!("{}:{}", t.project, t.key.as_deref().unwrap_or("")),
79        };
80        let cells = |why: &str| {
81            let mut cells = vec![
82                at.clone(),
83                format!("T{}", t.tier),
84                t.priority.name().to_string(),
85                report::when(p, today),
86            ];
87            if !why.is_empty() {
88                cells.push(why.to_string());
89            }
90            cells.push(report::truncate(&t.text, 80));
91            cells
92        };
93        let needs_you = t.priority == Priority::Critical || p.urgency.is_some();
94        match (t.eligible && t.key.is_some(), taken(p)) {
95            (_, Taken::Absent | Taken::Held) => {}
96            (true, Taken::Free) => next.agents.push(Row {
97                cells: cells(""),
98                command: Some(format!("pma dispatch {at}")),
99                subject: Subject::Task {
100                    project: t.project.clone(),
101                    target: at.clone(),
102                    retry: false,
103                },
104            }),
105            (true, Taken::Exhausted(n)) if needs_you => next.tasks.push(Row {
106                cells: cells(&format!("{n} attempts used")),
107                command: Some(format!("pma dispatch {at} --retry")),
108                subject: Subject::Task {
109                    project: t.project.clone(),
110                    target: at.clone(),
111                    retry: true,
112                },
113            }),
114            (false, _) if needs_you && t.line.is_some() => next.tasks.push(Row {
115                cells: cells("not for agents"),
116                command: None,
117                subject: Subject::Manual {
118                    project: t.project.clone(),
119                },
120            }),
121            _ => {}
122        }
123    }
124
125    // A run you decide blocks its task, so the ones that cost least to settle
126    // come first: read and decide, then publish, then merge.
127    let mut waiting: Vec<&Run> = runs
128        .iter()
129        .filter(|r| {
130            matches!(
131                r.state,
132                RunState::Ready | RunState::Failed | RunState::Approved | RunState::PrOpen
133            )
134        })
135        .collect();
136    let order = |r: &Run| match r.state {
137        RunState::Ready | RunState::Failed => 0,
138        RunState::Approved => 1,
139        _ => 2,
140    };
141    waiting.sort_by_key(|r| (order(r), r.id));
142    for r in waiting {
143        let (what, command) = match r.state {
144            RunState::Approved => ("to publish", format!("pma pr {} | pma push {}", r.id, r.id)),
145            RunState::PrOpen => (
146                "to merge",
147                r.outcome
148                    .clone()
149                    .unwrap_or_else(|| "its pull request".into()),
150            ),
151            _ => ("to review", format!("pma review {}", r.id)),
152        };
153        next.runs.push(Row {
154            cells: vec![
155                format!("#{}", r.id),
156                r.state.name().to_string(),
157                what.to_string(),
158                r.project.clone(),
159                report::truncate(&r.text, 80),
160            ],
161            command: Some(command),
162            subject: Subject::Run {
163                id: r.id,
164                state: r.state.name().into(),
165                project: r.project.clone(),
166            },
167        });
168    }
169    next
170}
171
172/// The two lists as text. `limit` caps the rows of each, and the count says
173/// what was left out.
174pub fn render(next: &Next, limit: Option<usize>) -> String {
175    let section = |rows: &[Row], indent: &str| -> String {
176        let shown = limit.map_or(rows.len(), |l| l.min(rows.len()));
177        let cells: Vec<Vec<String>> = rows[..shown].iter().map(|r| r.cells.clone()).collect();
178        let mut out = report::table(&cells, indent);
179        if shown < rows.len() {
180            out.push_str(&format!("{indent}and {} more\n", rows.len() - shown));
181        }
182        out
183    };
184    let mut out = String::new();
185    out.push_str(&format!("For agents: {}\n", next.agents.len()));
186    match next.agents.is_empty() {
187        true => out.push_str("  nothing an agent may take; tag an item #agent\n"),
188        false => {
189            out.push_str(&section(&next.agents, "  "));
190            out.push_str("  `pma dispatch --auto` takes them in this order\n");
191        }
192    }
193    out.push_str(&format!(
194        "\nFor you: {}\n",
195        next.runs.len() + next.tasks.len()
196    ));
197    if next.runs.is_empty() && next.tasks.is_empty() {
198        out.push_str("  nothing waits on you\n");
199    }
200    if !next.runs.is_empty() {
201        out.push_str(&format!("  runs: {}\n", next.runs.len()));
202        let rows: Vec<Row> = next
203            .runs
204            .iter()
205            .map(|r| Row {
206                cells: r.cells.iter().cloned().chain(r.command.clone()).collect(),
207                command: None,
208                subject: r.subject.clone(),
209            })
210            .collect();
211        out.push_str(&section(&rows, "    "));
212    }
213    if !next.tasks.is_empty() {
214        out.push_str(&format!("  tasks: {}\n", next.tasks.len()));
215        out.push_str(&section(&next.tasks, "    "));
216    }
217    out
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::rank::{Quadrant, Task, Urgency};
224
225    fn placed(project: &str, line: Option<i64>, priority: Priority, eligible: bool) -> Placed {
226        Placed {
227            task: Task {
228                project: project.into(),
229                tier: 1,
230                priority,
231                text: format!("text of {project}"),
232                line,
233                key: Some(match line {
234                    Some(_) => format!("text of {project}"),
235                    None => "ci".into(),
236                }),
237                group: None,
238                due: None,
239                tagged_urgent: false,
240                signal_urgent: line.is_none(),
241                eligible,
242                age_days: 3,
243            },
244            importance: 0.5,
245            urgency: line.is_none().then_some(Urgency::Signal),
246            quadrant: Quadrant::Q2,
247        }
248    }
249
250    fn run(id: i64, state: RunState) -> Run {
251        Run {
252            id,
253            state,
254            project: "p".into(),
255            text: format!("run {id}"),
256            outcome: (state == RunState::PrOpen).then(|| "https://x/pull/1".into()),
257            ..Run::default()
258        }
259    }
260
261    #[test]
262    fn agents_take_the_free_eligible_tasks_in_matrix_order() {
263        let tasks = [
264            placed("ci", None, Priority::High, true),
265            placed("free", Some(5), Priority::Medium, true),
266            placed("held", Some(6), Priority::Medium, true),
267            placed("gone", Some(7), Priority::Critical, true),
268            placed("spent", Some(8), Priority::Critical, true),
269            placed("tired", Some(9), Priority::Low, true),
270            placed("mine", Some(10), Priority::Critical, false),
271            placed("later", Some(11), Priority::Low, false),
272        ];
273        let next = build(&tasks, &[], 100, |p| match p.task.project.as_str() {
274            "held" => Taken::Held,
275            "gone" => Taken::Absent,
276            "spent" | "tired" => Taken::Exhausted(2),
277            _ => Taken::Free,
278        });
279        let at: Vec<&str> = next.agents.iter().map(|r| r.cells[0].as_str()).collect();
280        assert_eq!(at, ["ci:ci", "free:5"]);
281        assert_eq!(
282            next.agents[1].command.as_deref(),
283            Some("pma dispatch free:5")
284        );
285
286        // A spent task needs you only when it is critical or urgent, as any
287        // other does; one no agent may take says so.
288        let yours: Vec<(&str, &str)> = next
289            .tasks
290            .iter()
291            .map(|r| (r.cells[0].as_str(), r.cells[4].as_str()))
292            .collect();
293        assert_eq!(
294            yours,
295            [
296                ("spent:8", "2 attempts used"),
297                ("mine:10", "not for agents")
298            ]
299        );
300        assert_eq!(
301            next.tasks[0].command.as_deref(),
302            Some("pma dispatch spent:8 --retry")
303        );
304    }
305
306    #[test]
307    fn runs_waiting_on_you_come_review_first() {
308        let runs = [
309            run(1, RunState::PrOpen),
310            run(2, RunState::Approved),
311            run(3, RunState::Ready),
312            run(4, RunState::Running),
313            run(5, RunState::Merged),
314            run(6, RunState::Failed),
315        ];
316        let next = build(&[], &runs, 100, |_| Taken::Free);
317        let got: Vec<(&str, &str)> = next
318            .runs
319            .iter()
320            .map(|r| (r.cells[0].as_str(), r.command.as_deref().unwrap_or("")))
321            .collect();
322        assert_eq!(
323            got,
324            [
325                ("#3", "pma review 3"),
326                ("#6", "pma review 6"),
327                ("#2", "pma pr 2 | pma push 2"),
328                ("#1", "https://x/pull/1"),
329            ]
330        );
331    }
332
333    #[test]
334    fn a_limit_counts_what_it_leaves_out() {
335        let tasks: Vec<Placed> = (1..=4)
336            .map(|n| placed(&format!("p{n}"), Some(n), Priority::Low, true))
337            .collect();
338        let next = build(&tasks, &[run(3, RunState::Ready)], 100, |_| Taken::Free);
339        let out = render(&next, Some(2));
340        assert!(out.starts_with("For agents: 4\n  p1:1"), "{out}");
341        assert!(out.contains("  and 2 more\n"), "{out}");
342        assert!(
343            out.contains(
344                "For you: 1\n  runs: 1\n    #3  ready  to review  p  run 3  pma review 3\n"
345            ),
346            "{out}"
347        );
348        let empty = render(&Next::default(), None);
349        assert!(
350            empty.contains("nothing an agent may take") && empty.contains("nothing waits on you")
351        );
352    }
353}