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
10 changes: 10 additions & 0 deletions docs/codasaurus-toml.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ require_title_convention = false
max_blocking = 0
max_warnings = 20

# Optional natural-language pre-merge checks. Each runs the LLM against the
# PR diff. mode: off | warning | error (error + failed blocks merge).
# scope: optional glob filter over changed paths (tests/** or *.rs).
# Requires an LLM; disabled when offline_mode is on.
# [[pre_merge.checks]]
# name = "No secrets in tests"
# mode = "error"
# scope = "tests/**"
# instructions = "Fail if any test file contains hardcoded credentials or API keys."

[quality_gate]
name = "codasaurus way"
block_on_fail = true
Expand Down
1 change: 1 addition & 0 deletions src/bot/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub(crate) mod maintenance;
pub(crate) mod markdown;
pub(crate) mod offline;
mod policy;
pub(crate) mod premerge;
pub(crate) mod provenance;
mod quality;
pub(crate) mod readiness;
Expand Down
196 changes: 196 additions & 0 deletions src/bot/premerge.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
//! Natural-language pre-merge checks (Phase 5): LLM-judged, fail-closed offline.

use crate::config::PreMergeCheck;
use crate::db::DbPool;

pub struct PreMergeCheckResult {
pub name: String,
pub mode: String,
pub status: String,
pub reasoning: String,
}

/// Run configured checks against the diff. Offline or missing LLM config →
/// every check is `inconclusive` (never blocks). Scope-globbed checks only run
/// when a changed path matches.
pub async fn run_pre_merge_checks(
pool: Option<&DbPool>,
repo: &str,
pr_number: i64,
diff: &str,
changed_paths: &[String],
checks: &[PreMergeCheck],
offline: bool,
) -> Vec<PreMergeCheckResult> {
let mut results = Vec::new();
for check in checks {
if check.mode == "off" {
continue;
}
if let Some(scope) = check.scope.as_deref() {
if !changed_paths.iter().any(|p| glob_match(scope, p)) {
continue;
}
}

let (status, reasoning) = if offline {
(
"inconclusive".to_string(),
"LLM disabled (offline mode); inconclusive checks never block".to_string(),
)
} else if let Some(llm_cfg) = crate::llm::LlmConfig::from_db_or_env(pool).await {
match crate::llm::premerge_check(&check.name, &check.instructions, diff, &llm_cfg).await
{
Ok((s, r)) => (s, r),
Err(e) => ("inconclusive".to_string(), format!("LLM error: {e}")),
}
} else {
(
"inconclusive".to_string(),
"No LLM configured; pre-merge checks need a BYOK model".to_string(),
)
};

persist(pool, repo, pr_number, check, &status, &reasoning).await;
results.push(PreMergeCheckResult {
name: check.name.clone(),
mode: check.mode.clone(),
status,
reasoning,
});
}
results
}

/// Best-effort record of the run (dashboard + audit trail).
async fn persist(
pool: Option<&DbPool>,
repo: &str,
pr_number: i64,
check: &PreMergeCheck,
status: &str,
reasoning: &str,
) {
let Some(pool) = pool else {
return;
};
let _ = sqlx::query(
"INSERT INTO pre_merge_check_runs (repo_full_name, pr_number, check_name, mode, status, reasoning, evaluated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (repo_full_name, pr_number, check_name)
DO UPDATE SET status = EXCLUDED.status, reasoning = EXCLUDED.reasoning, evaluated_at = NOW()",
)
.bind(repo)
.bind(pr_number)
.bind(&check.name)
.bind(&check.mode)
.bind(status)
.bind(reasoning)
.execute(pool.as_pg())
.await;
}

