fallow-cli 3.2.0

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

use fallow_config::OutputFormat;
use serde::Serialize;
use serde_json::Value;

use crate::api::try_api_agent;
use crate::error::emit_error;

use super::{
    ApplyResult, CiProvider, PlannedReconcile, ReconcileOptions, apply_provider_reconcile,
    github_post_json, github_token, gitlab_post_json, load_provider_state, read_envelope,
    require_target, url_encode_path_segment,
};

#[derive(Clone, Copy)]
pub(super) struct PostReviewInput<'a> {
    pub provider: CiProvider,
    pub target: Option<&'a str>,
    pub envelope: &'a Path,
    pub repo: Option<&'a str>,
    pub project_id: Option<&'a str>,
    pub api_url: Option<&'a str>,
    pub dry_run: bool,
}

#[derive(Debug, Default, Serialize)]
struct PostReviewResult {
    action: &'static str,
    provider: &'static str,
    #[serde(skip_serializing_if = "Option::is_none")]
    target: Option<String>,
    dry_run: bool,
    comments_total: usize,
    comments_posted: usize,
    comments_skipped: usize,
    summary_posted: bool,
    summary_updated: bool,
    stale_fingerprints: usize,
    resolution_comments_posted: usize,
    threads_resolved: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    apply_hint: Option<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    apply_errors: Vec<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    post_errors: Vec<String>,
}

pub(super) fn post_review(input: PostReviewInput<'_>, output: OutputFormat) -> ExitCode {
    let envelope = match read_envelope(input.envelope) {
        Ok(value) => value,
        Err(e) => return emit_error(&e, 2, output),
    };
    let opts = ReconcileOptions {
        repo: input.repo,
        project_id: input.project_id,
        api_url: input.api_url,
        dry_run: input.dry_run,
    };
    let provider_state = match load_provider_state(input.provider, input.target, opts) {
        Ok(state) => state,
        Err(e) => return emit_error(&e, crate::api::NETWORK_EXIT_CODE, output),
    };
    let current = envelope_fingerprints(&envelope);
    let planned = PlannedReconcile::new(&current, &provider_state);

    let mut result = match input.provider {
        CiProvider::Github => post_github_review(input, &envelope, &provider_state.fingerprints),
        CiProvider::Gitlab => post_gitlab_review(input, &envelope, &provider_state.fingerprints),
    };
    if !result.post_errors.is_empty() {
        return emit_post_review_result(&result, output);
    }

    let applied = if input.dry_run {
        ApplyResult::default()
    } else {
        apply_provider_reconcile(input.provider, &planned, input.target, opts)
    };
    attach_reconcile_result(&mut result, &planned.plan.stale, applied);
    emit_post_review_result(&result, output)
}

fn post_github_review(
    input: PostReviewInput<'_>,
    envelope: &Value,
    existing_fingerprints: &BTreeSet<String>,
) -> PostReviewResult {
    let pr = match require_target("GitHub pull request", input.target) {
        Ok(pr) => pr,
        Err(e) => return result_with_error(input, e),
    };
    let repo = match github_repo(input.repo) {
        Ok(repo) => repo,
        Err(e) => return result_with_error(input, e),
    };
    let token = match github_token() {
        Ok(token) => token,
        Err(e) => return result_with_error(input, e),
    };
    let api = input
        .api_url
        .unwrap_or("https://api.github.com")
        .trim_end_matches('/');
    let agent = match try_api_agent() {
        Ok(agent) => agent,
        Err(e) => return result_with_error(input, e.to_string()),
    };
    let comments = new_comments(envelope, existing_fingerprints);
    let mut result = base_result(input, "github", comments.len());
    if envelope_comments_len(envelope) == 0 {
        result.action = "skip";
        return result;
    }
    result.comments_skipped = envelope_comments_len(envelope).saturating_sub(comments.len());
    if comments.is_empty() {
        result.action = "skip";
        return result;
    }
    if input.dry_run {
        result.action = "post_review";
        result.comments_posted = comments.len();
        return result;
    }
    let payload = github_review_payload(envelope, &comments);
    let url = format!("{api}/repos/{repo}/pulls/{pr}/reviews");
    match github_post_json(&agent, &url, &token, &payload) {
        Ok(_) => {
            result.action = "post_review";
            result.comments_posted = comments.len();
        }
        Err(e) => result.post_errors.push(e),
    }
    result
}

