fallow-cli 3.4.2

CLI for fallow, codebase intelligence for TypeScript and JavaScript
Documentation
use std::path::Path;
use std::process::ExitCode;

use fallow_config::OutputFormat;
use fallow_output::{PrDecisionAnnotation, PrDecisionAnnotationLevel, PrDecisionConclusion};
use serde_json::{Value, json};

use crate::error::emit_error;

use super::{github_post_json, github_token, read_text_file};

#[derive(Clone, Copy)]
pub(super) struct PostCheckRunInput<'a> {
    pub decision: &'a Path,
    pub repo: &'a str,
    pub head_sha: &'a str,
    pub api_url: Option<&'a str>,
    pub split_gates: bool,
    pub dry_run: bool,
}

pub(super) fn post_check_run(input: &PostCheckRunInput<'_>, output: OutputFormat) -> ExitCode {
    let decision = match read_decision(input.decision) {
        Ok(decision) => decision,
        Err(e) => return emit_error(&e, 2, output),
    };
    let payloads = github_check_run_payloads(&decision, input.head_sha, input.split_gates);
    if input.dry_run {
        let value = if input.split_gates {
            Value::Array(payloads)
        } else {
            payloads.into_iter().next().unwrap_or_else(|| json!({}))
        };
        return crate::report::emit_json(&value, "check run payload");
    }
    let token = match github_token() {
        Ok(token) => token,
        Err(e) => return emit_error(&e, crate::api::NETWORK_EXIT_CODE, output),
    };
    let agent = match crate::api::try_api_agent() {
        Ok(agent) => agent,
        Err(e) => return emit_error(&e.to_string(), crate::api::NETWORK_EXIT_CODE, output),
    };
    let api = input
        .api_url
        .unwrap_or("https://api.github.com")
        .trim_end_matches('/');
    let url = format!("{api}/repos/{}/check-runs", input.repo);
    let mut results = Vec::new();
    for payload in &payloads {
        match github_post_json(&agent, &url, &token, payload) {
            Ok(value) => results.push(value),
            Err(e) => return emit_error(&e, crate::api::NETWORK_EXIT_CODE, output),
        }
    }
    crate::report::emit_json(&Value::Array(results), "check run result")
}

fn read_decision(path: &Path) -> Result<fallow_output::PrDecisionSurface, String> {
    let text = read_text_file(path, "PR decision surface")?;
    serde_json::from_str(&text).map_err(|e| {
        format!(
            "failed to parse PR decision surface '{}': {e}",
            path.display()
        )
    })
}

fn github_check_run_payload(decision: &fallow_output::PrDecisionSurface, head_sha: &str) -> Value {
    let annotations = decision
        .annotations
        .iter()
        .take(50)
        .map(github_annotation)
        .collect::<Vec<_>>();
    github_check_run_payload_with_output(
        "Fallow",
        head_sha,
        decision.conclusion,
        &decision.title,
        &decision.details.summary_markdown,
        &annotations,
    )
}

fn github_check_run_payloads(
    decision: &fallow_output::PrDecisionSurface,
    head_sha: &str,
    split_gates: bool,
) -> Vec<Value> {
    if !split_gates {
        return vec![github_check_run_payload(decision, head_sha)];
    }
    let contexts = fallow_output::pr_status_contexts_with_mode(
        decision,
        status_mode_from_env(input_split_mode(split_gates)),
    );
    if contexts.is_empty() {
        return vec![github_check_run_payload(decision, head_sha)];
    }
    contexts
        .into_iter()
        .map(|context| {
            github_check_run_payload_with_output(
                &context.name,
                head_sha,
                context.conclusion,
                &context.name,
                &context.summary,
                &[],
            )
        })
        .collect()
}

fn input_split_mode(split_gates: bool) -> fallow_output::PrStatusMode {
    if split_gates {
        fallow_output::PrStatusMode::Split
    } else {
        fallow_output::PrStatusMode::Aggregate
    }
}

fn status_mode_from_env(default: fallow_output::PrStatusMode) -> fallow_output::PrStatusMode {
    if default != fallow_output::PrStatusMode::Split {
        return default;
    }
    match std::env::var("FALLOW_CONSOLIDATED_STATUS").as_deref() {
        Ok("1" | "true" | "yes" | "on") => fallow_output::PrStatusMode::AggregateAndSplit,
        _ => default,
    }
}

fn github_check_run_payload_with_output(
    name: &str,
    head_sha: &str,
    conclusion: PrDecisionConclusion,
    title: &str,
    summary: &str,
    annotations: &[Value],
) -> Value {
    json!({
        "name": name,
        "head_sha": head_sha,
        "status": "completed",
        "conclusion": github_conclusion(conclusion),
        "output": {
            "title": title,
            "summary": summary,
            "annotations": annotations
        }
    })
}