pub fn premerge_markdown(results: &[PreMergeCheckResult]) -> String {
if results.is_empty() {
return String::new();
}
let mut md = String::from("### Pre-merge checks\n\n| Check | Mode | Result |\n|---|---|---|\n");
for r in results {
let mark = match r.status.as_str() {
"passed" => "✅ passed",
"failed" => "❌ failed",
_ => "➖ inconclusive",
};
md.push_str(&format!("| {} | {} | {} |\n", r.name, r.mode, mark));
if !r.reasoning.is_empty() {
md.push_str(&format!(
"| | | {}\n",
r.reasoning.chars().take(120).collect::<String>()
));
}
}
md.push('\n');
md
}

/// Minimal glob: `*` matches within a path segment, `**` matches any depth.
fn glob_match(pattern: &str, path: &str) -> bool {
let pat = pattern.trim_start_matches('/').trim_end_matches('/');
let p = path.trim_start_matches('/');
if pat.contains("**") {
let parts: Vec<&str> = pat.split("**").collect();
let head = parts.first().unwrap_or(&"").trim_end_matches('/');
let tail = parts.last().unwrap_or(&"").trim_start_matches('/');
(head.is_empty() || p.starts_with(head)) && (tail.is_empty() || p.ends_with(tail))
} else if pat.contains('*') {
let re_pat = format!("^{}$", regex::escape(pat).replace("\\*", "[^/]*"));
regex::Regex::new(&re_pat).is_ok_and(|re| re.is_match(p))
} else {
p == pat
}
}

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

#[test]
fn glob_matches_paths() {
assert!(glob_match("tests/**", "tests/auth.rs"));
assert!(glob_match("tests/**", "tests/deep/unit.rs"));
assert!(!glob_match("tests/**", "src/auth.rs"));
assert!(glob_match("*.rs", "main.rs"));
assert!(!glob_match("*.rs", "src/main.rs"));
assert!(glob_match("src/main.rs", "src/main.rs"));
}

#[test]
fn scope_filters_checks() {
let checks = vec![PreMergeCheck {
name: "no secrets".into(),
mode: "error".into(),
scope: Some("tests/**".into()),
instructions: "fail on secrets".into(),
}];
// No matching path → no results at all.
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let results = rt.block_on(run_pre_merge_checks(
None,
"acme/repo",
1,
"",
&["src/app.rs".to_string()],
&checks,
true,
));
assert!(results.is_empty());
}

#[test]
fn offline_is_inconclusive() {
let checks = vec![PreMergeCheck {
name: "no secrets".into(),
mode: "error".into(),
scope: None,
instructions: "fail on secrets".into(),
}];
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let results = rt.block_on(run_pre_merge_checks(
None,
"acme/repo",
1,
"diff",
&["tests/app.rs".to_string()],
&checks,
true,
));
assert_eq!(results.len(), 1);
assert_eq!(results[0].status, "inconclusive");
}
}
69 changes: 63 additions & 6 deletions src/bot/review/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1150,8 +1150,54 @@ pub async fn review_pr_with_options(
)
.await;

let mut premerge_md = String::new();
let mut premerge_blockers: Vec<String> = Vec::new();
if !offline_mode && !config.pre_merge.checks.is_empty() {
let mut diff = String::new();
for f in files.iter().take(20) {
let name = f["filename"].as_str().unwrap_or("?");
let patch = f["patch"].as_str().unwrap_or("");
if patch.is_empty() {
continue;
}
use std::fmt::Write as _;
let _ = write!(diff, "--- a/{name}\n+++ b/{name}\n{patch}\n");
if diff.len() > 20_000 {
break;
}
}
let results = crate::bot::premerge::run_pre_merge_checks(
pool,
repo_name,
pr_number,
&diff,
&changed_paths,
&config.pre_merge.checks,
offline_mode,
)
.await;
for r in &results {
if r.status == "failed" && r.mode == "error" {
premerge_blockers.push(format!("pre-merge check \"{}\" failed", r.name));
}
}
premerge_md = crate::bot::premerge::premerge_markdown(&results);
if !premerge_md.is_empty() {
let _ = post_or_update_comment(
client,
&auth_header,
repo_name,
pr_number,
&premerge_md,
&state,
"premerge",
)
.await;
}
}

