use std::path::Path;
use std::process::Command;
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::git_env::scrub_git_env;
pub const VERDICTS_FOLDER: &str = "verdicts";
#[derive(Debug, Error)]
pub enum VerdictError {
#[error("verdict note {note} is malformed: {reason}")]
Malformed { note: String, reason: String },
#[error("IO failure at {path}: {source}")]
Io {
path: String,
source: std::io::Error,
},
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum VerdictValue {
Approve,
Revise,
}
impl std::fmt::Display for VerdictValue {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VerdictValue::Approve => formatter.write_str("approve"),
VerdictValue::Revise => formatter.write_str("revise"),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct VerdictRecord {
pub reviewer: String,
pub gate: String,
pub verdict: VerdictValue,
pub aggregate_hash: String,
pub timestamp: Timestamp,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Verdict {
pub record: VerdictRecord,
pub note: String,
}
#[derive(Clone, Debug)]
pub struct ReviewerPosition<'a> {
pub verdict: &'a Verdict,
pub current: bool,
}
#[derive(Clone, Debug)]
pub enum GateEvaluation<'a> {
Satisfied(&'a Verdict),
StaleApproval(&'a Verdict),
ReviseOutstanding(&'a Verdict),
NoVerdict,
}
pub const MERGE_GATE: &str = "merge";
pub const KNOWN_GATES: &[&str] = &[MERGE_GATE];
pub fn gate_refusal_state(evaluation: &GateEvaluation<'_>, current_hash: &str) -> Option<String> {
match evaluation {
GateEvaluation::Satisfied(_) => None,
GateEvaluation::NoVerdict => Some("no verdict recorded for the merge gate".to_string()),
GateEvaluation::StaleApproval(verdict) => Some(format!(
"stale approval by {reviewer} (bound sha256:{bound}, current sha256:{current_hash})",
reviewer = verdict.record.reviewer,
bound = verdict.record.aggregate_hash,
)),
GateEvaluation::ReviseOutstanding(verdict) => Some(format!(
"latest verdict is revise by {reviewer}{comment}",
reviewer = verdict.record.reviewer,
comment = verdict
.record
.comment
.as_deref()
.map(|text| format!(" ({text})"))
.unwrap_or_default(),
)),
}
}
pub fn resolve_reviewer(explicit: Option<&str>) -> Option<String> {
if let Some(value) = explicit {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
return Some(trimmed.to_string());
}
let mut command = Command::new("git");
scrub_git_env(&mut command);
let output = command.args(["config", "user.name"]).output().ok()?;
if !output.status.success() {
return None;
}
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() { None } else { Some(name) }
}
pub fn verdict_note_name(timestamp: &Timestamp) -> String {
static SEQUENCE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let compact = timestamp.strftime("%Y%m%d%H%M%S");
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.subsec_nanos())
.unwrap_or(0);
let sequence = SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!(
"{compact}-{:x}-{:06x}-{sequence:x}",
std::process::id(),
nanos & 0xff_ffff
)
}
pub fn render_verdict_note(
name: &str,
record: &VerdictRecord,
) -> Result<String, serde_json::Error> {
let json = serde_json::to_string_pretty(record)?;
Ok(format!("# {name}\n\n```json\n{json}\n```\n"))
}
pub fn read_verdicts(change_directory: &Path) -> Result<Vec<Verdict>, VerdictError> {
let directory = change_directory.join(VERDICTS_FOLDER);
if !directory.is_dir() {
return Ok(Vec::new());
}
let entries = std::fs::read_dir(&directory).map_err(|source| VerdictError::Io {
path: directory.display().to_string(),
source,
})?;
let mut names: Vec<String> = Vec::new();
for entry in entries {
let entry = entry.map_err(|source| VerdictError::Io {
path: directory.display().to_string(),
source,
})?;
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') || !name.ends_with(".md") {
continue;
}
names.push(name);
}
names.sort();
let mut verdicts = Vec::new();
for name in names {
let path = directory.join(&name);
let content = std::fs::read_to_string(&path).map_err(|source| VerdictError::Io {
path: path.display().to_string(),
source,
})?;
let record = parse_verdict_note(&content).map_err(|reason| VerdictError::Malformed {
note: format!("{VERDICTS_FOLDER}/{name}"),
reason,
})?;
verdicts.push(Verdict { record, note: name });
}
Ok(verdicts)
}
pub fn reviewer_positions<'a>(
verdicts: &'a [Verdict],
gate: &str,
current_hash: &str,
) -> Vec<ReviewerPosition<'a>> {
let mut latest: std::collections::BTreeMap<&'a str, &'a Verdict> =
std::collections::BTreeMap::new();
for verdict in verdicts.iter().filter(|v| v.record.gate == gate) {
latest
.entry(verdict.record.reviewer.as_str())
.and_modify(|incumbent| {
if supersedes(verdict, incumbent) {
*incumbent = verdict;
}
})
.or_insert(verdict);
}
latest
.into_values()
.map(|verdict| ReviewerPosition {
current: verdict.record.aggregate_hash == current_hash,
verdict,
})
.collect()
}
pub fn evaluate_gate<'a>(positions: &[ReviewerPosition<'a>]) -> GateEvaluation<'a> {
if let Some(position) = positions
.iter()
.find(|p| p.current && p.verdict.record.verdict == VerdictValue::Approve)
{
return GateEvaluation::Satisfied(position.verdict);
}
if let Some(position) = positions
.iter()
.filter(|p| p.verdict.record.verdict == VerdictValue::Approve)
.max_by(|a, b| recency(a.verdict).cmp(&recency(b.verdict)))
{
return GateEvaluation::StaleApproval(position.verdict);
}
if let Some(position) = positions
.iter()
.filter(|p| p.verdict.record.verdict == VerdictValue::Revise)
.max_by(|a, b| recency(a.verdict).cmp(&recency(b.verdict)))
{
return GateEvaluation::ReviseOutstanding(position.verdict);
}
GateEvaluation::NoVerdict
}
fn recency(verdict: &Verdict) -> (Timestamp, &str) {
(verdict.record.timestamp, verdict.note.as_str())
}
fn supersedes(candidate: &Verdict, incumbent: &Verdict) -> bool {
recency(candidate) > recency(incumbent)
}
fn parse_verdict_note(content: &str) -> Result<VerdictRecord, String> {
let json = extract_single_fenced_json(content)?;
let record: VerdictRecord =
serde_json::from_str(json).map_err(|error| format!("payload is not a verdict: {error}"))?;
if record.reviewer.trim().is_empty() {
return Err("reviewer is empty".to_string());
}
if record.gate.trim().is_empty() {
return Err("gate is empty".to_string());
}
if record.aggregate_hash.trim().is_empty() {
return Err("aggregate_hash is empty".to_string());
}
Ok(record)
}
fn extract_single_fenced_json(content: &str) -> Result<&str, String> {
let mut block: Option<(usize, usize)> = None;
let mut open_start: Option<usize> = None;
let mut offset = 0;
for line in content.split_inclusive('\n') {
let trimmed = line.trim();
match open_start {
None => {
if let Some(language) = trimmed.strip_prefix("```") {
let language = language.trim();
if language.is_empty() || language == "json" {
if block.is_some() {
return Err("more than one fenced block".to_string());
}
open_start = Some(offset + line.len());
}
}
}
Some(start) => {
if trimmed == "```" {
block = Some((start, offset));
open_start = None;
}
}
}
offset += line.len();
}
if open_start.is_some() {
return Err("unterminated fenced block".to_string());
}
match block {
Some((start, end)) => Ok(&content[start..end]),
None => Err("no fenced JSON block found".to_string()),
}
}