tga 10.3.0

Developer productivity analytics — git commit collection, classification, and reporting
//! The commit-context block `llm.context` appends to the LLM user prompt.
//!
//! Why (#111): the LLM tier saw only the commit message, while the second
//! rater, who also saw the changed paths, the PR title and the issue type,
//! scored well above it.
//! What: [`CommitContext`] holds one commit's facts after the path caps;
//! [`CommitContext::render`] lays them out as one delimited block, passing
//! each fact through a caller's transform (none for the plain providers,
//! the pseudonymizer for Jev with `obfuscate`); [`with_context`] joins a
//! message and its plain block. No context, or no stored fact, adds nothing,
//! so the prompt stays byte-identical to the message-only one.
//! Test: `classify::llm_context_tests`, `classify::tiers::jev_context_tests`,
//! `tests` below.

use std::borrow::Cow;
use std::convert::Infallible;

/// First line of the block, after a blank line that ends the message.
pub(crate) const OPEN: &str =
    "\n\n--- commit context (from the repository, not the commit message) ---\n";
/// Last line of the block.
pub(crate) const CLOSE: &str = "--- end commit context ---";

/// Which fact a [`CommitContext::render`] transform is given.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Fact {
    /// One changed path.
    Path,
    /// The linked PR's title.
    PrTitle,
    /// The linked work item's type.
    IssueType,
}

/// The `llm.context` facts for one commit (#111).
///
/// Invariant: `paths.len() <= paths_total`; every kept string is on one
/// line (whitespace runs, newlines included, are collapsed to one space).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct CommitContext {
    paths: Vec<String>,
    paths_total: usize,
    pr_title: Option<String>,
    issue_type: Option<String>,
}

/// `s` on one line: whitespace runs collapsed, ends trimmed.
fn flat(s: &str) -> String {
    s.split_whitespace().collect::<Vec<_>>().join(" ")
}

impl CommitContext {
    /// Keep `paths` in order up to `max_paths` entries and `max_path_bytes`
    /// bytes in total; the list stops before the first path that would pass
    /// either cap. An empty title or issue type counts as absent.
    /// Test: `tests::caps_stop_before_the_first_path_over_either_cap`.
    pub(crate) fn new(
        paths: &[String],
        max_paths: usize,
        max_path_bytes: usize,
        pr_title: Option<&str>,
        issue_type: Option<&str>,
    ) -> Self {
        let mut kept = Vec::new();
        let mut bytes = 0_usize;
        for p in paths {
            let p = flat(p);
            if kept.len() == max_paths || bytes + p.len() > max_path_bytes {
                break;
            }
            bytes += p.len();
            kept.push(p);
        }
        let present = |s: Option<&str>| s.map(flat).filter(|s| !s.is_empty());
        let ctx = Self {
            paths: kept,
            paths_total: paths.len(),
            pr_title: present(pr_title),
            issue_type: present(issue_type),
        };
        debug_assert!(ctx.paths.len() <= ctx.paths_total);
        ctx
    }

    /// Whether the commit has no fact to show.
    pub(crate) fn is_empty(&self) -> bool {
        self.paths_total == 0 && self.pr_title.is_none() && self.issue_type.is_none()
    }

    /// The block, each fact passed through `fact`; empty when
    /// [`Self::is_empty`]. The headers are fixed tga text.
    ///
    /// # Errors
    ///
    /// The first error `fact` returns.
    pub(crate) fn render<E>(
        &self,
        mut fact: impl FnMut(Fact, &str) -> Result<String, E>,
    ) -> Result<String, E> {
        if self.is_empty() {
            return Ok(String::new());
        }
        let mut out = String::from(OPEN);
        if self.paths_total > 0 {
            if self.paths.len() < self.paths_total {
                out.push_str(&format!(
                    "Changed paths ({} of {} shown):\n",
                    self.paths.len(),
                    self.paths_total
                ));
            } else {
                out.push_str("Changed paths:\n");
            }
            for p in &self.paths {
                out.push_str(&format!("- {}\n", fact(Fact::Path, p)?));
            }
        }
        if let Some(t) = &self.pr_title {
            out.push_str(&format!("PR title: {}\n", fact(Fact::PrTitle, t)?));
        }
        if let Some(t) = &self.issue_type {
            out.push_str(&format!("Issue type: {}\n", fact(Fact::IssueType, t)?));
        }
        out.push_str(CLOSE);
        Ok(out)
    }