fn post_gitlab_review(
    input: PostReviewInput<'_>,
    envelope: &Value,
    existing_fingerprints: &BTreeSet<String>,
) -> PostReviewResult {
    let mr = match require_target("GitLab merge request", input.target) {
        Ok(mr) => mr,
        Err(e) => return result_with_error(input, e),
    };
    let project_id = match gitlab_project_id(input.project_id) {
        Ok(project_id) => project_id,
        Err(e) => return result_with_error(input, e),
    };
    let token = match gitlab_token() {
        Ok(token) => token,
        Err(e) => return result_with_error(input, e),
    };
    let api = gitlab_api_url(input.api_url);
    let encoded_project = url_encode_path_segment(&project_id);
    let agent = match try_api_agent() {
        Ok(agent) => agent,
        Err(e) => return result_with_error(input, e.to_string()),
    };
    let comments = new_comments(envelope, existing_fingerprints);
    let mut result = base_result(input, "gitlab", comments.len());
    if envelope_comments_len(envelope) == 0 {
        result.action = "skip";
        return result;
    }
    result.comments_skipped = envelope_comments_len(envelope).saturating_sub(comments.len());
    if comments.is_empty() {
        result.action = "skip";
        return result;
    }
    result.action = "post_review";
    if input.dry_run {
        result.comments_posted = comments.len();
        return result;
    }
    for comment in &comments {
        match post_gitlab_inline_comment(&agent, &api, &encoded_project, mr, &token, comment) {
            Ok(()) => result.comments_posted += 1,
            Err(e) => result.post_errors.push(e),
        }
    }
    result
}

fn github_review_payload(envelope: &Value, comments: &[Value]) -> Value {
    let comments = comments
        .iter()
        .map(|comment| {
            serde_json::json!({
                "path": comment.get("path").cloned().unwrap_or(Value::Null),
                "line": comment.get("line").cloned().unwrap_or(Value::Null),
                "side": comment.get("side").cloned().unwrap_or(Value::Null),
                "body": comment.get("body").cloned().unwrap_or(Value::Null),
            })
        })
        .collect::<Vec<_>>();
    serde_json::json!({
        "event": envelope.get("event").and_then(Value::as_str).unwrap_or("COMMENT"),
        "body": envelope.get("body").and_then(Value::as_str).unwrap_or(""),
        "comments": comments,
    })
}

fn post_gitlab_inline_comment(
    agent: &ureq::Agent,
    api: &str,
    encoded_project: &str,
    mr: &str,
    token: &str,
    comment: &Value,
) -> Result<(), String> {
    let position = comment.get("position").cloned().unwrap_or(Value::Null);
    let body = comment
        .get("body")
        .and_then(Value::as_str)
        .unwrap_or("")
        .to_owned();
    if position.get("base_sha").and_then(Value::as_str).is_some()
        && position.get("head_sha").and_then(Value::as_str).is_some()
    {
        let payload = serde_json::json!({ "body": body, "position": position });
        let url = format!("{api}/projects/{encoded_project}/merge_requests/{mr}/discussions");
        return gitlab_post_json(agent, &url, token, &payload).map(|_| ());
    }
    let path = position
        .get("new_path")
        .and_then(Value::as_str)
        .unwrap_or("unknown");
    let line = position
        .get("new_line")
        .and_then(Value::as_u64)
        .unwrap_or(1);
    let fallback_body = format!("Warning: **{path}:{line}**\n\n{body}");
    let payload = serde_json::json!({ "body": fallback_body });
    let url = format!("{api}/projects/{encoded_project}/merge_requests/{mr}/notes");
    gitlab_post_json(agent, &url, token, &payload).map(|_| ())
}

fn attach_reconcile_result(result: &mut PostReviewResult, stale: &[String], applied: ApplyResult) {
    result.stale_fingerprints = stale.len();
    result.resolution_comments_posted = applied.resolution_comments_posted;
    result.threads_resolved = applied.threads_resolved;
    result.apply_hint = applied.hint();
    result.apply_errors = applied.errors;
}

