terraphim_orchestrator 1.20.2

AI Dark Factory orchestrator wiring spawner, router, supervisor into a reconciliation loop
Documentation
//! Orchestrator-owned verdict comment poster. Refs #2301.
//!
//! Reads the agent's drain `.log`, runs the per-CLI assistant-text
//! extractor, runs the result through `pr_review::parse_verdict`, and posts
//! a parseable verdict comment under the `pr-reviewer` login with a
//! `Last reviewed commit: <head[:7]>` footer. The caller (the pr_review
//! reconcile arm) derives the `adf/<reviewer>` commit status from the
//! returned `VerdictPostOutcome` — a no-parseable-verdict run is NOT green.
//!
//! Idempotency is per-(project, pr_number, headsha): we list existing
//! comments by the `pr-reviewer` token and skip posting if a comment with
//! the same footer already exists. The `Reviews (N)` suffix is incremented
//! on a fresh head so the remediation loop's re-score produces a distinct
//! comment for the new SHA, not a duplicate of the previous head's review.
//!
//! No mocks: the `OutputPoster` wrapper drives the real Gitea comment API,
//! and the call site is wired to the existing axum fake-Gitea harness in
//! `tests/remediation_tests.rs`.

use std::path::Path;

use serde::{Deserialize, Serialize};
use tracing::warn;

use crate::output_poster::OutputPoster;
use crate::pr_review::extractor;
use crate::pr_review::parse_verdict;

/// Per-PR metadata the poster needs to construct the verdict body and the
/// idempotency key.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrVerdictMeta {
    /// The project id the PR lives in (used for the idempotency key and the
    /// `OutputPoster` tracker routing).
    pub project: String,
    /// Gitea owner (for `post_raw_as_agent_for_project`).
    pub owner: String,
    /// Gitea repo.
    pub repo: String,
    /// PR number.
    pub pr_number: u64,
    /// Full 40-char head SHA.
    pub head_sha: String,
    /// First 7 hex characters of the head SHA (for the footer + idempotency).
    pub head_short: String,
    /// Author login (recorded in the body for context).
    pub author_login: String,
    /// PR title (recorded in the body for context).
    pub title: String,
    /// Reported diff size in LOC.
    pub diff_loc: u64,
    /// CLI tool the agent ran under (used to dispatch the extractor).
    pub cli_tool: String,
    /// Agent name (`pr-reviewer` / `pr-validator` / `pr-verifier`).
    pub agent_name: String,
    /// The min_confidence threshold below which a parseable verdict is
    /// still green-with-concerns rather than unconditional green.
    pub min_confidence: u8,
}

