thirdshift 0.16.5

Turn a GitHub issue into a ready-for-review pull request, unattended
//! Completion facts, saved by their owner or reconstructed from older records.

use std::collections::HashSet;

use chrono::DateTime;
use serde::{Deserialize, Serialize};

use super::command_record::{CommandRecord, StageName};
use super::session_signals::{SessionSignals, Summary};
use super::usual_range::{UsualRange, UsualRanges};

#[derive(Clone, Serialize, Deserialize)]
pub(super) struct RunMetrics {
    pub(super) outcome: String,
    pub(super) repairs: usize,
    pub(super) ci_rounds: usize,
    #[serde(default)]
    pub(super) unaddressed_findings: Option<crate::pull_request::UnaddressedFindings>,
    pub(super) stages: Vec<StageMetrics>,
    pub(super) total_minutes: Option<f64>,
    #[serde(default)]
    pub(super) session_signals: Option<std::collections::BTreeMap<String, Summary>>,
}

#[derive(Clone, Serialize, Deserialize)]
pub(super) struct StageMetrics {
    pub(super) name: String,
    pub(super) seconds: Option<f64>,
    pub(super) minutes: Option<f64>,
    pub(super) usual_range: Option<UsualRange>,
    pub(super) position: Position,
    // Only the first stage referencing a Session holds its review counts.
    pub(super) review_findings: Option<ReviewFindings>,
    #[serde(default)]
    pub(super) session_signals: Option<SessionSignals>,
}

#[derive(Clone, Serialize, Deserialize)]
pub(super) struct ReviewFindings {
    pub(super) standards: Option<u64>,
    pub(super) spec: Option<u64>,
}

#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum Position {
    Below,
    Within,
    Above,
    TooFewRuns,
}

impl RunMetrics {
    pub(super) fn add_session_signals(
        &mut self,
        record: &CommandRecord,
        mut read: impl FnMut(&std::path::Path) -> Option<SessionSignals>,
    ) {
        if self.session_signals.is_some() {
            return;
        }
        let mut seen = HashSet::new();
        let mut summaries = std::collections::BTreeMap::<String, Summary>::new();
        for (stage, metric) in record.stages.iter().zip(&mut self.stages) {
            if let Some(log) = stage.session_log.as_deref()
                && seen.insert(super::session::original(log))
            {
                metric.session_signals = read(log);
                summaries
                    .entry(stage.name.clone())
                    .or_default()
                    .add(metric.session_signals.as_ref());
            }
        }
        self.session_signals = Some(summaries);
    }

    pub(super) fn of(
        record: &CommandRecord,
        ranges: &UsualRanges,
        outside: &mut impl super::effects::Outside,
        unaddressed_findings: Option<crate::pull_request::UnaddressedFindings>,
    ) -> Self {
        let mut metrics = Self::calculate(record, ranges, unaddressed_findings, |log| {
            outside
                .review_summary(log)
                .and_then(|bytes| serde_json::from_slice(&bytes).ok())
        });
        metrics.add_session_signals(record, |log| outside.session_signals(log));
        metrics
    }

    /// Old records have no saved review counts or Usual ranges. Do not infer
    /// those from sidecars or today's history, or write anything back.
    pub(super) fn historical(record: &CommandRecord) -> Self {
        Self::calculate(record, &UsualRanges::default(), None, |_| None)
    }

    fn calculate(
        record: &CommandRecord,
        ranges: &UsualRanges,
        unaddressed_findings: Option<crate::pull_request::UnaddressedFindings>,
        mut review: impl FnMut(&std::path::Path) -> Option<ReviewFindings>,
    ) -> Self {
        let mut sessions = HashSet::new();
        let count = |name: StageName| {
            record
                .stages
                .iter()
                .filter(|stage| stage.name == name.name())
                .count()
        };
        Self {
            outcome: record
                .outcome
                .clone()
                .expect("a finished Run has an outcome"),
            repairs: count(StageName::Repair),
            ci_rounds: count(StageName::CiWatch),
            unaddressed_findings,
            stages: record
                .stage_times(None)
                .map(|timing| {
                    let seconds = timing.seconds();
                    let usual_range = ranges.get(&record.repository, &timing.stage.name);
                    let position = match seconds.zip(usual_range) {
                        Some((seconds, range)) if seconds < range.lower_seconds => Position::Below,
                        Some((seconds, range)) if seconds > range.upper_seconds => Position::Above,
                        Some(_) => Position::Within,
                        None => Position::TooFewRuns,
                    };
                    StageMetrics {
                        name: timing.stage.name.clone(),
                        seconds,
                        minutes: seconds.map(|seconds| seconds / 60.0),
                        usual_range,
                        position,
                        review_findings: timing
                            .stage
                            .session_log
                            .as_deref()
                            .filter(|log| sessions.insert(log.to_path_buf()))
                            .and_then(&mut review),
                        session_signals: None,
                    }
                })
                .collect(),
            total_minutes: record.ended_at.as_deref().and_then(|ended| {
                let began = DateTime::parse_from_rfc3339(&record.started_at).ok()?;
                let ended = DateTime::parse_from_rfc3339(ended).ok()?;
                let milliseconds = ended.signed_duration_since(began).num_milliseconds();
                (milliseconds >= 0).then_some(milliseconds as f64 / 60_000.0)
            }),
            session_signals: None,
        }
    }
}