fn new_comments(envelope: &Value, existing: &BTreeSet<String>) -> Vec<Value> {
    envelope
        .get("comments")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .filter(|comment| {
            comment
                .get("fingerprint")
                .and_then(Value::as_str)
                .is_none_or(|fingerprint| !existing.contains(fingerprint))
        })
        .cloned()
        .collect()
}

fn envelope_comments_len(envelope: &Value) -> usize {
    envelope
        .get("comments")
        .and_then(Value::as_array)
        .map_or(0, Vec::len)
}

fn envelope_fingerprints(envelope: &Value) -> BTreeSet<String> {
    envelope
        .get("comments")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(|comment| comment.get("fingerprint").and_then(Value::as_str))
        .filter(|fingerprint| !fingerprint.trim().is_empty())
        .map(str::to_owned)
        .collect()
}

fn base_result(
    input: PostReviewInput<'_>,
    provider: &'static str,
    comments_total: usize,
) -> PostReviewResult {
    PostReviewResult {
        action: "skip",
        provider,
        target: input.target.map(str::to_owned),
        dry_run: input.dry_run,
        comments_total,
        ..PostReviewResult::default()
    }
}

fn result_with_error(input: PostReviewInput<'_>, error: String) -> PostReviewResult {
    let mut result = base_result(
        input,
        match input.provider {
            CiProvider::Github => "github",
            CiProvider::Gitlab => "gitlab",
        },
        0,
    );
    result.post_errors.push(error);
    result
}

fn emit_post_review_result(result: &PostReviewResult, output: OutputFormat) -> ExitCode {
    match serde_json::to_value(result) {
        Ok(value) => crate::report::emit_json(&value, "review post result"),
        Err(e) => emit_error(&format!("JSON serialization error: {e}"), 2, output),
    }
}

fn github_repo(explicit: Option<&str>) -> Result<String, String> {
    explicit
        .map(str::to_owned)
        .or_else(|| std::env::var("GH_REPO").ok())
        .or_else(|| std::env::var("GITHUB_REPOSITORY").ok())
        .ok_or_else(|| {
            "GitHub review posting requires --repo, GH_REPO, or GITHUB_REPOSITORY".to_owned()
        })
}

fn gitlab_project_id(explicit: Option<&str>) -> Result<String, String> {
    explicit
        .map(str::to_owned)
        .or_else(|| std::env::var("CI_PROJECT_ID").ok())
        .ok_or_else(|| "GitLab review posting requires --project-id or CI_PROJECT_ID".to_owned())
}

fn gitlab_token() -> Result<String, String> {
    std::env::var("GITLAB_TOKEN")
        .map_err(|_| "GitLab review posting requires GITLAB_TOKEN".to_owned())
}

fn gitlab_api_url(explicit: Option<&str>) -> String {
    explicit
        .map(str::to_owned)
        .or_else(|| std::env::var("CI_API_V4_URL").ok())
        .unwrap_or_else(|| "https://gitlab.com/api/v4".to_owned())
        .trim_end_matches('/')
        .to_owned()
}

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

    fn envelope() -> Value {
        serde_json::json!({
            "event": "COMMENT",
            "body": "body",
            "comments": [
                { "path": "src/a.ts", "line": 1, "side": "RIGHT", "body": "A", "fingerprint": "a" },
                { "path": "src/b.ts", "line": 2, "side": "RIGHT", "body": "B", "fingerprint": "b" }
            ]
        })
    }

    #[test]
    fn new_comments_filters_existing_fingerprints() {
        let existing = BTreeSet::from(["a".to_owned()]);

        let comments = new_comments(&envelope(), &existing);

        assert_eq!(comments.len(), 1);
        assert_eq!(comments[0]["fingerprint"], "b");
    }

    #[test]
    fn github_review_payload_keeps_retryable_input_body() {
        let comments = new_comments(&envelope(), &BTreeSet::new());

        let payload = github_review_payload(&envelope(), &comments);

        assert_eq!(payload["event"], "COMMENT");
        assert_eq!(payload["comments"][0]["path"], "src/a.ts");
        assert_eq!(payload["comments"][0]["side"], "RIGHT");
    }
}