thirdshift 0.15.0

Turn a GitHub issue into a ready-for-review pull request, unattended
//! Dismiss an Inbox row from the CLI or Console, or take a dismissal back, keeping
//! each dismissal beside its Command record as a Stop request is kept.

use std::io::Write;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};

use super::reading::{Group, Observation, Snapshot};
use crate::config::UserConfig;

/// The stored kind of a dismissed Ready for review row.
pub(crate) const READY_KIND: &str = "Ready";

/// One Inbox row a dismissal covers, within its Command record. A dispatched
/// Spec run or Ticket run shares its outer Command's record, so the selector
/// says which of the record's rows this entry covers.
#[derive(Clone, Deserialize, Serialize)]
pub(crate) struct DismissedRow {
    pub selector: String,
    /// Last kind dismissed; retained for single-kind files and readers.
    pub kind: String,
    #[serde(default)]
    pub kinds: Vec<String>,
    pub who: String,
    pub at: String,
    #[serde(default)]
    pub from_console: bool,
}

impl DismissedRow {
    pub(crate) fn covers(&self, kind: &str) -> bool {
        self.kinds.iter().any(|covered| covered == kind)
    }

    fn source(&self) -> &'static str {
        if self.from_console { "Console" } else { "CLI" }
    }

    pub(crate) fn view(&self, current_kind: Option<&str>) -> DismissalView {
        DismissalView {
            who: self.who.clone(),
            dismissed_at: self.at.clone(),
            source: self.source(),
            kind: current_kind.unwrap_or(&self.kind).to_owned(),
            kinds: self.kinds.clone(),
        }
    }
}

/// A row's dismissal as `status --json` reports it: who, when, from where
/// and every kind it covers. `kind` is the current kind, or the last one
/// dismissed while a live row has no problem.
#[derive(Serialize)]
pub(crate) struct DismissalView {
    pub who: String,
    pub dismissed_at: String,
    pub source: &'static str,
    pub kind: String,
    pub kinds: Vec<String>,
}

impl DismissalView {
    pub(crate) fn line(&self) -> String {
        format!(
            "dismissed by {} from the {} at {}",
            self.who, self.source, self.dismissed_at
        )
    }
}

/// Every dismissal kept beside one Command record.
#[derive(Default, Deserialize, Serialize)]
struct DismissalFile {
    #[serde(default)]
    rows: Vec<DismissedRow>,
}

fn dismissal_path(record: &Path) -> PathBuf {
    record.with_extension("dismiss.json")
}

fn read(record: &Path) -> DismissalFile {
    std::fs::read(dismissal_path(record))
        .ok()
        .and_then(|bytes| serde_json::from_slice(&bytes).ok())
        .unwrap_or_default()
}

/// The dismissal covering this record's row, if one names its selector.
pub(crate) fn covering(record: &Path, selector: &str) -> Option<DismissedRow> {
    read(record)
        .rows
        .into_iter()
        .find(|row| row.selector == selector)
        .map(|mut row| {
            // Dismissals written before kinds accumulated covered one kind.
            if !row.covers(&row.kind) {
                row.kinds.push(row.kind.clone());
            }
            row
        })
}

fn write(record: &Path, file: &DismissalFile) -> Result<()> {
    let path = dismissal_path(record);
    if file.rows.is_empty() {
        return match std::fs::remove_file(&path) {
            Ok(()) => Ok(()),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(error) => Err(error).with_context(|| {
                format!("could not remove the dismissal beside {}", record.display())
            }),
        };
    }
    let parent = path.parent().context("Command record has no directory")?;
    let mut staged = tempfile::NamedTempFile::new_in(parent)?;
    staged.write_all(&serde_json::to_vec(&file)?)?;
    staged
        .persist(&path)
        .map(|_| ())
        .with_context(|| format!("could not keep the dismissal beside {}", record.display()))
}

/// One Inbox row holding the named Command: where to record and report the
/// dismissal, the kind a dismissal would cover, and every record-backed row
/// it stands for. A row read from history stands for no record.
struct DismissalTarget {
    repository: String,
    name: String,
    subject: String,
    group: Group,
    kind: String,
    members: Vec<(PathBuf, String)>,
    dismissed: bool,
}

fn row_for(
    snapshot: &Snapshot,
    github: &super::github::Kept,
    input: &str,
    days: i64,
    undo: bool,
) -> Result<DismissalTarget> {
    let selected = snapshot.resolve(input)?;
    let record = match selected.observation {
        Observation::Record(command) => &command.record,
        Observation::History(_) => {
            bail!("cannot dismiss {input}: row has no Command record");
        }
    };
    let name = record.name.clone();
    let mut target = DismissalTarget {
        repository: record.repository.clone(),
        subject: match &record.issue {
            Some(issue) => format!("{} #{}", record.kind, issue.number),
            None => name.clone(),
        },
        name,
        group: Group::History,
        kind: String::new(),
        members: Vec::new(),
        dismissed: false,
    };
    let selector = selected
        .observation
        .selector()
        .with_context(|| format!("cannot dismiss {input}: row has no Command record"))?;
    let found = snapshot
        .inbox(true, days, github)
        .into_iter()
        .find(|item| {
            item.command.selector().as_deref() == Some(selector.as_str())
                || item
                    .collapsed
                    .iter()
                    .any(|child| child.command.selector().as_deref() == Some(selector.as_str()))
        })
        .with_context(|| {
            format!("cannot dismiss {input}: row is not in Needs you or Ready for review")
        })?;
    for item in std::iter::once(&found).chain(found.collapsed.iter()) {
        let Observation::Record(command) = item.command else {
            bail!("cannot dismiss {input}: row has no Command record");
        };
        let selector = item
            .command
            .selector()
            .with_context(|| format!("cannot dismiss {input}: row has no Command record"))?;
        target.members.push((command.record_path.clone(), selector));
    }
    // Taking a dismissal back works wherever the row now is, so only a
    // dismissal names a kind.
    let kind = match found.group {
        Group::NeedsYou => Some(
            found
                .needs_you_kind
                .map(|kind| kind.title().to_owned())
                .with_context(|| format!("cannot dismiss {input}: row has no Needs you kind"))?,
        ),
        Group::ReadyForReview => Some(READY_KIND.to_owned()),
        _ => None,
    };
    target.kind = match kind {
        Some(kind) => kind,
        None if undo => String::new(),
        None => {
            bail!(
                "cannot dismiss {input}: row is in {}, not Needs you or Ready for review",
                found.group.title()
            );
        }
    };
    target.group = found.group;
    target.dismissed = found.dismissal.is_some();
    Ok(target)
}

