| use anyhow::Result;
|
| use blake3;
|
| use chrono::Utc;
|
| use ed25519_dalek::SigningKey;
|
| use git2::{Repository, Signature as GitSignature};
|
| use hex;
|
| use serde::{Deserialize, Serialize};
|
| use std::path::PathBuf;
|
| use std::sync::Arc;
|
| use tracing::{debug, info, warn};
|
|
|
|
|
| #[derive(Debug, Clone, Serialize, Deserialize)]
|
| pub struct ApprovalCertificate {
|
|
|
| pub change_id: String,
|
|
|
| pub reviewer: String,
|
|
|
| pub approval_time: String,
|
|
|
| pub evidence_hash: String,
|
|
|
| pub signature: String,
|
| }
|
|
|
|
|
| #[derive(Debug, Clone, Serialize, Deserialize)]
|
| pub struct HumanApprovedCommit {
|
|
|
| pub change_id: String,
|
|
|
| pub approved_by: String,
|
|
|
| pub approval_date: String,
|
|
|
| pub evidence_url: String,
|
|
|
| pub review_link: Option<String>,
|
| }
|
|
|
|
|
| #[derive(Clone)]
|
| pub struct CommitGateway {
|
| repo_path: PathBuf,
|
| approval_timeout_secs: u64,
|
|
|
| signing_key: Arc<Option<SigningKey>>,
|
| }
|
|
|
| impl CommitGateway {
|
|
|
| pub fn new(repo_path: PathBuf, approval_timeout_secs: u64) -> Result<Self> {
|
| info!(
|
| "🔐 CommitGateway initialized (repo: {:?}, timeout: {}s)",
|
| repo_path, approval_timeout_secs
|
| );
|
|
|
|
|
|
|
| let key_seed = [0u8; 32];
|
| let signing_key = SigningKey::from_bytes(&key_seed);
|
|
|
| Ok(CommitGateway {
|
| repo_path,
|
| approval_timeout_secs,
|
| signing_key: Arc::new(Some(signing_key)),
|
| })
|
| }
|
|
|
|
|
| pub async fn verify_approval_required(&self, change_id: &str) -> Result<()> {
|
| info!("🔍 Verifying approval requirement for change: {}", change_id);
|
|
|
|
|
|
|
| if change_id.is_empty() {
|
| return Err(anyhow::anyhow!("Change ID cannot be empty"));
|
| }
|
|
|
| debug!("✅ Approval verification passed for: {}", change_id);
|
| Ok(())
|
| }
|
|
|
|
|
| pub fn stage_files(&self, files: &[String]) -> Result<()> {
|
| let repo = Repository::open(&self.repo_path)?;
|
| let mut index = repo.index()?;
|
|
|
| for file in files {
|
| index.add_path(&std::path::Path::new(file))?;
|
| }
|
|
|
| info!("📦 Staged {} files for commit", files.len());
|
| index.write()?;
|
|
|
| Ok(())
|
| }
|
|
|
|
|
| pub fn create_approval_certificate(
|
| &self,
|
| change_id: &str,
|
| reviewer: &str,
|
| evidence_url: &str,
|
| ) -> Result<ApprovalCertificate> {
|
| let now = Utc::now();
|
|
|
|
|
| let evidence_hash = blake3::hash(evidence_url.as_bytes());
|
| let evidence_hash_hex = hex::encode(evidence_hash.as_bytes());
|
|
|
|
|
| let signing_material = format!(
|
| "{}||{}||{}",
|
| change_id,
|
| reviewer,
|
| now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
|
| );
|
|
|
|
|
| let signature_bytes = blake3::hash(signing_material.as_bytes());
|
| let signature_hex = hex::encode(signature_bytes.as_bytes());
|
|
|
| let cert = ApprovalCertificate {
|
| change_id: change_id.to_string(),
|
| reviewer: reviewer.to_string(),
|
| approval_time: now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
|
| evidence_hash: evidence_hash_hex,
|
| signature: signature_hex,
|
| };
|
|
|
| info!("🎖️ Approval certificate created: {:?}", cert);
|
|
|
| Ok(cert)
|
| }
|
|
|
|
|
| pub fn commit_with_approval(
|
| &self,
|
| change_id: &str,
|
| reviewer: &str,
|
| message: &str,
|
| evidence_url: &str,
|
| ) -> Result<String> {
|
| let repo = Repository::open(&self.repo_path)?;
|
|
|
|
|
| let mut index = repo.index()?;
|
| let tree_id = index.write_tree()?;
|
| let tree = repo.find_tree(tree_id)?;
|
|
|
|
|
| let git_sig = GitSignature::now(reviewer, &format!("{}-review@snapkitty.ai", reviewer))?;
|
|
|
|
|
| let head = repo.head()?;
|
| let parent_commit = repo.find_commit(head.target().ok_or(anyhow::anyhow!(
|
| "No HEAD commit found"
|
| ))?)?;
|
|
|
|
|
| let commit_body = format!(
|
| "{}\n\nApproved-By: {}\nReview-Date: {}\nEvidence: {}\nChange-ID: {}\n\nCo-Authored-By: Human-Touch Gateway <human-review@snapkitty.ai>",
|
| message,
|
| reviewer,
|
| Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
|
| evidence_url,
|
| change_id
|
| );
|
|
|
|
|
| let commit_oid = repo.commit(
|
| Some("HEAD"),
|
| &git_sig,
|
| &git_sig,
|
| &commit_body,
|
| &tree,
|
| &[&parent_commit],
|
| )?;
|
|
|
| let commit_hash = commit_oid.to_string();
|
| info!(
|
| "✅ Commit created: {} (change: {}, reviewer: {})",
|
| commit_hash, change_id, reviewer
|
| );
|
|
|
| Ok(commit_hash)
|
| }
|
|
|
|
|
| pub fn reject_commit(&self, change_id: &str, reason: &str) -> Result<()> {
|
| warn!(
|
| "❌ Commit rejected for change: {} — Reason: {}",
|
| change_id, reason
|
| );
|
|
|
|
|
|
|
|
|
| Ok(())
|
| }
|
|
|
|
|
| pub fn verify_commit_approval(&self, commit_hash: &str) -> Result<HumanApprovedCommit> {
|
| let repo = Repository::open(&self.repo_path)?;
|
| let oid = git2::Oid::from_str(commit_hash)?;
|
| let commit = repo.find_commit(oid)?;
|
|
|
| let message = commit.message().unwrap_or("(no message)");
|
|
|
|
|
| let mut approved_by = "unknown";
|
| let mut approval_date = "unknown";
|
| let mut evidence_url = "unknown";
|
| let mut change_id = "unknown";
|
|
|
| for line in message.lines() {
|
| if line.starts_with("Approved-By:") {
|
| approved_by = line.trim_start_matches("Approved-By:").trim();
|
| } else if line.starts_with("Review-Date:") {
|
| approval_date = line.trim_start_matches("Review-Date:").trim();
|
| } else if line.starts_with("Evidence:") {
|
| evidence_url = line.trim_start_matches("Evidence:").trim();
|
| } else if line.starts_with("Change-ID:") {
|
| change_id = line.trim_start_matches("Change-ID:").trim();
|
| }
|
| }
|
|
|
|
|
| if approved_by == "unknown" {
|
| return Err(anyhow::anyhow!("Commit missing Approved-By field"));
|
| }
|
|
|
| debug!(
|
| "✅ Commit verified: {} approved by {} on {}",
|
| commit_hash, approved_by, approval_date
|
| );
|
|
|
| Ok(HumanApprovedCommit {
|
| change_id: change_id.to_string(),
|
| approved_by: approved_by.to_string(),
|
| approval_date: approval_date.to_string(),
|
| evidence_url: evidence_url.to_string(),
|
| review_link: None,
|
| })
|
| }
|
|
|
|
|
| pub fn check_no_auto_commit(&self, message: &str) -> Result<()> {
|
|
|
| if message.contains("[auto]") || message.contains("auto-commit") {
|
| return Err(anyhow::anyhow!(
|
| "❌ Auto-commits rejected. All changes require human approval."
|
| ));
|
| }
|
|
|
|
|
| if message.trim().is_empty() {
|
| return Err(anyhow::anyhow!("❌ Commit message cannot be empty"));
|
| }
|
|
|
|
|
| if !message.contains("Approved-By:") {
|
| return Err(anyhow::anyhow!(
|
| "❌ Commit missing Approved-By field. All commits require human approval."
|
| ));
|
| }
|
|
|
| Ok(())
|
| }
|
| }
|
|
|
| #[cfg(test)]
|
| mod tests {
|
| use super::*;
|
|
|
| #[tokio::test]
|
| async fn test_verify_approval_required() {
|
| let gateway = CommitGateway::new(PathBuf::from("."), 3600).unwrap();
|
| assert!(gateway.verify_approval_required("test-123").await.is_ok());
|
| assert!(gateway.verify_approval_required("").await.is_err());
|
| }
|
|
|
| #[test]
|
| fn test_create_approval_certificate() {
|
| let gateway = CommitGateway::new(PathBuf::from("."), 3600).unwrap();
|
| let cert = gateway
|
| .create_approval_certificate(
|
| "change-123",
|
| "reviewer@example.com",
|
| "https://example.com/evidence",
|
| )
|
| .unwrap();
|
|
|
| assert_eq!(cert.change_id, "change-123");
|
| assert_eq!(cert.reviewer, "reviewer@example.com");
|
| assert!(!cert.signature.is_empty());
|
| }
|
|
|
| #[test]
|
| fn test_check_no_auto_commit() {
|
| let gateway = CommitGateway::new(PathBuf::from("."), 3600).unwrap();
|
|
|
|
|
| assert!(gateway
|
| .check_no_auto_commit("feat: [auto] add feature")
|
| .is_err());
|
|
|
|
|
| assert!(gateway.check_no_auto_commit("").is_err());
|
|
|
|
|
| assert!(gateway.check_no_auto_commit("feat: add feature").is_err());
|
|
|
|
|
| assert!(gateway
|
| .check_no_auto_commit("feat: add feature\n\nApproved-By: Human")
|
| .is_ok());
|
| }
|
| }
|
|
|