/// Outcome of the verdict-post attempt. The caller maps this to the
/// `adf/<reviewer>` commit status per the design's mapping (Step 4).
#[derive(Debug, PartialEq, Eq)]
pub enum VerdictPostOutcome {
    /// A parseable verdict was found and a comment was POSTed.
    Posted {
        /// The newly-posted Gitea comment id.
        comment_id: u64,
        /// Parsed confidence (1..=5).
        confidence: u8,
    },
    /// A comment with the same `Last reviewed commit: <head_short>` footer
    /// already exists for this PR (idempotency hit) — the existing comment
    /// stands; the caller treats the run as success.
    AlreadyPresent { existing_comment_id: u64 },
    /// The drain log was readable but contained no parseable verdict text.
    /// The caller should set `adf/<reviewer> = failure` (NOT green) per R1.
    NoParseableVerdict { reason: &'static str },
    /// The drain log was missing or unreadable.
    NoDrain { reason: String },
}

/// Read the agent's drain `.log`, extract the final assistant text, render
/// a parseable verdict body, and post it under the `pr-reviewer` login
/// (matching `is_pr_reviewer_comment`'s footer-anchor fallback so the
/// existing `evaluate_pr_verdict` recognises it).
///
/// `output_poster` is the existing `OutputPoster` wrapper that owns the
/// Gitea API client and the per-project token routing. The poster is
/// infallible at the call boundary: any Gitea error is converted into a
/// `NoDrain` outcome so the caller still gets a deterministic result and
/// can set the commit status accordingly.
pub async fn post_verdict_from_drain(
    log_path: &Path,
    pr_meta: &PrVerdictMeta,
    output_poster: &OutputPoster,
) -> VerdictPostOutcome {
    // 1. Read the drain log.
    let lines: Vec<String> = match std::fs::read_to_string(log_path) {
        Ok(content) => content.lines().map(|s| s.to_string()).collect(),
        Err(e) => {
            return VerdictPostOutcome::NoDrain {
                reason: format!("read_to_string({}): {e}", log_path.display()),
            };
        }
    };
    if lines.is_empty() {
        return VerdictPostOutcome::NoDrain {
            reason: "drain log empty".to_string(),
        };
    }

    // 2. Extract the final assistant text.
    let Some(text) = extractor::extract_final_assistant_text(&lines, &pr_meta.cli_tool) else {
        return VerdictPostOutcome::NoParseableVerdict {
            reason: "no recognisable assistant text in stream",
        };
    };
    let trimmed = text.trim();
    if trimmed.is_empty() {
        return VerdictPostOutcome::NoParseableVerdict {
            reason: "extracted text was empty after trim",
        };
    }

    // 3+4. List the existing comments ONCE and do both the idempotency
    //    check and the Reviews (N) count on the same Vec. The two helpers
    //    are folded into a single fetch so we don't make 2x the network
    //    calls to Gitea for the same list.
    let tracker = output_poster.tracker_for(&pr_meta.project, "pr-reviewer");
    let footer_marker = format!("Last reviewed commit: {}", pr_meta.head_short);
    let comments = match tracker {
        Some(t) => match t.fetch_comments(pr_meta.pr_number, None).await {
            Ok(c) => c,
            Err(e) => {
                warn!(
                    pr = pr_meta.pr_number,
                    project = %pr_meta.project,
                    error = %e,
                    "idempotency list failed; treating as fresh post (caller may re-run)"
                );
                Vec::new()
            }
        },
        None => Vec::new(),
    };

    // 3. Idempotency: skip posting if a comment for the same head SHA
    //    already exists.
    if let Some(existing) = comments.iter().find(|c| c.body.contains(&footer_marker)) {
        return VerdictPostOutcome::AlreadyPresent {
            existing_comment_id: existing.id,
        };
    }

    // 4. Determine the `Reviews (N)` counter for the body footer. N is the
    //    count of existing comments on this PR whose footer carries the
    //    same head short hash, plus one for the post we are about to make.
    let reviews_n = comments
        .iter()
        .filter(|c| c.body.contains(&footer_marker))
        .count() as u32
        + 1;

    // 5. Render the verdict body. The model is told (Step 1) to emit
    //    exactly the four anchors. We wrap the extracted text in a body
    //    that parse_verdict accepts: a Summary section, a Confidence Score
    //    section, an Inline Findings section (or none), and the footer.
    //
    //    The model may have emitted the anchors in any order; the wrapper
    //    here ensures the body is well-formed even if the model only
    //    produced free-form text. The PR's title, author, and diff LOC
    //    are surfaced under Summary as context for downstream readers.
    let body = render_verdict_body(
        trimmed,
        &pr_meta.head_short,
        reviews_n,
        &pr_meta.title,
        &pr_meta.author_login,
        pr_meta.diff_loc,
    );

    // 6. Sanity-check the body before posting. If parse_verdict rejects
    //    it, we do NOT post a fake verdict -- the caller will set
    //    `adf/<reviewer> = failure`. The parsed confidence is kept so
    //    we can surface it on the Posted outcome.
    let confidence = match parse_verdict(&body, 0) {
        Ok(v) => v.confidence,
        Err(_) => {
            return VerdictPostOutcome::NoParseableVerdict {
                reason: "rendered body failed parse_verdict",
            };
        }
    };

    // 7. Post the comment under the `pr-reviewer` login so
    //    `is_pr_reviewer_comment` recognises it by both the footer
    //    anchor AND the user login. The body carries the canonical
    //    `Last reviewed commit: <head_short> | Reviews (N)` footer.
    match output_poster
        .post_raw_as_agent_for_project(&pr_meta.project, "pr-reviewer", pr_meta.pr_number, &body)
        .await
    {
        Ok(comment_id) => VerdictPostOutcome::Posted {
            comment_id,
            confidence,
        },
        Err(e) => VerdictPostOutcome::NoDrain {
            reason: format!("post_raw_as_agent_for_project failed: {e}"),
        },
    }
}

/// Render the verdict body. Wraps the extracted text in the four anchors
/// `parse_verdict` requires: Summary / Confidence Score / Inline Findings /
/// `Last reviewed commit: <head_short> | Reviews (N)`.
///
/// The extracted text is placed under `### Summary`. If the extracted text
/// already contains the four anchors (the well-formed case), we keep it
/// as-is and only append the canonical footer -- avoiding a double-wrap
/// that would confuse the parse. The `is_already_well_formed` check
/// anchors on the four heading markers.
fn render_verdict_body(
    extracted: &str,
    head_short: &str,
    reviews_n: u32,
    title: &str,
    author_login: &str,
    diff_loc: u64,
) -> String {
    let canonical_footer = format!(
        "<sub>Last reviewed commit: {head_short} | Reviews ({n})</sub>",
        n = reviews_n
    );

    // If the extracted text already carries the four anchors, replace
    // any existing footer with the canonical one (so the Reviews (N)
    // counter stays accurate across rounds) and return.
    let has_summary = extracted.contains("### Summary") || extracted.contains("## Summary");
    let has_confidence =
        extracted.contains("### Confidence Score:") || extracted.contains("## Confidence Score:");
    let has_findings = extracted.contains("### Inline Findings")
        || extracted.contains("## Inline Findings")
        || extracted.contains("### Findings")
        || extracted.contains("## Findings");
    if has_summary && has_confidence && has_findings {
        return strip_and_reattach_footer(extracted, &canonical_footer);
    }

    // Otherwise wrap the extracted text under Summary. We attempt to
    // extract a confidence score from the text via a regex (the model
    // might have used a different heading), and we leave Inline Findings
    // empty (the caller can fill it in via re-review).
    let confidence_line = extract_confidence_line(extracted)
        .unwrap_or_else(|| "### Confidence Score: 3/5".to_string());

    // The PR context line is surfaced in the body so downstream readers
    // (humans and the evaluate_pr_verdict footer-anchor path) can see
    // which PR the review covers without scrolling to the Gitea page.
    let context_line = format!("_PR_: {title} _(by {author_login}, {diff_loc} LOC)_");

    format!(
        "### Summary\n\n{context_line}\n\n{extracted}\n\n{confidence_line}\n\n### Inline Findings\n\n{footer}",
        context_line = context_line,
        confidence_line = confidence_line,
        footer = canonical_footer
    )
}

fn strip_and_reattach_footer(extracted: &str, canonical_footer: &str) -> String {
    // Drop any existing <sub>Last reviewed commit: ...</sub> line and
    // append the canonical one. This keeps the `Reviews (N)` counter
    // accurate across rounds.
    let mut out = String::with_capacity(extracted.len() + canonical_footer.len() + 8);
    for line in extracted.lines() {
        if line.trim_start().starts_with("<sub>Last reviewed commit:") {
            continue;
        }
        if !out.is_empty() {
            out.push('\n');
        }
        out.push_str(line);
    }
    // Ensure a blank line before the footer.
    if !out.ends_with('\n') {
        out.push('\n');
    }
    out.push('\n');
    out.push_str(canonical_footer);
    out
}

fn extract_confidence_line(extracted: &str) -> Option<String> {
    // The model may have written `Confidence Score: N/5` anywhere in
    // the body (any heading depth). Find the first match.
    for line in extracted.lines() {
        let l = line.trim_start();
        // Match: any prefix of '#' followed by 'Confidence Score: N/5'.
        if let Some(idx) = l.find("Confidence Score:") {
            let after = &l[idx + "Confidence Score:".len()..];
            // Take the first non-space token; expect `N/5` or `N / 5`.
            let token = after.split_whitespace().next().unwrap_or("").trim();
            // Trim trailing punctuation.
            let token = token.trim_end_matches(|c: char| !c.is_ascii_digit());
            if !token.is_empty() {
                return Some(format!("### Confidence Score: {token}/5"));
            }
        }
    }
    None
}

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