#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Action {
    Dismiss,
    Undo,
}

/// Command details uses the same row and collapse selection as a request.
pub(super) fn action(
    snapshot: &Snapshot,
    github: &super::github::Kept,
    input: &str,
    days: i64,
) -> Option<Action> {
    let target = row_for(snapshot, github, input, days, true).ok()?;
    if matches!(target.group, Group::NeedsYou | Group::ReadyForReview) {
        Some(Action::Dismiss)
    } else if target.dismissed {
        Some(Action::Undo)
    } else {
        None
    }
}

pub(crate) struct Request {
    pub who: String,
    pub undo: bool,
    pub from_console: bool,
}

pub(crate) struct Change {
    name: String,
    group: Group,
    dismissal: Option<DismissalView>,
}

fn activity_write(config: &UserConfig, repository: &str, line: &str) -> Result<()> {
    let path = config.logs_dir.join(repository).join(super::activity::FILE);
    let mut file = super::activity::open(&path)?;
    writeln!(
        file,
        "{} {line}",
        chrono::Local::now().format(super::activity::TIME_FORMAT)
    )
    .with_context(|| format!("could not keep {}", path.display()))
}

pub fn dismiss(input: &str, undo: bool, json: bool) -> Result<()> {
    let who = super::unix_user("dismissal")?;
    let change = request(
        input,
        Request {
            who,
            undo,
            from_console: false,
        },
    )?;
    if let Some(dismissal) = change.dismissal {
        if json {
            println!(
                "{}",
                serde_json::json!({
                    "version": 1, "name": change.name, "group": change.group,
                    "dismissal": dismissal,
                })
            );
        } else {
            println!("dismissed {} from {}", change.name, change.group.title());
        }
    } else if json {
        println!(
            "{}",
            serde_json::json!({"version": 1, "name": change.name, "undone": true})
        );
    } else {
        println!("took back the dismissal of {}", change.name);
    }
    Ok(())
}

/// Shared mutation: Console callers supply their authenticated account; the CLI
/// supplies its Unix user. Neither caller changes GitHub or the live Command.
pub(crate) fn request(input: &str, request: Request) -> Result<Change> {
    let Request {
        who,
        undo,
        from_console,
    } = request;
    let source = if from_console { "Console" } else { "CLI" };
    let at = chrono::Local::now().to_rfc3339();
    let config = UserConfig::load()?;
    let snapshot = Snapshot::load_with_silence(
        &config.logs_dir,
        chrono::Local::now(),
        config.silent_interval,
    )?;
    let github = super::github::load(&config.logs_dir);
    let target = row_for(&snapshot, &github, input, config.inbox_days, undo)?;
    if target.members.is_empty() {
        bail!("cannot dismiss {input}: row has no Command record");
    }
    if undo {
        let mut taken_back = false;
        for (record, selector) in &target.members {
            let mut file = read(record);
            let kept = file.rows.len();
            file.rows.retain(|row| row.selector != *selector);
            if file.rows.len() != kept {
                taken_back = true;
                write(record, &file)?;
            }
        }
        if !taken_back {
            bail!("cannot take back the dismissal of {input}: row is not dismissed");
        }
        activity_write(
            &config,
            &target.repository,
            &format!(
                "{} dismissal taken back from the {source} by {who}",
                target.subject
            ),
        )?;
        return Ok(Change {
            name: target.name,
            group: target.group,
            dismissal: None,
        });
    }
    let mut dismissal = None;
    for (record, selector) in &target.members {
        let mut file = read(record);
        let mut kinds = covering(record, selector)
            .map(|row| row.kinds)
            .unwrap_or_default();
        if !kinds.contains(&target.kind) {
            kinds.push(target.kind.clone());
        }
        file.rows.retain(|row| row.selector != *selector);
        let row = DismissedRow {
            selector: selector.clone(),
            kind: target.kind.clone(),
            kinds,
            who: who.clone(),
            at: at.clone(),
            from_console,
        };
        dismissal.get_or_insert_with(|| row.view(None));
        file.rows.push(row);
        write(record, &file)?;
    }
    activity_write(
        &config,
        &target.repository,
        &format!("{} dismissed from the {source} by {who}", target.subject),
    )?;
    Ok(Change {
        name: target.name,
        group: target.group,
        dismissal,
    })
}