use std::collections::HashMap;
use terraphim_spawner::SpawnContext;
use crate::config::{AgentDefinition, OrchestratorConfig};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewPrRequest {
pub pr_number: u64,
pub project: String,
pub head_sha: String,
pub author_login: String,
pub title: String,
pub diff_loc: u32,
}
pub fn find_pr_reviewer<'a>(
config: &'a OrchestratorConfig,
project: &str,
) -> Option<&'a AgentDefinition> {
config
.agents
.iter()
.find(|a| a.name == "pr-reviewer" && a.project.as_deref() == Some(project))
}
pub fn build_review_task(req: &ReviewPrRequest) -> String {
format!(
"Structural review of PR #{}: {} (project={}, size={} LOC, author={}, head={})\n\
\n\
On your final assistant turn, print a single markdown block with these\n\
exact sections, in this order:\n\
\n\
### Summary\n\
\n\
### Confidence Score: N/5\n\
\n\
### Inline Findings\n\
\n\
(one line per finding, prefixed `**P0 `, `**P1 `, or `**P2 `; or omit the section if there are no findings)\n\
\n\
<sub>Last reviewed commit: <first-7-hex-of-ADF_PR_HEAD_SHA></sub>\n\
\n\
The `N` in `Confidence Score: N/5` is 1..=5. The `<first-7-hex-of-ADF_PR_HEAD_SHA>` is the first 7 hex characters of the `ADF_PR_HEAD_SHA` environment variable (full 40-char SHA is `{}`).\n\
\n\
You will not be asked to post this; the orchestrator reads it from the captured output.",
req.pr_number,
req.title,
req.project,
req.diff_loc,
req.author_login,
req.head_sha,
req.head_sha,
)
}
pub fn pr_env_overrides(req: &ReviewPrRequest) -> HashMap<String, String> {
let mut env = HashMap::new();
env.insert("ADF_PR_NUMBER".to_string(), req.pr_number.to_string());
env.insert("ADF_PR_HEAD_SHA".to_string(), req.head_sha.clone());
env.insert("ADF_PR_PROJECT".to_string(), req.project.clone());
env.insert("ADF_PR_AUTHOR".to_string(), req.author_login.clone());
env.insert("ADF_PR_DIFF_LOC".to_string(), req.diff_loc.to_string());
env.insert("ADF_PR_TITLE".to_string(), req.title.clone());
env
}
pub fn layer_pr_env(mut base: SpawnContext, req: &ReviewPrRequest) -> SpawnContext {
for (k, v) in pr_env_overrides(req) {
base = base.with_env(k, v);
}
base
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_request() -> ReviewPrRequest {
ReviewPrRequest {
pr_number: 641,
project: "terraphim".to_string(),
head_sha: "deadbeef1234".to_string(),
author_login: "claude-code".to_string(),
title: "fix(kg): short synonyms".to_string(),
diff_loc: 42,
}
}
fn multi_project_config() -> OrchestratorConfig {
let toml_str = r#"
working_dir = "/tmp/pr-dispatch-tests"
[nightwatch]
[compound_review]
schedule = "0 2 * * *"
repo_path = "/tmp"
[[projects]]
id = "alpha"
working_dir = "/tmp/alpha"
[[projects]]
id = "beta"
working_dir = "/tmp/beta"
[[agents]]
name = "pr-reviewer"
layer = "Safety"
cli_tool = "echo"
task = "review"
project = "alpha"
[[agents]]
name = "pr-reviewer"
layer = "Safety"
cli_tool = "echo"
task = "review"
project = "beta"
[[agents]]
name = "other"
layer = "Safety"
cli_tool = "echo"
task = "other"
project = "alpha"
"#;
OrchestratorConfig::from_toml(toml_str).unwrap()
}
#[test]
fn build_review_task_embeds_routing_keywords_and_metadata() {
let t = build_review_task(&sample_request());
assert!(
t.contains("review"),
"task must contain routing keyword 'review'"
);
assert!(t.contains("PR #641"));
assert!(t.contains("project=terraphim"));
assert!(t.contains("42 LOC"));
assert!(t.contains("deadbeef1234"));
}
#[test]
fn build_review_task_carries_template_anchors() {
let t = build_review_task(&sample_request());
assert!(t.contains("### Summary"), "missing Summary anchor");
assert!(
t.contains("### Confidence Score: N/5"),
"missing Confidence Score anchor"
);
assert!(
t.contains("### Inline Findings"),
"missing Inline Findings anchor"
);
assert!(
t.contains("Last reviewed commit: <first-7-hex-of-ADF_PR_HEAD_SHA>"),
"missing Last reviewed commit anchor that references ADF_PR_HEAD_SHA"
);
assert!(
t.contains("the orchestrator reads it from the captured output"),
"missing self-posting disclaimer"
);
assert!(t.contains("Structural review of PR #641"));
assert!(t.contains("head=deadbeef1234"));
}
#[test]
fn pr_env_overrides_populates_all_adf_pr_keys() {
let e = pr_env_overrides(&sample_request());
assert_eq!(e.get("ADF_PR_NUMBER"), Some(&"641".to_string()));
assert_eq!(e.get("ADF_PR_HEAD_SHA"), Some(&"deadbeef1234".to_string()));
assert_eq!(e.get("ADF_PR_PROJECT"), Some(&"terraphim".to_string()));
assert_eq!(e.get("ADF_PR_AUTHOR"), Some(&"claude-code".to_string()));
assert_eq!(e.get("ADF_PR_DIFF_LOC"), Some(&"42".to_string()));
assert_eq!(
e.get("ADF_PR_TITLE"),
Some(&"fix(kg): short synonyms".to_string())
);
}
#[test]
fn layer_pr_env_preserves_base_env_and_adds_pr_keys() {
let base = SpawnContext::default()
.with_env("ADF_PROJECT_ID", "terraphim")
.with_env("GITEA_OWNER", "terraphim");
let ctx = layer_pr_env(base, &sample_request());
assert_eq!(
ctx.env_overrides.get("ADF_PROJECT_ID"),
Some(&"terraphim".to_string())
);
assert_eq!(
ctx.env_overrides.get("GITEA_OWNER"),
Some(&"terraphim".to_string())
);
assert_eq!(
ctx.env_overrides.get("ADF_PR_NUMBER"),
Some(&"641".to_string())
);
}
#[test]
fn find_pr_reviewer_matches_on_name_and_project() {
let config = multi_project_config();
assert_eq!(
find_pr_reviewer(&config, "alpha")
.unwrap()
.project
.as_deref(),
Some("alpha")
);
assert_eq!(
find_pr_reviewer(&config, "beta")
.unwrap()
.project
.as_deref(),
Some("beta")
);
assert!(find_pr_reviewer(&config, "gamma").is_none());
}
}