let readiness_md = if config.readiness.enabled && !head_sha.is_empty() {
let report = crate::bot::readiness::evaluate(
let mut report = crate::bot::readiness::evaluate(
client,
&headers,
repo_name,
Expand All @@ -1163,6 +1209,12 @@ pub async fn review_pr_with_options(
&config.readiness,
)
.await;
if !premerge_blockers.is_empty() {
for b in &premerge_blockers {
report.blockers.push(b.clone());
}
report.score = 0;
}
let md = report.markdown();
let _ = post_or_update_comment(
client,
Expand All @@ -1178,11 +1230,16 @@ pub async fn review_pr_with_options(
} else {
String::new()
};
let summary_extra = if readiness_md.is_empty() {
dep_delta_md.clone()
} else {
format!("{dep_delta_md}\n\n{readiness_md}")
let summary_extra = match (readiness_md.is_empty(), premerge_md.is_empty()) {
(true, true) => dep_delta_md.clone(),
(true, false) => format!("{dep_delta_md}\n\n{premerge_md}"),
(false, _) => format!("{dep_delta_md}\n\n{readiness_md}\n\n{premerge_md}"),
};
let effective_gate = crate::gates::GateResult {
passed: gate_result.passed && premerge_blockers.is_empty(),
failed_conditions: gate_result.failed_conditions.clone(),
};
let check_run_gate = Some((&effective_gate, config.quality_gate.block_on_fail));

if policy_pack.create_check_run {
if let Err(e) = crate::bot::github_extra::create_findings_check_run(
Expand All @@ -1192,7 +1249,7 @@ pub async fn review_pr_with_options(
head_sha,
&findings.findings,
&summary_extra,
Some((&gate_result, config.quality_gate.block_on_fail)),
check_run_gate,
)
.await
{
Expand Down
20 changes: 20 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,26 @@ pub struct PreMergeConfig {
/// Maximum number of warnings allowed
#[serde(default = "default_ten")]
pub max_warnings: usize,
/// Natural-language pre-merge checks (LLM-judged), from `[[pre_merge_checks]]`.
#[serde(default)]
pub checks: Vec<PreMergeCheck>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreMergeCheck {
/// Check name, shown in the PR comment (<=50 chars).
pub name: String,
/// `off` | `warning` | `error`. `error` + failed blocks merge.
#[serde(default = "default_premerge_mode")]
pub mode: String,
/// Optional glob filter; check only applies to matching changed files.
pub scope: Option<String>,
/// Natural-language instructions the LLM evaluates against the diff.
pub instructions: String,
}

fn default_premerge_mode() -> String {
"warning".into()
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
28 changes: 28 additions & 0 deletions src/db/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,34 @@ pub async fn run_migrations(pool: &PgPool) -> Result<(), sqlx::Error> {
migrate_v17_baseline_and_gates(pool).await?;
migrate_v18_confidence(pool).await?;
migrate_v19_symbol_index(pool).await?;
migrate_v20_pre_merge_checks(pool).await?;
Ok(())
}

/// v20: per-PR natural-language pre-merge check runs (Phase 5).
async fn migrate_v20_pre_merge_checks(pool: &PgPool) -> Result<(), sqlx::Error> {
let current: Option<i64> = sqlx::query_scalar("SELECT MAX(version) FROM schema_version")
.fetch_one(pool)
.await?;
if current.unwrap_or(0) >= 20 {
return Ok(());
}
let _ = sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS pre_merge_check_runs (
repo_full_name TEXT NOT NULL,
pr_number INTEGER NOT NULL,
check_name TEXT NOT NULL,
mode TEXT NOT NULL,
status TEXT NOT NULL,
reasoning TEXT,
evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (repo_full_name, pr_number, check_name)
)
"#,
)
.execute(pool)
.await;
Ok(())
}

Expand Down
Loading