| 1 | //! Captured review presentation. Inputs, provider budget and publication remain Core-owned. |
| 2 | use std::time::Duration; |
| 3 | |
| 4 | use serde_json::{Value, json}; |
| 5 | |
| 6 | use super::review::{PrReviewPlan, ReviewOutput}; |
| 7 | use super::review_pr::GhPullRequest; |
| 8 | use super::spec::{ToolContext, ToolError}; |
| 9 | use crate::extension_host::StockOperation; |
| 10 | use crate::features::Feature; |
| 11 | |
| 12 | pub(crate) fn view_snapshot(view: &GhPullRequest) -> Value { |
| 13 | json!({"title":view.title,"body":view.body,"base":view.base,"head":view.head,"url":view.url,"head_sha":view.head_sha,"base_sha":view.base_sha,"changed_files":view.changed_files}) |
| 14 | } |
| 15 | pub(crate) fn sort_json_keys() -> bool { |
| 16 | json!({"z":null,"a":null}) |
| 17 | .as_object() |
| 18 | .expect("object") |
| 19 | .keys() |
| 20 | .next() |
| 21 | .is_some_and(|key| key == "a") |
| 22 | } |
| 23 | async fn project( |
| 24 | operation: StockOperation, |
| 25 | input: Value, |
| 26 | context: &ToolContext, |
| 27 | ) -> Result<String, ToolError> { |
| 28 | if !context.features.enabled(Feature::ReviewHost) { |
| 29 | return Err(ToolError::not_available( |
| 30 | "Review Host backend is not selected", |
| 31 | )); |
| 32 | } |
| 33 | let mut budget = Duration::from_secs(30); |
| 34 | if let Some(deadline) = context.turn_deadline { |
| 35 | budget = budget.min(deadline.saturating_duration_since(tokio::time::Instant::now())); |
| 36 | } |
| 37 | let result = crate::extension_host::manager() |
| 38 | .execute_stock(operation, input, context, budget) |
| 39 | .await?; |
| 40 | if !result.success |
| 41 | || result |
| 42 | .metadata |
| 43 | .as_ref() |
| 44 | .is_some_and(|metadata| !metadata.is_null()) |
| 45 | { |
| 46 | return Err(ToolError::execution_failed( |
| 47 | "Review presenter returned an unowned outcome", |
| 48 | )); |
| 49 | } |
| 50 | Ok(result.content) |
| 51 | } |
| 52 | pub(crate) async fn source_prompt( |
| 53 | snapshot: Value, |
| 54 | context: &ToolContext, |
| 55 | ) -> Result<String, ToolError> { |
| 56 | project(StockOperation::ReviewSourcePrompt, snapshot, context).await |
| 57 | } |
| 58 | pub(crate) async fn cli_prompt(diff: &str, context: &ToolContext) -> Result<String, ToolError> { |
| 59 | source_prompt(json!({"kind":"cli_diff","diff":diff}), context).await |
| 60 | } |
| 61 | pub(crate) async fn pr_prompts( |
| 62 | number: u32, |
| 63 | view: &GhPullRequest, |
| 64 | plan: &PrReviewPlan, |
| 65 | context: &ToolContext, |
| 66 | ) -> Result<Vec<String>, ToolError> { |
| 67 | if !context.features.enabled(Feature::ReviewHost) { |
| 68 | return super::review::build_pr_review_prompts(number, view, plan, &context.workspace) |
| 69 | .await |
| 70 | .map_err(|error| ToolError::execution_failed(error.to_string())); |
| 71 | } |
| 72 | let view = std::sync::Arc::new(view.clone()); |
| 73 | let plan = std::sync::Arc::new(plan.clone()); |
| 74 | let mut prompts = Vec::with_capacity(plan.passes.len()); |
| 75 | for index in 0..plan.passes.len() { |
| 76 | let admission = crate::extension_host::manager().admit_review_capture()?; |
| 77 | let (view, plan, workspace) = ( |
| 78 | std::sync::Arc::clone(&view), |
| 79 | std::sync::Arc::clone(&plan), |
| 80 | context.workspace.clone(), |
| 81 | ); |
| 82 | // Reuse Core's pinned parser and existing permit-retaining worker. |
| 83 | // Capture one pass at a time; no extra catalog of source snapshots. |
| 84 | let snapshot = super::github::host::report_worker(admission, move || { |
| 85 | Ok(super::review::capture_pr_pass_snapshot( |
| 86 | number, |
| 87 | &view, |
| 88 | &plan, |
| 89 | &plan.passes[index], |
| 90 | &workspace, |
| 91 | )) |
| 92 | }) |
| 93 | .await?; |
| 94 | prompts.push(project(StockOperation::ReviewPassPrompt, snapshot, context).await?); |
| 95 | } |
| 96 | Ok(prompts) |
| 97 | } |
| 98 | pub(crate) async fn interactive( |
| 99 | number: u32, |
| 100 | view: &GhPullRequest, |
| 101 | diff: &str, |
| 102 | context: &ToolContext, |
| 103 | ) -> Result<String, ToolError> { |
| 104 | project(StockOperation::ReviewInteractivePr, json!({"number":number,"view":view_snapshot(view),"diff":super::review_pr::model_diff(diff)}), context).await |
| 105 | } |
| 106 | pub(crate) async fn report( |
| 107 | review: Option<&ReviewOutput>, |
| 108 | output: &str, |
| 109 | posted: bool, |
| 110 | context: &ToolContext, |
| 111 | ) -> Result<String, ToolError> { |
| 112 | project( |
| 113 | StockOperation::ReviewReport, |
| 114 | json!({"review":review,"output":output,"posted":posted}), |
| 115 | context, |
| 116 | ) |
| 117 | .await |
| 118 | } |
| 119 | |
| 120 | #[cfg(test)] |
| 121 | mod tests { |
| 122 | use super::*; |
| 123 | use crate::dependencies::ExternalTool; |
| 124 | #[tokio::test] |
| 125 | async fn disabled_review_host_refuses_before_demand_or_provider() { |
| 126 | let temp = tempfile::tempdir().unwrap(); |
| 127 | let context = ToolContext::new(temp.path()); |
| 128 | assert!(matches!( |
| 129 | cli_prompt("diff", &context).await, |
| 130 | Err(ToolError::NotAvailable { .. }) |
| 131 | )); |
| 132 | } |
| 133 | #[test] |
| 134 | fn review_view_snapshot_carries_pinned_public_facts_without_additional_authority() { |
| 135 | let view = GhPullRequest { |
| 136 | title: "untrusted title".into(), |
| 137 | head_sha: "a".repeat(40), |
| 138 | base_sha: "b".repeat(40), |
| 139 | ..Default::default() |
| 140 | }; |
| 141 | let snapshot = view_snapshot(&view); |
| 142 | assert_eq!(snapshot["head_sha"], view.head_sha); |
| 143 | assert_eq!(snapshot["title"], view.title); |
| 144 | assert!(snapshot.get("token").is_none()); |
| 145 | assert!(snapshot.get("workspace").is_none()); |
| 146 | } |
| 147 | #[tokio::test(flavor = "current_thread")] |
| 148 | async fn actual_review_pass_host_reuses_pinned_context_and_partial_plan_exactly() { |
| 149 | let _home = crate::test_support::SealedHome::new(); |
| 150 | let _policy = crate::plugins::activation::TestPolicyGuard::extension_host(false); |
| 151 | let Some(node) = crate::extension_host::tests::node_for_tests("review_pass_context_parity") |
| 152 | else { |
| 153 | return; |
| 154 | }; |
| 155 | let root = tempfile::tempdir().unwrap(); |
| 156 | let manager = std::sync::Arc::new(crate::extension_host::ExtensionHostManager::new( |
| 157 | crate::extension_host::ExtensionHostOptions { |
| 158 | runtime: crate::config::ExtensionHostRuntime::Node, |
| 159 | node_override: Some(node), |
| 160 | root: Some(root.path().join("host")), |
| 161 | ..Default::default() |
| 162 | }, |
| 163 | )); |
| 164 | let _manager = |
| 165 | crate::extension_host::TestManagerGuard::install(std::sync::Arc::clone(&manager)); |
| 166 | let git = |args: &[&str]| { |
| 167 | let output = crate::dependencies::Git::command() |
| 168 | .unwrap() |
| 169 | .args(args) |
| 170 | .current_dir(root.path()) |
| 171 | .output() |
| 172 | .unwrap(); |
| 173 | assert!( |
| 174 | output.status.success(), |
| 175 | "{}", |
| 176 | String::from_utf8_lossy(&output.stderr) |
| 177 | ); |
| 178 | String::from_utf8_lossy(&output.stdout).trim().to_string() |
| 179 | }; |
| 180 | git(&["init", "-q"]); |
| 181 | let hooks = root.path().join("no-hooks"); |
| 182 | std::fs::create_dir(&hooks).unwrap(); |
| 183 | git(&["config", "core.hooksPath", hooks.to_str().unwrap()]); |
| 184 | git(&["config", "user.name", "Codewhale Test"]); |
| 185 | git(&["config", "user.email", "test@example.invalid"]); |
| 186 | let original = format!( |
| 187 | "fn existing() {{\n{}\n}}\n", |
| 188 | (1..=80) |
| 189 | .map(|i| format!(" // line {i}")) |
| 190 | .collect::<Vec<_>>() |
| 191 | .join("\n") |
| 192 | ); |
| 193 | std::fs::write(root.path().join("x.rs"), &original).unwrap(); |
| 194 | git(&["config", "commit.gpgsign", "false"]); |
| 195 | git(&["add", "x.rs"]); |
| 196 | git(&["commit", "-qm", "base"]); |
| 197 | let base = git(&["rev-parse", "HEAD"]); |
| 198 | std::fs::write( |
| 199 | root.path().join("x.rs"), |
| 200 | original.replace("// line 40", "panic!(\"introduced\");"), |
| 201 | ) |
| 202 | .unwrap(); |
| 203 | git(&["add", "x.rs"]); |
| 204 | git(&["commit", "-qm", "head"]); |
| 205 | let head = git(&["rev-parse", "HEAD"]); |
| 206 | let patch = git(&["diff", "&BASE", "&HEAD", "--", "x.rs"].map(|x| { |
| 207 | if x == "&BASE" { |
| 208 | base.as_str() |
| 209 | } else if x == "&HEAD" { |
| 210 | head.as_str() |
| 211 | } else { |
| 212 | x |
| 213 | } |
| 214 | })); |
| 215 | let oversized = format!( |
| 216 | "diff --git a/too-big b/too-big\n--- /dev/null\n+++ b/too-big\n@@ -0,0 +1 @@\n+{}\n", |
| 217 | "x".repeat(30_000) |
| 218 | ); |
| 219 | let diff = format!("{patch}\n{oversized}"); |
| 220 | let view = GhPullRequest { |
| 221 | head_sha: head, |
| 222 | base_sha: base, |
| 223 | title: "untrusted ```suggestion".into(), |
| 224 | body: "漢字 description".into(), |
| 225 | changed_files: 2, |
| 226 | ..Default::default() |
| 227 | }; |
| 228 | let plan = super::super::review::plan_pr_review(&diff, &view, 20_000, 1).unwrap(); |
| 229 | assert_eq!(plan.manifest.skipped_files.len(), 1); |
| 230 | let snapshot = super::super::review::capture_pr_pass_snapshot( |
| 231 | 7, |
| 232 | &view, |
| 233 | &plan, |
| 234 | &plan.passes[0], |
| 235 | root.path(), |
| 236 | ); |
| 237 | assert!( |
| 238 | !snapshot["context"].is_null(), |
| 239 | "the same pinned source collector must actually run" |
| 240 | ); |
| 241 | assert_eq!(snapshot["context"]["unavailable_files"], 0); |
| 242 | assert!(!snapshot["context"]["files"].as_array().unwrap().is_empty()); |
| 243 | let expected = super::super::review::build_pr_pass_prompt( |
| 244 | 7, |
| 245 | &view, |
| 246 | &plan, |
| 247 | &plan.passes[0], |
| 248 | root.path(), |
| 249 | ); |
| 250 | let mut flags = crate::features::Features::with_defaults(); |
| 251 | flags.enable(Feature::ReviewHost); |
| 252 | let context = ToolContext::new(root.path()).with_features(flags); |
| 253 | let prompts = pr_prompts(7, &view, &plan, &context).await.unwrap(); |
| 254 | assert_eq!(prompts, vec![expected]); |
| 255 | let value: Value = serde_json::from_str(&prompts[0]).unwrap(); |
| 256 | assert_eq!( |
| 257 | value["manifest"], |
| 258 | serde_json::to_value(&plan.manifest).unwrap() |
| 259 | ); |
| 260 | assert!( |
| 261 | value["task"] |
| 262 | .as_str() |
| 263 | .unwrap() |
| 264 | .contains("Do not claim full coverage.") |
| 265 | ); |
| 266 | manager.shutdown().await; |
| 267 | } |
| 268 | } |
| 269 |