fn github_conclusion(conclusion: PrDecisionConclusion) -> &'static str {
    match conclusion {
        PrDecisionConclusion::Success => "success",
        PrDecisionConclusion::Failure => "failure",
        PrDecisionConclusion::Neutral => "neutral",
        PrDecisionConclusion::Skipped => "skipped",
    }
}

fn github_annotation(annotation: &PrDecisionAnnotation) -> Value {
    let mut value = json!({
        "path": annotation.path,
        "start_line": annotation.line.max(1),
        "end_line": annotation.line.max(1),
        "annotation_level": github_annotation_level(annotation.level),
        "message": annotation.message,
        "title": annotation.title,
    });
    if let Some(raw_details) = annotation.raw_details.as_ref()
        && let Some(object) = value.as_object_mut()
    {
        object.insert("raw_details".to_owned(), json!(raw_details));
    }
    value
}

fn github_annotation_level(level: PrDecisionAnnotationLevel) -> &'static str {
    match level {
        PrDecisionAnnotationLevel::Notice => "notice",
        PrDecisionAnnotationLevel::Warning => "warning",
        PrDecisionAnnotationLevel::Failure => "failure",
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn github_payload_maps_decision_to_check_run() {
        let decision = fallow_output::PrDecisionSurface {
            schema: fallow_output::PR_DECISION_SCHEMA.to_owned(),
            title: "Fallow".to_owned(),
            conclusion: PrDecisionConclusion::Failure,
            gates: vec![],
            annotations: vec![PrDecisionAnnotation {
                path: "src/app.ts".to_owned(),
                line: 12,
                level: PrDecisionAnnotationLevel::Warning,
                title: "fallow/high-crap-score".to_owned(),
                message: "Function is hard to safely change.".to_owned(),
                raw_details: Some("Extract smaller units.".to_owned()),
            }],
            details: fallow_output::PrDecisionDetails {
                summary_markdown: "Quality gate failed.".to_owned(),
                full_report_path: None,
                details_url: None,
            },
        };

        let payload = github_check_run_payload(&decision, "abc123");

        assert_eq!(payload["name"], "Fallow");
        assert_eq!(payload["head_sha"], "abc123");
        assert_eq!(payload["conclusion"], "failure");
        assert_eq!(payload["output"]["summary"], "Quality gate failed.");
        assert_eq!(
            payload["output"]["annotations"][0]["annotation_level"],
            "warning"
        );
    }

    #[test]
    fn github_annotation_uses_line_one_for_zero_line_findings() {
        let annotation = PrDecisionAnnotation {
            path: "package.json".to_owned(),
            line: 0,
            level: PrDecisionAnnotationLevel::Notice,
            title: "fallow/unused-dependency".to_owned(),
            message: "Dependency appears unused.".to_owned(),
            raw_details: None,
        };

        let value = github_annotation(&annotation);

        assert_eq!(value["start_line"], 1);
        assert!(value.get("raw_details").is_none());
    }

    #[test]
    fn split_gate_payloads_use_gate_names_without_annotations() {
        let decision = fallow_output::PrDecisionSurface {
            schema: fallow_output::PR_DECISION_SCHEMA.to_owned(),
            title: "Fallow".to_owned(),
            conclusion: PrDecisionConclusion::Neutral,
            gates: vec![fallow_output::PrDecisionGate {
                id: "health".to_owned(),
                label: "Health".to_owned(),
                status: PrDecisionConclusion::Neutral,
                observed: "1 finding".to_owned(),
                threshold: Some("configured complexity gates".to_owned()),
                scope: "new code".to_owned(),
            }],
            annotations: vec![PrDecisionAnnotation {
                path: "src/app.ts".to_owned(),
                line: 12,
                level: PrDecisionAnnotationLevel::Warning,
                title: "fallow/high-crap-score".to_owned(),
                message: "Function is hard to safely change.".to_owned(),
                raw_details: None,
            }],
            details: fallow_output::PrDecisionDetails {
                summary_markdown: "Review recommended.".to_owned(),
                full_report_path: None,
                details_url: None,
            },
        };

        let payloads = github_check_run_payloads(&decision, "abc123", true);

        assert_eq!(payloads.len(), 1);
        assert_eq!(payloads[0]["name"], "Fallow / health");
        assert_eq!(payloads[0]["conclusion"], "neutral");
        assert_eq!(
            payloads[0]["output"]["annotations"]
                .as_array()
                .expect("annotations array")
                .len(),
            0
        );
    }
}