Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ reviewer:
focus: [security, concurrency]
review:
onClean: skip
uncertaintyResolution: false # resolve uncertainty findings from referenced repository files
gate:
failOn: error # info, warn, error, or never
onError: block # block or advisory for provider outages
Expand Down Expand Up @@ -53,6 +54,7 @@ Place organization-specific merge rules in `.postil/guardrails.md`. Place additi
| `REVIEW_MODEL_CONSENSUS` | Number of models from the generator chain to run concurrently; agreeing findings are retained |
| `REVIEW_SCORER_MODEL` | Scorer model override |
| `REVIEW_SCORER_MODEL_CASCADE` | One scorer fallback model |
| `POSTIL_UNCERTAINTY_RESOLUTION` | Override uncertainty resolution with `true`/`false` or `1`/`0` |
| `POSTIL_LLM_REQUEST_TIMEOUT_SECS` | Per-attempt model request timeout; defaults to 480 seconds |
| `POSTIL_LLM_TOTAL_TIMEOUT_SECS` | Optional total local-review model deadline |
| `POSTIL_DETAILS_URL` | HTTP(S) details link for forge check runs |
Expand Down
107 changes: 103 additions & 4 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -924,6 +924,7 @@ pub struct Config {
pub tone: String,
pub focus: Vec<String>,
pub on_clean: OnClean,
pub uncertainty_resolution: bool,
pub gate_fail_on: GateLevel,
/// Gate behavior on operational error. Default: fail closed.
pub gate_on_error: OnError,
Expand Down Expand Up @@ -968,6 +969,7 @@ impl Default for Config {
tone: "concise, dry, lightly sardonic, never hostile; no praise or filler".to_string(),
focus: Vec::new(),
on_clean: OnClean::Skip,
uncertainty_resolution: false,
gate_fail_on: GateLevel::Severity(Severity::Error),
gate_on_error: OnError::Block,
block_on_kinds: vec![Kind::HumanEscalation],
Expand Down Expand Up @@ -1015,6 +1017,7 @@ pub struct ReviewerSection {
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ReviewSection {
pub on_clean: Option<OnClean>,
pub uncertainty_resolution: Option<bool>,
}

#[derive(Debug, Default, Deserialize, Serialize)]
Expand Down Expand Up @@ -1158,10 +1161,13 @@ impl Config {
self.focus = fo;
}
}
if let Some(r) = f.review
&& let Some(oc) = r.on_clean
{
self.on_clean = oc;
if let Some(r) = f.review {
if let Some(oc) = r.on_clean {
self.on_clean = oc;
}
if let Some(enabled) = r.uncertainty_resolution {
self.uncertainty_resolution = enabled;
}
}
if let Some(g) = f.gate {
if let Some(fo) = g.fail_on {
Expand Down Expand Up @@ -1282,6 +1288,17 @@ impl Config {
}

fn apply_env(&mut self) -> Result<()> {
if let Ok(value) = std::env::var("POSTIL_UNCERTAINTY_RESOLUTION")
&& !value.trim().is_empty()
{
self.uncertainty_resolution = match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" => true,
"0" | "false" => false,
_ => anyhow::bail!(
"POSTIL_UNCERTAINTY_RESOLUTION must be 1, true, 0, or false (got {value:?})"
),
};
}
if hosted_mode() {
if let Some(profile) = admitted_profile_for(model_defaults(), qualification_manifest())
{
Expand Down Expand Up @@ -1898,6 +1915,7 @@ reviewer:

review:
onClean: skip # skip = stay silent on clean PRs (default) | comment
uncertaintyResolution: false # fetch referenced repository files to resolve uncertainty findings

gate:
failOn: error # the postil/gate check fails at/above: info | warn | error | never
Expand Down Expand Up @@ -2001,6 +2019,45 @@ style linter.";
mod tests {
use super::*;
use std::io::Write;
use std::sync::Mutex;

struct EnvRestore {
name: &'static str,
value: Option<std::ffi::OsString>,
}

impl EnvRestore {
fn capture(name: &'static str) -> Self {
Self {
name,
value: std::env::var_os(name),
}
}

fn set(name: &str, value: &str) {
unsafe {
std::env::set_var(name, value);
}
}
}

impl Drop for EnvRestore {
fn drop(&mut self) {
match &self.value {
Some(value) => unsafe {
std::env::set_var(self.name, value);
},
None => unsafe {
std::env::remove_var(self.name);
},
}
}
}

fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}

fn write(dir: &Path, name: &str, content: &str) {
let mut f = std::fs::File::create(dir.join(name)).unwrap();
Expand Down Expand Up @@ -2241,12 +2298,54 @@ scorer = { enabled = true, default_model = "provider/scorer", fallback = "provid
let c = Config::default();
assert_eq!(c.min_confidence, 0.6);
assert_eq!(c.on_clean, OnClean::Skip);
assert!(!c.uncertainty_resolution);
assert!(matches!(
c.gate_fail_on,
GateLevel::Severity(Severity::Error)
));
}

#[test]
fn file_enables_uncertainty_resolution() {
let file: FileConfig =
yaml_serde::from_str("review:\n uncertaintyResolution: true\n").unwrap();
let mut config = Config::default();
config.apply_file_inner(file, false, false).unwrap();
assert!(config.uncertainty_resolution);
}

#[test]
fn uncertainty_resolution_env_accepts_booleans_and_rejects_invalid_values() {
const NAME: &str = "POSTIL_UNCERTAINTY_RESOLUTION";
let _lock = env_lock().lock().unwrap();
let _env = EnvRestore::capture(NAME);

for value in ["1", "true", "TRUE"] {
EnvRestore::set(NAME, value);
let mut config = Config::default();
config.apply_env().unwrap();
assert!(config.uncertainty_resolution, "value {value:?}");
}

for value in ["0", "false", "FALSE"] {
EnvRestore::set(NAME, value);
let mut config = Config {
uncertainty_resolution: true,
..Config::default()
};
config.apply_env().unwrap();
assert!(!config.uncertainty_resolution, "value {value:?}");
}

EnvRestore::set(NAME, "sometimes");
let error = Config::default().apply_env().unwrap_err();
assert!(
error
.to_string()
.contains("POSTIL_UNCERTAINTY_RESOLUTION must be 1, true, 0, or false")
);
}

#[test]
fn benchmark_bounded_selection_requires_the_hermetic_feature_and_loopback_endpoints() {
let valid = || {
Expand Down
44 changes: 44 additions & 0 deletions src/forge/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,18 @@ impl GitHub {
Ok((snapshot, byte_count))
}

pub(crate) async fn fetch_repository_file_at_revision(
&self,
revision: &str,
path: &str,
) -> Result<String> {
let (snapshot, _) = self
.source_file(revision, path, WorkspaceBudget::new())
.await?;
String::from_utf8(snapshot.as_bytes().to_vec())
.context("GitHub repository file is not valid UTF-8")
}

async fn build_complete_diff(
&self,
files: Vec<PullFile>,
Expand Down Expand Up @@ -2069,6 +2081,38 @@ mod tests {
}
}

#[tokio::test]
async fn fetch_repository_file_at_revision_returns_text_and_rejects_binary() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/owner/repo/contents/config/review.toml"))
.and(query_param("ref", "abc123"))
.respond_with(ResponseTemplate::new(200).set_body_string("enabled = true\n"))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/owner/repo/contents/assets/data.bin"))
.and(query_param("ref", "def456"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(vec![0xff, 0xfe]))
.expect(1)
.mount(&server)
.await;
let github = test_github(&server);

let text = github
.fetch_repository_file_at_revision("abc123", "config/review.toml")
.await
.unwrap();
assert_eq!(text, "enabled = true\n");

let error = github
.fetch_repository_file_at_revision("def456", "assets/data.bin")
.await
.unwrap_err();
assert!(error.to_string().contains("not valid UTF-8"));
}

async fn mount_current_delivery_snapshot(server: &MockServer) {
Mock::given(method("GET"))
.and(path("/repos/owner/repo/pulls/1"))
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub mod local;
pub mod output;
pub mod plan;
pub mod prompt;
pub(crate) mod resolve;
pub mod respond;
pub mod review;
pub mod sarif;
Loading
Loading