    #[test]
    fn render_verdict_body_wraps_free_form_text_under_summary() {
        let body = render_verdict_body(
            "A short review.",
            "abc1234",
            1,
            "Fix #1 dead-code",
            "root",
            500,
        );
        assert!(body.contains("### Summary"));
        assert!(body.contains("A short review."));
        assert!(body.contains("### Confidence Score: 3/5"));
        assert!(body.contains("### Inline Findings"));
        assert!(body.contains("<sub>Last reviewed commit: abc1234 | Reviews (1)</sub>"));
        // The PR context is surfaced in the wrapped body.
        assert!(body.contains("Fix #1 dead-code"));
        assert!(body.contains("root"));
        assert!(body.contains("500 LOC"));
    }

    #[test]
    fn render_verdict_body_keeps_well_formed_body_and_replaces_footer() {
        // The model emitted the four anchors in a single turn; the
        // extracted text already has a `Reviews (2)` footer from a
        // previous round. We must reattach the canonical footer so the
        // counter stays accurate.
        let extracted = "\
### Summary

A short review.

### Confidence Score: 5/5

### Inline Findings

**P2 file.rs, line 1**: hygiene

<sub>Last reviewed commit: abc1234 | Reviews (2)</sub>
";
        let body = render_verdict_body(extracted, "abc1234", 3, "title", "root", 42);
        // The new footer has Reviews (3), not (2).
        assert!(body.contains("| Reviews (3)</sub>"));
        // The old footer is gone.
        assert!(!body.contains("| Reviews (2)</sub>"));
        // The anchors are still there.
        assert!(body.contains("### Summary"));
        assert!(body.contains("### Confidence Score: 5/5"));
        assert!(body.contains("### Inline Findings"));
    }

    #[test]
    fn render_verdict_body_extracts_confidence_from_inline_text() {
        // The model's free-form text mentioned the score without a
        // heading; we should still surface a `### Confidence Score: N/5`
        // line in the rendered body.
        let extracted = "I read the diff. Confidence Score: 4/5. Looks fine.";
        let body = render_verdict_body(extracted, "deadbee", 1, "title", "root", 10);
        assert!(body.contains("### Confidence Score: 4/5"));
    }

    #[test]
    fn render_verdict_body_falls_back_to_default_confidence_when_unreadable() {
        // No confidence marker anywhere.
        let body = render_verdict_body("Just a review.", "abc1234", 1, "title", "root", 10);
        assert!(body.contains("### Confidence Score: 3/5"));
    }
}