thirdshift 0.12.1

Turn a GitHub issue into a ready-for-review pull request, unattended
//! A command's lifecycle, from its start to its exit code, for every command
//! that does factory work: a Run or Spec run started on an Issue URL,
//! or a Pass. A child Run goes through it too, as part
//! of the command that started it.
//!
//! A command [`start`]s: it begins its record in the logs, loads the User
//! config, offering Setup, installs the interrupt handler and checks its Run
//! notification, in that order. It does its work, then [`Started::finish`]es
//! with its [`Ending`]: the Factory skills it wrote out for its sessions
//! removed, then the Activity log's end line, the terminal display and the
//! Run notification, in that order, only the display of those three for a
//! skipped pass.

use std::process::ExitCode;
use std::time::Instant;

use crate::architect::{self, Reviewed};
use crate::config::UserConfig;
use crate::failed_run::FailedRun;
use crate::harness::Choice;
use crate::interrupt;
use crate::issue::IssueUrl;
use crate::logs::{self, Begin};
use crate::notification::{About, NotificationAsk, RunNotification};
use crate::pickup;
use crate::progress;
use crate::run::Ended;
use crate::run_ending;
use crate::setup;
use crate::skills;

/// How a command that did factory work, or was skipped before any, ended.
pub enum Ending {
    /// A Run or a Spec run ended: started by hand, or dispatched by a Pickup
    /// run.
    Run(Ended),
    /// An Architect run was not skipped: how its Architecture review ended,
    /// failing if anything before it did, and how the run it dispatched its
    /// plan as ended, if it dispatched one.
    Architect {
        review: Result<Reviewed, FailedRun>,
        dispatched: Option<Ended>,
    },
    /// A Security run completed its audit, or failed.
    Security(crate::security::Ended),
    SecurityFix {
        ended: Ended,
        findings: Vec<crate::security::RecordedFinding>,
    },
    /// A Pass was skipped before any work.
    Skipped(Skip),
}

/// Why a pass was skipped, and what it prints.
pub struct Skip {
    /// The line that says why, on stderr.
    pub reason: String,
    /// The URLs it puts on stdout, a line each: those of the issues it was
    /// skipped for, if any.
    pub urls: Vec<String>,
}

impl From<architect::Skipped> for Skip {
    /// An Architect run's skip: its reason, and the URLs of the issues it
    /// was skipped for.
    fn from(skipped: architect::Skipped) -> Self {
        Skip {
            reason: skipped.to_string(),
            urls: skipped.urls().into_iter().map(String::from).collect(),
        }
    }
}

impl From<pickup::Skipped> for Skip {
    /// A Pickup run's skip: its reason, with nothing on stdout.
    fn from(skipped: pickup::Skipped) -> Self {
        Skip {
            reason: skipped.to_string(),
            urls: Vec::new(),
        }
    }
}

/// A command started, with its Run notification checked if it asked for
/// one, to finish with its [`Ending`].
pub struct Started {
    notification: Option<RunNotification>,
    /// Only the top-level command reports elapsed wall time.
    started: Option<Instant>,
}

/// Start a command that begins in the logs as `begin` says: load the User
/// config, after offering Setup where there is none; install the interrupt
/// handler; then check the Run notification that `ask` of that config asks
/// for, if any, about `about`. Returns the User config, for the command's
/// own asks, and the command to finish, or, if any of that fails, the
/// failure to exit with, its error reported.
pub fn start(
    begin: Begin,
    ask: impl FnOnce(&UserConfig) -> NotificationAsk,
    about: About,
) -> Result<(UserConfig, Started), ExitCode> {
    let started = (!matches!(begin, Begin::ChildRun(..))).then(Instant::now);
    logs::begin(begin);
    let config = setup::offer()
        .and_then(|()| UserConfig::load())
        .map_err(|error| failure(&error))?;
    logs::configured(&config);
    // First, so no interrupt can end the command once its notification is
    // checked.
    interrupt::install().map_err(|error| failure(&error))?;
    let notification = match ask(&config) {
        NotificationAsk::Send(to) => {
            Some(RunNotification::new(to, &config.email, about).map_err(|error| failure(&error))?)
        }
        NotificationAsk::Skip => None,
    };
    Ok((
        config,
        Started {
            notification,
            started,
        },
    ))
}

impl Started {
    /// Take note that the command's sessions run on `harness`, which its Run
    /// notification names.
    pub fn built_with(&mut self, harness: &Choice) {
        if let Some(notification) = &mut self.notification {
            notification.built_with(harness);
        }
    }

    /// Take note that the Pickup run took the Ready issue `issue`, titled
    /// `title`, which its Run notification is then about.
    pub fn took(&mut self, issue: &IssueUrl, title: String) {
        if let Some(notification) = &mut self.notification {
            notification.took(issue, title);
        }
    }

    /// Finish the command that ended as `ending`: remove the Factory skills
    /// it wrote out for its sessions, if it did, write the Activity log's
    /// end line, show how it ended, then send its Run notification, if it
    /// asked for one. A skipped pass only shows how it ended: its skip is
    /// recorded where it was decided, and it sends no notification. Returns
    /// its exit code.
    pub fn finish(self, ending: Ending) -> ExitCode {
        skills::remove_written();
        let account = match run_ending::read(&ending) {
            Ok(account) => account,
            Err(skip) => return run_ending::show_skip(skip),
        };
        logs::ended(account.summary());
        logs::finished(&account);
        let elapsed = match &ending {
            Ending::Run(_)
            | Ending::Architect {
                dispatched: Some(_),
                ..
            }
            | Ending::SecurityFix { .. } => self.started.map(|started| started.elapsed()),
            _ => None,
        };
        let code = account.show(elapsed);
        if let Some(notification) = self.notification {
            notification.send(&account);
        }
        code
    }
}

/// `error` on stderr, after any lines a pass held from the terminal, and the
/// exit code of a failure.
pub fn failure(error: &anyhow::Error) -> ExitCode {
    logs::show_held();
    progress::step(format_args!("{error:#}"));
    ExitCode::FAILURE
}