1use crate::rank::Placed;
10use crate::report;
11use crate::store::{Run, RunState};
12use crate::todo::Priority;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Taken {
17 Free,
19 Held,
21 Exhausted(i64),
23 Absent,
25}
26
27#[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 Task {
40 project: String,
41 target: String,
42 retry: bool,
43 },
44 Manual { project: String },
46 Run {
48 id: i64,
49 state: String,
50 project: String,
51 },
52}
53
54#[derive(Debug, Default)]
55pub struct Next {
56 pub agents: Vec<Row>,
58 pub runs: Vec<Row>,
60 pub tasks: Vec<Row>,
63}
64
65pub 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 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
172pub 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(§ion(&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(§ion(&rows, " "));
212 }
213 if !next.tasks.is_empty() {
214 out.push_str(&format!(" tasks: {}\n", next.tasks.len()));
215 out.push_str(§ion(&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 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}