thirdshift 0.16.3

Turn a GitHub issue into a ready-for-review pull request, unattended
//! Counts from Delivery's last published Unaddressed findings section.

use serde::{Deserialize, Serialize};

#[derive(Clone, Default, Serialize, Deserialize)]
pub(crate) struct UnaddressedFindings {
    pub(crate) standards: Option<usize>,
    pub(crate) spec: Option<usize>,
    pub(crate) security: Option<usize>,
}

enum Axis {
    Standards,
    Spec,
    Security,
    SecurityOutcome,
}

impl Axis {
    fn named(text: &str) -> Option<Self> {
        match label(text).to_ascii_lowercase().as_str() {
            "standards" => Some(Self::Standards),
            "spec" => Some(Self::Spec),
            "security" => Some(Self::Security),
            "security review outcome" => Some(Self::SecurityOutcome),
            _ => None,
        }
    }
}

fn label(text: &str) -> &str {
    text.trim_matches(|c: char| c.is_whitespace() || matches!(c, '*' | '_' | ':' | '.' | '#'))
}

fn list_item(text: &str) -> Option<&str> {
    text.strip_prefix("- ")
        .or_else(|| text.strip_prefix("* "))
        .or_else(|| text.strip_prefix("+ "))
        .or_else(|| {
            let end = text.find(|c: char| !c.is_ascii_digit())?;
            if end == 0 {
                return None;
            }
            text[end..]
                .strip_prefix(". ")
                .or_else(|| text[end..].strip_prefix(") "))
        })
}

impl UnaddressedFindings {
    pub(super) fn include_security_review(&mut self, count: usize) {
        self.security = Some(self.security.unwrap_or_default().max(count));
    }

    pub(super) fn from_security_review(count: usize) -> Self {
        Self {
            security: Some(count),
            ..Self::default()
        }
    }

    pub(super) fn from_body(body: &str) -> Self {
        let (mut standards, mut spec, mut security) = (0, 0, 0);
        let mut section_level = None;
        let mut axis = None;
        let mut item_indent = None;
        let mut security_outcome = 0;
        let mut fence = None;
        for line in body.lines() {
            let text = line.trim();
            if text.starts_with("```") || text.starts_with("~~~") {
                let marker = &text[..3];
                if fence == Some(marker) {
                    fence = None;
                } else if fence.is_none() {
                    fence = Some(marker);
                }
                continue;
            }
            if fence.is_some() {
                continue;
            }
            let indent = line.len() - line.trim_start().len();
            if item_indent.is_some_and(|root| indent > root) {
                continue;
            }
            let title = text.trim_start_matches('#');
            let level = text.len() - title.len();
            if level > 0 && title.starts_with(' ') {
                if label(title).eq_ignore_ascii_case("Unaddressed findings") {
                    section_level = Some(level);
                    axis = None;
                } else if let Some(section) = section_level {
                    if level <= section {
                        section_level = None;
                    }
                    axis = Axis::named(title);
                }
                item_indent = None;
                continue;
            }
            if section_level.is_none() {
                continue;
            }
            let item = list_item(text);
            if let Some(named) = Axis::named(item.unwrap_or(text)) {
                axis = Some(named);
                item_indent = None;
                continue;
            }
            let Some(item) = item else { continue };
            if matches!(
                label(item).to_ascii_lowercase().as_str(),
                "" | "none"
                    | "no findings"
                    | "no unaddressed findings"
                    | "no unaddressed introduced findings"
            ) {
                continue;
            }
            item_indent.get_or_insert(indent);
            match axis {
                Some(Axis::Standards) => standards += 1,
                Some(Axis::Spec) => spec += 1,
                Some(Axis::Security) => security += 1,
                Some(Axis::SecurityOutcome) => security_outcome += 1,
                None => {}
            }
        }
        // Delivery's deterministic summary repeats the review's detailed entries.
        Self {
            standards: Some(standards),
            spec: Some(spec),
            security: Some(security.max(security_outcome)),
        }
    }
}