1use std::path::{Path, PathBuf};
10
11use crate::config::Config;
12use crate::store::{
13 Attempt, Campaign, Job, Note, Run, RunState, Session, Store, WorkflowInstance, WorkflowRevision,
14};
15use crate::{dates, dispatch, jobs, next, publish, rank, report_runs, store, todo};
16
17type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
18
19#[derive(Debug, Clone, Default, PartialEq)]
21pub struct Selection {
22 pub projects: Vec<String>,
23 pub tags: Vec<String>,
24}
25
26#[derive(Debug, Clone, PartialEq)]
27pub enum Query {
28 Next {
29 of: Selection,
30 all: bool,
31 },
32 Matrix {
33 of: Selection,
34 quadrant: Option<rank::Quadrant>,
35 all: bool,
36 },
37 Status {
39 of: Selection,
40 explain: bool,
41 all: bool,
42 },
43 Stale {
44 of: Selection,
45 count: Option<usize>,
46 },
47 Report {
48 by: report_runs::By,
49 },
50 Runs,
52 Run {
55 id: i64,
56 },
57 Agents,
59 Jobs,
60 JobLog {
61 id: i64,
62 },
63 Notes,
64 Workflows,
65 Campaigns,
66 Campaign {
67 name: String,
68 },
69 Project {
71 name: String,
72 },
73}
74
75pub enum View {
76 Next {
77 header: String,
78 lists: next::Next,
79 limit: Option<usize>,
80 },
81 Matrix {
82 header: String,
83 placed: Vec<rank::Placed>,
84 limit: Option<usize>,
85 quadrant: Option<rank::Quadrant>,
86 today: i64,
87 },
88 Status {
89 header: String,
90 portfolio: Box<Portfolio>,
91 explain: bool,
92 },
93 Stale {
94 header: String,
95 placed: Vec<rank::Placed>,
96 limit: usize,
97 },
98 Report {
99 runs: Vec<Run>,
100 attempts: Vec<Attempt>,
101 by: report_runs::By,
102 },
103 Runs(Vec<Run>),
104 Run {
105 run: Box<Run>,
106 attempts: Vec<Attempt>,
107 changelog: Option<String>,
110 diff: String,
111 moved: bool,
114 },
115 Agents {
116 state: PathBuf,
117 jobs: Vec<Job>,
118 runs: Vec<Run>,
119 now: i64,
120 },
121 Jobs(Vec<Job>),
122 JobLog {
124 id: i64,
125 text: Option<String>,
126 },
127 Notes(Vec<Note>),
128 Workflows {
129 revisions: Vec<WorkflowRevision>,
130 instances: Vec<WorkflowInstance>,
131 },
132 Campaigns(Vec<Campaign>),
133 Campaign {
134 campaign: Campaign,
135 runs: Vec<Run>,
136 },
137 Project {
138 name: String,
139 path: PathBuf,
140 scanned_at: Option<i64>,
142 tags: Vec<String>,
143 verify: Option<String>,
145 status: Option<crate::report::StatusLine>,
148 placed: Vec<rank::Placed>,
150 today: i64,
151 runs: Vec<Run>,
153 },
154}
155
156#[derive(Debug, Clone, Default, PartialEq)]
158pub struct WorkerChoice {
159 pub agent: Option<String>,
160 pub model: Option<String>,
161 pub preset: Option<String>,
162}
163
164#[derive(Debug, Clone, PartialEq)]
165pub enum Action {
166 ApproveRun {
169 id: i64,
170 seen: Option<String>,
171 },
172 ApproveRuns {
174 ids: Vec<i64>,
175 },
176 RejectRun {
177 id: i64,
178 keep_git_changes: bool,
179 infra: bool,
180 seen: Option<String>,
181 },
182 AddReviewTime {
183 id: i64,
184 minutes: u32,
185 },
186 SettlePrs,
188 StopJob {
189 id: i64,
190 },
191 ApproveJob {
192 id: i64,
193 },
194 StopWorkflow {
195 instance: i64,
196 },
197 AddNote {
198 text: String,
199 },
200 EditNote {
201 id: i64,
202 text: String,
203 },
204 RemoveNote {
205 id: i64,
206 },
207
208 Scan {
210 of: Selection,
211 offline: bool,
212 deps: bool,
213 },
214 Dispatch {
215 targets: Vec<String>,
216 tags: Vec<String>,
217 auto: bool,
218 count: Option<usize>,
219 retry: bool,
220 worker: WorkerChoice,
221 },
222 WorkflowRun {
224 name: String,
225 projects: Vec<String>,
226 tag: Option<String>,
227 instance: Option<i64>,
228 set: Vec<String>,
229 approve: Option<String>,
230 unattended: bool,
231 local: bool,
232 cap: Vec<String>,
233 worker: WorkerChoice,
234 },
235 ReworkRun {
236 id: i64,
237 feedback: String,
238 },
239 Publish {
240 ids: Vec<i64>,
241 all_approved: bool,
242 target: publish::Target,
243 },
244 RunCampaign {
245 name: String,
246 count: Option<usize>,
247 },
248 Sync {
249 of: Selection,
250 apply: bool,
251 },
252 Verify {
253 of: Selection,
254 },
255}
256
257#[derive(Debug, Default, PartialEq)]
260pub struct Outcome {
261 pub said: Vec<String>,
262 pub warnings: Vec<String>,
263}
264
265impl Outcome {
266 fn said(line: impl Into<String>) -> Outcome {
267 Outcome {
268 said: vec![line.into()],
269 warnings: Vec::new(),
270 }
271 }
272}
273
274pub struct Portfolio {
276 pub cfg: Config,
277 pub today: i64,
278 pub projects: Vec<(rank::Project, store::ProjectRow)>,
279 pub tasks: Vec<rank::Task>,
280 pub untiered: usize,
281 pub all: bool,
283 pub scanned_ago: i64,
284}
285
286impl Portfolio {
287 pub fn header(&self) -> String {
288 if self.all {
289 return format!(
290 "last scan {}; {} projects, {} untiered ranked as tier 5",
291 crate::report::ago(self.scanned_ago),
292 self.projects.len(),
293 self.untiered
294 );
295 }
296 format!(
297 "last scan {}; {} tiered projects, {} untiered (pma project tier <1-5> <project>...)",
298 crate::report::ago(self.scanned_ago),
299 self.projects.len(),
300 self.untiered
301 )
302 }
303}
304
305pub fn config(store: &Store) -> Result<Config> {
306 let rows = store.config_rows()?;
307 Ok(Config::with_overrides(
308 rows.iter().map(|(k, v)| (k.as_str(), v.as_str())),
309 )?)
310}
311
312pub fn whoami() -> String {
313 std::env::var("USER")
314 .or_else(|_| std::env::var("LOGNAME"))
315 .unwrap_or_else(|_| "unknown".into())
316}
317
318pub fn select(store: &Store, names: &[String], tags: &[String]) -> Result<Vec<String>> {
322 if tags.is_empty() {
323 return Ok(names.to_vec());
324 }
325 let mut out = names.to_vec();
326 for project in store.projects_tagged(tags)? {
327 if !out.contains(&project) {
328 out.push(project);
329 }
330 }
331 if out.is_empty() {
332 return Err(format!("no project carries `{}`", tags.join("` or `")).into());
333 }
334 Ok(out)
335}
336
337pub fn portfolio(store: &Store, of: &Selection, all: bool) -> Result<Portfolio> {
338 let cfg = config(store)?;
339 let now = dates::now();
340 let today = dates::day(now);
341 let last = store
342 .last_scan()?
343 .ok_or("nothing scanned yet; run `pma scan`")?;
344
345 let names = select(store, &of.projects, &of.tags)?;
346 let rows = store.projects()?;
347 for n in &names {
348 if !rows.iter().any(|r| &r.name == n) {
349 return Err(format!("unknown project `{n}`").into());
350 }
351 }
352 let rows: Vec<_> = rows
355 .into_iter()
356 .filter(|r| names.is_empty() || names.contains(&r.name))
357 .filter(|r| r.absent_since.is_none())
358 .collect();
359 let fallback = cfg.tier_or_default(None);
360 let untiered = rows
361 .iter()
362 .filter(|r| r.tier.is_none() && fallback.is_none())
363 .count();
364 let task_rows = store.tasks()?;
365
366 let mut projects = Vec::new();
367 let mut tasks = Vec::new();
368 for row in rows {
369 let Some(tier) = row.tier.or(fallback).or(all.then_some(5)) else {
370 continue;
371 };
372 let own: Vec<&store::TaskRow> =
373 task_rows.iter().filter(|t| t.project == row.name).collect();
374 let project = rank::Project {
375 name: row.name.clone(),
376 tier,
377 open: own.iter().map(|t| t.priority).collect(),
378 idle_days: row.last_activity.map(|t| (today - dates::day(t)).max(0)),
379 ci: row.ci.clone(),
380 dirty: row.dirty,
381 leftover: row.leftover,
382 ahead: row.ahead,
383 deps: row
384 .deps
385 .map(|n| (n, row.deps_at.map_or(0, |t| (today - dates::day(t)).max(0)))),
386 };
387 tasks.extend(own.iter().map(|t| rank::Task {
388 project: row.name.clone(),
389 tier,
390 priority: t.priority,
391 text: t.text.clone(),
392 line: Some(t.line),
393 key: Some(t.key.clone()),
394 group: t.group.clone(),
395 due: t.due.as_deref().and_then(dates::parse),
396 tagged_urgent: t.tags.iter().any(|g| g == "urgent"),
397 signal_urgent: false,
398 eligible: t.tags.iter().any(|g| g == "agent") && !t.tags.iter().any(|g| g == "manual"),
399 age_days:
400 (today - dates::day(t.added_at.unwrap_or(t.first_seen).min(t.first_seen))).max(0),
401 }));
402 tasks.extend(rank::signal_tasks(&cfg, &project));
403 projects.push((project, row));
404 }
405 Ok(Portfolio {
406 cfg,
407 today,
408 projects,
409 tasks,
410 untiered,
411 all,
412 scanned_ago: now - last,
413 })
414}
415
416pub fn has_run(runs: &[Run], project: &str, key: &str, text: &str) -> bool {
418 runs.iter().any(|r| {
419 !r.state.is_final()
420 && r.project == project
421 && (r.task_key == key || todo::normal_text(&r.text) == todo::normal_text(text))
422 })
423}
424
425pub fn next_lists(store: &Store, p: &Portfolio) -> Result<next::Next> {
426 let active = store.runs()?;
427 let absent: Vec<String> = store
428 .projects()?
429 .into_iter()
430 .filter(|r| r.absent_since.is_some())
431 .map(|r| r.name)
432 .collect();
433 let placed = rank::place(&p.cfg, p.tasks.clone(), p.today);
434 let mut spent = std::collections::HashMap::new();
435 for x in placed.iter().filter(|x| x.task.eligible) {
436 let revision = dispatch::revision(&x.task.text);
437 let n = store.consumed_attempts(&x.task.project, &revision)?;
438 spent.insert((x.task.project.clone(), revision), n);
439 }
440 let taken = |x: &rank::Placed| {
442 let t = &x.task;
443 if absent.contains(&t.project) {
444 return next::Taken::Absent;
445 }
446 if let Some(key) = &t.key
447 && has_run(&active, &t.project, key, &t.text)
448 {
449 return next::Taken::Held;
450 }
451 let used = spent
452 .get(&(t.project.clone(), dispatch::revision(&t.text)))
453 .copied()
454 .unwrap_or(0);
455 match used >= dispatch::ATTEMPT_LIMIT {
456 true => next::Taken::Exhausted(used),
457 false => next::Taken::Free,
458 }
459 };
460 Ok(next::build(&placed, &active, p.today, taken))
461}
462
463pub fn changelog(home: &Path, run: &Run) -> Option<String> {
466 match std::fs::read_to_string(dispatch::changelog_draft(home, run.id)) {
467 Ok(text) => Some(text),
468 Err(_) => dispatch::wants_changelog(run).then(String::new),
469 }
470}
471
472pub fn busy(store: &Store) -> Result<bool> {
475 Ok(store
476 .jobs(50)?
477 .iter()
478 .any(|j| matches!(j.state.as_str(), "queued" | "running"))
479 || store.runs()?.iter().any(|r| r.state == RunState::Running))
480}
481
482pub fn settle_interrupted(store: &Store) -> Result<()> {
484 if let Some(s) = Session::try_acquire(&store::home()?)? {
485 store.fail_interrupted_runs(&s)?;
486 }
487 Ok(())
488}
489
490pub fn query(store: &Store, q: Query) -> Result<View> {
491 Ok(match q {
492 Query::Next { of, all } => {
493 let p = portfolio(store, &of, false)?;
494 View::Next {
495 header: p.header(),
496 lists: next_lists(store, &p)?,
497 limit: (!all).then_some(p.cfg.quadrant_limit as usize),
498 }
499 }
500 Query::Matrix { of, quadrant, all } => {
501 let p = portfolio(store, &of, false)?;
502 View::Matrix {
503 header: p.header(),
504 limit: (!all).then_some(p.cfg.quadrant_limit as usize),
505 today: p.today,
506 placed: rank::place(&p.cfg, p.tasks, p.today),
507 quadrant,
508 }
509 }
510 Query::Status { of, explain, all } => {
511 let p = portfolio(store, &of, all)?;
512 View::Status {
513 header: p.header(),
514 portfolio: Box::new(p),
515 explain,
516 }
517 }
518 Query::Stale { of, count } => {
519 let p = portfolio(store, &of, false)?;
520 View::Stale {
521 header: p.header(),
522 limit: count.unwrap_or(p.cfg.quadrant_limit as usize),
523 placed: rank::place(&p.cfg, p.tasks, p.today),
524 }
525 }
526 Query::Report { by } => View::Report {
527 runs: store.runs()?,
528 attempts: store.attempts(None)?,
529 by,
530 },
531 Query::Runs => {
532 settle_interrupted(store)?;
533 View::Runs(
534 store
535 .runs()?
536 .into_iter()
537 .filter(|r| !r.state.is_final())
538 .collect(),
539 )
540 }
541 Query::Run { id } => {
542 let run = store.run(id)?;
543 let home = store::state_home()?;
544 let approvable = matches!(run.state, RunState::Ready | RunState::Approved);
547 let before = approvable.then(|| dispatch::tree(&run).ok()).flatten();
548 let diff = if run.branch.is_empty() {
551 "(none: this node changes no files)".to_string()
552 } else {
553 dispatch::diff(&run).unwrap_or_else(|e| format!("(no diff: {e})"))
554 };
555 let after = approvable.then(|| dispatch::tree(&run).ok()).flatten();
556 let moved = match (before, after) {
557 (Some(a), Some(b)) if a == b => {
558 store.set_reviewed_tree(run.id, &a)?;
559 false
560 }
561 (Some(_), Some(_)) => true,
562 _ => false,
563 };
564 View::Run {
565 attempts: store.attempts(Some(run.id))?,
566 changelog: changelog(&home, &run),
567 run: Box::new(run),
568 diff,
569 moved,
570 }
571 }
572 Query::Agents => View::Agents {
573 state: store::state_home()?,
574 jobs: store.jobs(50)?,
575 runs: store.runs()?,
576 now: dates::now(),
577 },
578 Query::Jobs => View::Jobs(store.jobs(20)?),
579 Query::JobLog { id } => {
580 store.job(id)?;
581 View::JobLog {
582 id,
583 text: std::fs::read_to_string(jobs::log_path(id)?).ok(),
584 }
585 }
586 Query::Notes => View::Notes(store.notes()?),
587 Query::Workflows => View::Workflows {
588 revisions: store.workflow_revisions()?,
589 instances: store.workflow_instances()?,
590 },
591 Query::Campaigns => View::Campaigns(store.campaigns()?),
592 Query::Campaign { name } => View::Campaign {
593 campaign: store.campaign(&name)?,
594 runs: store.runs()?,
595 },
596 Query::Project { name } => {
597 let row = store
598 .project(&name)?
599 .ok_or_else(|| format!("unknown project `{name}`"))?;
600 let p = portfolio(
601 store,
602 &Selection {
603 projects: vec![name.clone()],
604 tags: Vec::new(),
605 },
606 true,
607 )?;
608 let status = p.projects.first().and_then(|(project, row)| {
609 let r = crate::report::StatusRow {
610 project,
611 tiered: row.tier.is_some() || p.cfg.default_tier.is_some(),
612 has_todo: row.has_todo,
613 lint_errors: row.lint_errors,
614 scan_error: row.scan_error.as_deref(),
615 };
616 crate::report::status_rows(&p.cfg, &[r]).into_iter().next()
617 });
618 let mut runs: Vec<Run> = store
619 .runs()?
620 .into_iter()
621 .filter(|r| r.project == name)
622 .collect();
623 runs.sort_by_key(|r| std::cmp::Reverse(r.id));
624 View::Project {
625 scanned_at: row.scanned_at,
626 path: row.path,
627 tags: store
628 .project_tags()?
629 .into_iter()
630 .filter(|(p, _)| *p == name)
631 .map(|(_, t)| t)
632 .collect(),
633 verify: p.cfg.project(&name).verify,
634 status,
635 placed: rank::place(&p.cfg, p.tasks, p.today),
636 today: p.today,
637 runs,
638 name,
639 }
640 }
641 })
642}
643
644fn unmoved(run: &Run, seen: Option<&str>) -> Result<()> {
646 match seen {
647 Some(s) if s != run.state.name() => Err(format!(
648 "run #{} is now {}, not {s}; read it again",
649 run.id,
650 run.state.name()
651 )
652 .into()),
653 _ => Ok(()),
654 }
655}
656
657pub fn act(store: &Store, a: Action) -> Result<Outcome> {
658 let note_text = |t: String| -> Result<String> {
659 let t = t.trim().to_string();
660 if t.is_empty() {
661 return Err("a note needs text".into());
662 }
663 Ok(t)
664 };
665 Ok(match a {
666 Action::ApproveRun { id, seen } => {
667 let mut run = store.run(id)?;
668 unmoved(&run, seen.as_deref())?;
669 dispatch::approve(store, &mut run, &whoami())?;
670 Outcome::default()
671 }
672 Action::ApproveRuns { ids } => {
673 let mut runs = Vec::new();
674 for id in &ids {
675 let run = store.run(*id)?;
676 let reasons = crate::accept::review_reasons(&run);
677 if !reasons.is_empty() {
678 return Err(format!(
679 "#{id} is not clean, so it is not a batch approval: {}. \
680 Approve it on its own after reading it.",
681 reasons.join("; ")
682 )
683 .into());
684 }
685 runs.push(run);
686 }
687 let by = whoami();
688 let mut out = Outcome::default();
689 for mut run in runs {
690 dispatch::approve(store, &mut run, &by)?;
691 out.said
692 .push(format!("#{} {}: approved", run.id, run.project));
693 }
694 out
695 }
696 Action::RejectRun {
697 id,
698 keep_git_changes,
699 infra,
700 seen,
701 } => {
702 let session = Session::acquire(&store::home()?)?;
704 store.fail_interrupted_runs(&session)?;
705 let mut run = store.run(id)?;
706 unmoved(&run, seen.as_deref())?;
707 dispatch::reject(store, &mut run, keep_git_changes, infra)?;
708 Outcome::default()
709 }
710 Action::AddReviewTime { id, minutes } => {
711 store.run(id)?;
712 store.add_review_seconds(id, i64::from(minutes) * 60)?;
713 Outcome::default()
714 }
715 Action::SettlePrs => {
716 let mut out = Outcome::default();
717 publish::settle(store, |run, outcome| match outcome {
718 Ok(o) => out.said.push(format!("#{} {}: {o}", run.id, run.project)),
719 Err(e) => out.warnings.push(format!(
720 "#{} {}: pull request state unknown: {e}",
721 run.id, run.project
722 )),
723 })?;
724 out
725 }
726 Action::StopJob { id } => Outcome::said(jobs::stop(store, id)?),
727 Action::ApproveJob { id } => Outcome::said(jobs::approve(store, id)?),
728 Action::StopWorkflow { instance } => {
729 let _session = Session::acquire(&store::home()?)?;
730 let found = store
731 .workflow_instance(instance)?
732 .ok_or_else(|| format!("no workflow instance {instance}"))?;
733 if found.finished_at.is_some() {
734 return Err(format!(
735 "instance {instance} is already {}",
736 found.outcome.as_deref().unwrap_or("finished")
737 )
738 .into());
739 }
740 store.finish_instance(instance, "stopped")?;
741 let _ = std::fs::remove_dir_all(
742 store::state_home()?
743 .join("workflow-trees")
744 .join(instance.to_string()),
745 );
746 Outcome::said(format!(
747 "instance {instance} stopped; runs it made stand in `pma review`"
748 ))
749 }
750 Action::AddNote { text } => {
751 let id = store.add_note(¬e_text(text)?, dates::now())?;
752 Outcome::said(format!("#{id}"))
753 }
754 Action::EditNote { id, text } => {
755 if !store.edit_note(id, ¬e_text(text)?, dates::now())? {
756 return Err(format!("no note #{id}").into());
757 }
758 Outcome::default()
759 }
760 Action::RemoveNote { id } => {
761 if !store.remove_note(id)? {
762 return Err(format!("no note #{id}").into());
763 }
764 Outcome::default()
765 }
766 Action::Scan {
767 offline: true,
768 deps: true,
769 ..
770 } => return Err("a scan is offline or reads dependencies, not both".into()),
771 queued => {
772 let args = command_line(&queued).expect("every other action is queued");
773 let id = jobs::queue(store, &args, &std::env::current_dir()?)?;
774 Outcome::said(format!(
775 "job {id} queued; `pma job` lists jobs and `pma job log {id}` shows its output"
776 ))
777 }
778 })
779}
780
781pub fn command_line(a: &Action) -> Option<Vec<String>> {
783 let mut v: Vec<String> = Vec::new();
784 let mut push = |s: &str| v.push(s.to_string());
785 let flag = |v: &mut Vec<String>, on: bool, name: &str| {
786 if on {
787 v.push(name.to_string());
788 }
789 };
790 let opt = |v: &mut Vec<String>, name: &str, value: &Option<String>| {
792 if let Some(x) = value {
793 v.push(format!("{name}={x}"));
794 }
795 };
796 let worker = |v: &mut Vec<String>, w: &WorkerChoice| {
797 opt(v, "--agent", &w.agent);
798 opt(v, "--model", &w.model);
799 opt(v, "--preset", &w.preset);
800 };
801 let tags = |v: &mut Vec<String>, tags: &[String]| {
802 for t in tags {
803 v.push(format!("--tag={t}"));
804 }
805 };
806 match a {
807 Action::Scan { of, offline, deps } => {
808 push("scan");
809 v.extend(of.projects.iter().cloned());
810 tags(&mut v, &of.tags);
811 flag(&mut v, *offline, "--offline");
812 flag(&mut v, *deps, "--deps");
813 }
814 Action::Dispatch {
815 targets,
816 tags: t,
817 auto,
818 count,
819 retry,
820 worker: w,
821 } => {
822 push("dispatch");
823 v.extend(targets.iter().cloned());
824 tags(&mut v, t);
825 flag(&mut v, *auto, "--auto");
826 opt(&mut v, "--count", &count.map(|n| n.to_string()));
827 flag(&mut v, *retry, "--retry");
828 worker(&mut v, w);
829 }
830 Action::WorkflowRun {
831 name,
832 projects,
833 tag,
834 instance,
835 set,
836 approve,
837 unattended,
838 local,
839 cap,
840 worker: w,
841 } => {
842 v.extend(["workflow".to_string(), "run".into(), name.clone()]);
843 v.extend(projects.iter().cloned());
844 opt(&mut v, "--tag", tag);
845 opt(&mut v, "--instance", &instance.map(|n| n.to_string()));
846 for s in set {
847 v.push(format!("--set={s}"));
848 }
849 opt(&mut v, "--approve", approve);
850 flag(&mut v, *unattended, "--unattended");
851 flag(&mut v, *local, "--local");
852 for c in cap {
853 v.push(format!("--cap={c}"));
854 }
855 worker(&mut v, w);
856 }
857 Action::ReworkRun { id, feedback } => {
858 v.extend([
859 "review".to_string(),
860 id.to_string(),
861 format!("--rework={feedback}"),
862 ]);
863 }
864 Action::Publish {
865 ids,
866 all_approved,
867 target,
868 } => {
869 push(match target {
870 publish::Target::Pr => "pr",
871 publish::Target::Push => "push",
872 });
873 v.extend(ids.iter().map(i64::to_string));
874 flag(&mut v, *all_approved, "--all-approved");
875 }
876 Action::RunCampaign { name, count } => {
877 v.extend(["campaign".to_string(), "run".into(), name.clone()]);
878 opt(&mut v, "--count", &count.map(|n| n.to_string()));
879 }
880 Action::Sync { of, apply } => {
881 push("sync");
882 v.extend(of.projects.iter().cloned());
883 tags(&mut v, &of.tags);
884 flag(&mut v, *apply, "--apply");
885 }
886 Action::Verify { of } => {
887 push("verify");
888 v.extend(of.projects.iter().cloned());
889 tags(&mut v, &of.tags);
890 }
891 Action::ApproveRun { .. }
892 | Action::ApproveRuns { .. }
893 | Action::RejectRun { .. }
894 | Action::AddReviewTime { .. }
895 | Action::SettlePrs
896 | Action::StopJob { .. }
897 | Action::ApproveJob { .. }
898 | Action::StopWorkflow { .. }
899 | Action::AddNote { .. }
900 | Action::EditNote { .. }
901 | Action::RemoveNote { .. } => return None,
902 }
903 Some(v)
904}
905
906#[cfg(test)]
907mod tests {
908 use super::*;
909
910 #[test]
913 fn queued_actions_name_their_cli_command() {
914 let line = |a: Action| command_line(&a).expect("queued");
915 let w = WorkerChoice {
916 agent: None,
917 model: Some("haiku".into()),
918 preset: None,
919 };
920 assert_eq!(
921 line(Action::WorkflowRun {
922 name: "review-fix".into(),
923 projects: vec!["argdec".into()],
924 tag: None,
925 instance: Some(3),
926 set: vec!["breadth=5".into()],
927 approve: Some("abc".into()),
928 unattended: false,
929 local: true,
930 cap: vec![],
931 worker: w.clone(),
932 }),
933 [
934 "workflow",
935 "run",
936 "review-fix",
937 "argdec",
938 "--instance=3",
939 "--set=breadth=5",
940 "--approve=abc",
941 "--local",
942 "--model=haiku"
943 ]
944 );
945 assert_eq!(
946 line(Action::Dispatch {
947 targets: vec![],
948 tags: vec!["py".into()],
949 auto: true,
950 count: Some(2),
951 retry: false,
952 worker: w,
953 }),
954 [
955 "dispatch",
956 "--tag=py",
957 "--auto",
958 "--count=2",
959 "--model=haiku"
960 ]
961 );
962 assert_eq!(
963 line(Action::Publish {
964 ids: vec![4],
965 all_approved: false,
966 target: publish::Target::Pr
967 }),
968 ["pr", "4"]
969 );
970 }
971}