Skip to main content

pma_core/
api.rs

1//! What a front end may read and do: `Query` returns a `View` of plain data,
2//! and `Action` returns an `Outcome`. The CLI, the TUI and the web each render
3//! these, so a front end that matches every variant cannot fall behind
4//! another. Design: `docs/dev/frontends.md`.
5//!
6//! An action that runs agents or waits on the network is queued as a job
7//! running the same CLI command, so no front end blocks on it.
8
9use 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/// Projects named outright and those carrying any of `tags`; empty is all.
20#[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    /// `all` ranks untiered projects as tier 5.
38    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 that are not final: what waits on a reviewer.
51    Runs,
52    /// One run, with its diff. Showing it records the tree shown, which an
53    /// approval then requires.
54    Run {
55        id: i64,
56    },
57    /// Jobs and running agents, as `pma watch` reads them.
58    Agents,
59    Jobs,
60    JobLog {
61        id: i64,
62    },
63    Notes,
64    Workflows,
65    Campaigns,
66    Campaign {
67        name: String,
68    },
69    /// One project: its state, its open tasks and its runs.
70    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        /// `None` when the project keeps no `CHANGELOG.md`; empty when the
108        /// agent drafted no entry.
109        changelog: Option<String>,
110        diff: String,
111        /// Set when the tree moved while it was read, so no approval may
112        /// rest on what was shown.
113        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    /// `None` until the job writes output.
123    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        /// When a scan last read it; what the view shows is that old.
141        scanned_at: Option<i64>,
142        tags: Vec<String>,
143        /// The verify command set for it; `None` detects one at dispatch.
144        verify: Option<String>,
145        /// Its states and status cells, as `pma status` shows them; `None`
146        /// for a project no scan has found lately.
147        status: Option<crate::report::StatusLine>,
148        /// Its open tasks, in matrix order.
149        placed: Vec<rank::Placed>,
150        today: i64,
151        /// Every run it has had, newest first.
152        runs: Vec<Run>,
153    },
154}
155
156/// The worker a queued action names, as `-a`, `-m` and `-p` do.
157#[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    /// Approves one run, read or not; the reviewer has read it. `seen` is
167    /// the state a front end showed it in: the run must still be in it.
168    ApproveRun {
169        id: i64,
170        seen: Option<String>,
171    },
172    /// Approves several runs at once, each of which must be clean.
173    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    /// Reads each open pull request's state from GitHub.
187    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    // Queued as jobs.
209    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    /// Starts or resumes an instance; with `approve`, runs that plan.
223    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/// What an action did. `said` goes to standard output, `warnings` to
258/// standard error.
259#[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
274/// What the last scan recorded, for tiered projects.
275pub 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    /// Whether the untiered projects are in `projects`, ranked as tier 5.
282    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
318/// The projects named outright together with every project carrying one of
319/// `tags`. Empty means all of them, so a `--tag` that matches nothing is an
320/// error rather than a silent selection of the whole portfolio.
321pub 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    // A project no scan can find has nothing to dispatch against, so it does
353    // not compete for attention. `pma project` still lists it.
354    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
416/// Whether a run that is not final holds this task, by key or by its text.
417pub 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    // What `--auto` checks before it takes a task, in the order it checks.
441    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
463/// A run's drafted changelog entries: `None` when its project keeps no
464/// `CHANGELOG.md` and the agent drafted none, empty when it should have.
465pub 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
472/// Whether a job or an agent run is under way, so a front end refreshes
473/// often rather than now and then.
474pub 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
482/// Marks runs whose session died `failed`, when no session holds the lock.
483pub 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            // The tree on both sides of the diff, so an approval can require
545            // what was shown. A tree that moved meanwhile is not recorded.
546            let approvable = matches!(run.state, RunState::Ready | RunState::Approved);
547            let before = approvable.then(|| dispatch::tree(&run).ok()).flatten();
548            // A workflow node that is not an `edit` has no branch, and its tree
549            // is gone once its output is read.
550            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
644/// Refuses a run that left the state a front end showed it in.
645fn 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            // Rejecting changes the worktree, so it holds the session.
703            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(&note_text(text)?, dates::now())?;
752            Outcome::said(format!("#{id}"))
753        }
754        Action::EditNote { id, text } => {
755            if !store.edit_note(id, &note_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
781/// The CLI command a queued action runs; `None` for one that runs in place.
782pub 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    // `--name=value`, so a value that starts with `-` is not read as a flag.
791    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    /// A queued action's command line is one the CLI parses, with each flag
911    /// spelled as the CLI spells it.
912    #[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}