    /// [`Self::render`] with every fact as stored.
    pub(crate) fn render_plain(&self) -> String {
        match self.render(|_, s| Ok::<_, Infallible>(s.to_string())) {
            Ok(s) => s,
            Err(never) => match never {},
        }
    }
}

/// `message` followed by the plain block of `ctx`; borrowed, so
/// byte-identical, when there is no block.
pub(crate) fn with_context<'a>(message: &'a str, ctx: Option<&CommitContext>) -> Cow<'a, str> {
    match ctx.map(CommitContext::render_plain) {
        Some(block) if !block.is_empty() => Cow::Owned(format!("{message}{block}")),
        _ => Cow::Borrowed(message),
    }
}

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

    fn paths(p: &[&str]) -> Vec<String> {
        p.iter().map(|s| s.to_string()).collect()
    }

    /// Why (#111): the caps bound the prompt size whatever the commit.
    /// What: a 10-byte cap keeps a 10-byte path but not an 11th byte; a
    /// count of 1 keeps one; a cap of 0 keeps none but still reports the
    /// total; a first path over the byte cap ends the list at once.
    /// Test: this test.
    #[test]
    fn caps_stop_before_the_first_path_over_either_cap() {
        let p = paths(&["abcde/f.rs", "g.rs"]);
        let at_cap = CommitContext::new(&p, 30, 10, None, None);
        assert!(at_cap
            .render_plain()
            .contains("(1 of 2 shown):\n- abcde/f.rs\n"));
        let both = CommitContext::new(&p, 30, 14, None, None);
        assert!(both
            .render_plain()
            .contains("Changed paths:\n- abcde/f.rs\n- g.rs\n"));
        let one = CommitContext::new(&p, 1, 2048, None, None);
        assert!(one
            .render_plain()
            .contains("(1 of 2 shown):\n- abcde/f.rs\n"));
        let none = CommitContext::new(&p, 0, 2048, None, None);
        assert!(none.render_plain().contains("(0 of 2 shown):\n---"));
        let first_too_big = CommitContext::new(&p, 30, 9, None, None);
        assert!(first_too_big
            .render_plain()
            .contains("(0 of 2 shown):\n---"));
    }

    /// Why (#111): a fact with a newline could forge a header line or end
    /// the block early; nothing stored, nothing shown.
    /// What: a two-line title is sent on one line; an all-blank title and
    /// no facts at all render as nothing, and [`with_context`] then borrows
    /// the message unchanged.
    /// Test: this test.
    #[test]
    fn facts_are_one_line_and_empty_context_adds_nothing() {
        let ctx = CommitContext::new(&[], 30, 2048, Some("Fix\n--- end commit context ---"), None);
        assert_eq!(
            ctx.render_plain(),
            format!("{OPEN}PR title: Fix --- end commit context ---\n{CLOSE}")
        );
        let blank = CommitContext::new(&[], 30, 2048, Some(" \n "), Some(""));
        assert!(blank.is_empty());
        assert_eq!(blank.render_plain(), "");
        assert!(matches!(
            with_context("m", Some(&blank)),
            Cow::Borrowed("m")
        ));
        assert!(matches!(with_context("m", None), Cow::Borrowed("m")));
    }

    /// Why (#111): Bedrock needs live AWS for the call itself, so its
    /// request builder is where the context is proven to arrive.
    /// What: the Converse request built from [`with_context`]'s text
    /// carries the message and then the block in its user message.
    /// Test: this test.
    #[test]
    fn bedrock_request_carries_the_context_block() {
        let ctx = CommitContext::new(&paths(&["src/a.rs"]), 30, 2048, None, Some("Bug"));
        let text = with_context("fix: a", Some(&ctx));
        let req = crate::classify::tiers::bedrock::converse_request("m", "sys", &text);
        let json = serde_json::to_value(&req).expect("serialize");
        let user = json["messages"]
            .as_array()
            .expect("messages")
            .iter()
            .find(|m| m["role"] == "user")
            .expect("user message");
        let expected = format!(
            "Classify this commit message:\n\nfix: a{OPEN}Changed paths:\n- src/a.rs\n\
             Issue type: Bug\n{CLOSE}"
        );
        assert_eq!(user["content"], serde_json::Value::from(expected), "{json}");
    }
}