From 9a4eb378bfbe187019606715281852c358c66e58bc5b26388b1e1efc14a98287 Mon Sep 17 00:00:00 2001 From: Tyler J King Date: Sun, 12 Apr 2026 12:06:43 -0400 Subject: [PATCH] feat(chronicle_client): emit GovernanceEnvelope in git events Add emit_git_event() method to ChronicleClient that embeds a GovernanceEnvelope in the CloudEvent data payload. The CloudEvent id is set to the hex-encoded git_ref from the envelope. Migrate GOV_COMMIT_CREATED, GOV_PUSH, and GOV_PR_CREATED in git_commands.rs to use emit_git_event(). Non-git events continue to use the existing emit() method. Signed-off-by: Tyler King --- org-ops-core/src/chronicle_client.rs | 104 +++++++++++++++++++++++++++ org-ops-core/src/git_commands.rs | 61 +++++++++++----- 2 files changed, 148 insertions(+), 17 deletions(-) diff --git a/org-ops-core/src/chronicle_client.rs b/org-ops-core/src/chronicle_client.rs index 1bc4d0a..9322675 100644 --- a/org-ops-core/src/chronicle_client.rs +++ b/org-ops-core/src/chronicle_client.rs @@ -9,6 +9,8 @@ use std::time::Duration; +use governance_types::GovernanceEnvelope; + /// CloudEvents type prefix for Guildhouse Chronicle events. const CE_TYPE_PREFIX: &str = "dev.guildhouse.chronicle."; @@ -92,6 +94,47 @@ impl ChronicleClient { pub fn generate_id() -> String { uuid::Uuid::new_v4().to_string() } + + /// Emit a git-originated governance event with a full envelope. + /// + /// The CloudEvent `id` is set to the hex-encoded `git_ref` from the + /// envelope, so Chronicle's entry_id matches the git SHA. + pub fn emit_git_event( + &self, + kind: &str, + envelope: &GovernanceEnvelope, + extra_data: serde_json::Value, + ) -> bool { + let git_ref_hex = hex::encode(envelope.git_ref); + + // Merge envelope + extra_data into a single data payload + let mut data = extra_data; + if let serde_json::Value::Object(ref mut map) = data { + map.insert("kind".to_string(), serde_json::json!(kind)); + map.insert( + "envelope".to_string(), + serde_json::to_value(envelope).unwrap_or_default(), + ); + } + + let ce = serde_json::json!({ + "specversion": "1.0", + "type": format!("{}{}", CE_TYPE_PREFIX, kind), + "source": &envelope.actor_did, + "id": &git_ref_hex, + "time": now_rfc3339(), + "datacontenttype": "application/json", + "data": data, + }); + + self.http + .post(&self.endpoint) + .header("Content-Type", "application/cloudevents+json; charset=utf-8") + .json(&ce) + .send() + .map(|r| r.status().is_success()) + .unwrap_or(false) + } } /// RFC 3339 timestamp for CloudEvents `time` field. @@ -224,6 +267,67 @@ mod tests { assert_eq!((y, m, d), (1970, 1, 1)); } + #[test] + fn test_emit_git_event_payload_structure() { + use governance_types::GovernanceEnvelope; + + let sha = hex::decode("95d09f2b10159347eece71399a7e2e907ea3df4f").unwrap(); + let mut git_ref = [0u8; 20]; + git_ref.copy_from_slice(&sha); + + let mut envelope = GovernanceEnvelope::for_commit( + git_ref, + Some("refs/heads/main"), + "guildhouse/substrate", + [0; 32], + "did:web:guildhouse.dev:user:tking", + ); + envelope.timestamp_ns = 1_000_000; + + let extra = serde_json::json!({ + "description": "test commit", + "branch": "main", + }); + + // Build the CloudEvent payload as emit_git_event would + let git_ref_hex = hex::encode(envelope.git_ref); + let mut data = extra.clone(); + if let serde_json::Value::Object(ref mut map) = data { + map.insert("kind".to_string(), serde_json::json!("GOV_COMMIT_CREATED")); + map.insert( + "envelope".to_string(), + serde_json::to_value(&envelope).unwrap(), + ); + } + + let ce = serde_json::json!({ + "specversion": "1.0", + "type": format!("{}GOV_COMMIT_CREATED", CE_TYPE_PREFIX), + "source": &envelope.actor_did, + "id": &git_ref_hex, + "time": now_rfc3339(), + "datacontenttype": "application/json", + "data": data, + }); + + // CloudEvents 1.0 required fields + assert_eq!(ce["specversion"], "1.0"); + assert!(ce["type"].as_str().unwrap().starts_with("dev.guildhouse.chronicle.")); + assert!(!ce["source"].as_str().unwrap().is_empty()); + assert_eq!(ce["id"], "95d09f2b10159347eece71399a7e2e907ea3df4f"); + + // Envelope is embedded in data + assert!(ce["data"]["envelope"].is_object()); + assert_eq!( + ce["data"]["envelope"]["git_ref"], + "95d09f2b10159347eece71399a7e2e907ea3df4f" + ); + assert_eq!(ce["data"]["envelope"]["git_ref_type"], "commit"); + + // CloudEvent id matches envelope.git_ref + assert_eq!(ce["id"].as_str().unwrap(), ce["data"]["envelope"]["git_ref"].as_str().unwrap()); + } + #[test] fn test_days_to_ymd_known_date() { // 2026-04-12 = day 20555 since epoch diff --git a/org-ops-core/src/git_commands.rs b/org-ops-core/src/git_commands.rs index f38415e..caf5c30 100644 --- a/org-ops-core/src/git_commands.rs +++ b/org-ops-core/src/git_commands.rs @@ -6,8 +6,19 @@ use crate::chronicle_client::ChronicleClient; use crate::session::SessionContext; use crate::traits::OrgCommands; +use governance_types::GovernanceEnvelope; use std::process::Command; +/// Parse a hex SHA string into a [u8; 20] git ref. Returns zeros on parse failure. +fn parse_git_sha(hex_str: &str) -> [u8; 20] { + let mut sha = [0u8; 20]; + if let Ok(bytes) = hex::decode(hex_str.trim()) { + let len = bytes.len().min(20); + sha[..len].copy_from_slice(&bytes[..len]); + } + sha +} + pub struct GitConfig { pub forgejo_url: String, pub forgejo_token: Option, @@ -225,20 +236,29 @@ impl GovernedGitCommands { let chronicle = self.chronicle(); let (commit_log, _, _) = Self::git(&["log", &format!("{}/{}..HEAD", remote, branch), "--format=%H|%s", "--no-merges"]); + let ref_name = format!("refs/heads/{}", branch); + let (remote_url, _, _) = Self::git(&["remote", "get-url", remote]); + let repo_name = remote_url.trim().trim_end_matches(".git") + .rsplit('/').take(2).collect::>().into_iter().rev() + .collect::>().join("/"); + for line in commit_log.lines().filter(|l| !l.is_empty()) { let parts: Vec<&str> = line.splitn(2, '|').collect(); if parts.len() >= 2 { let commit_sha = parts[0].trim(); - chronicle.emit( - "GOV_COMMIT_CREATED", + let envelope = GovernanceEnvelope::for_commit( + parse_git_sha(commit_sha), + Some(&ref_name), + &repo_name, + [0; 32], // accord_hash populated when accord context is available &actor_did, - commit_sha, + ); + chronicle.emit_git_event( + "GOV_COMMIT_CREATED", + &envelope, serde_json::json!({ - "kind": "GOV_COMMIT_CREATED", "description": format!("sha={} msg={}", commit_sha, parts[1]), - "git_commit": commit_sha, "message": parts[1], - "git_ref": format!("refs/heads/{}", branch), }), ); } @@ -247,15 +267,18 @@ impl GovernedGitCommands { // Chronicle: GOV_PUSH let (sha, _, _) = Self::git(&["rev-parse", "HEAD"]); let head_sha = sha.trim(); - chronicle.emit( - "GOV_PUSH", + let push_envelope = GovernanceEnvelope::for_commit( + parse_git_sha(head_sha), + Some(&ref_name), + &repo_name, + [0; 32], &actor_did, - head_sha, + ); + chronicle.emit_git_event( + "GOV_PUSH", + &push_envelope, serde_json::json!({ - "kind": "GOV_PUSH", "description": format!("{}@{} -> {}/{}", head_sha, branch, remote, branch), - "git_commit": head_sha, - "git_ref": format!("refs/heads/{}", branch), "remote": remote, "branch": branch, }), @@ -310,14 +333,18 @@ impl GovernedGitCommands { println!("PR created: {}", pr_url); let actor_did = format!("did:web:{}/user/operator", ctx.trust_domain); let (sha, _, _) = Self::git(&["rev-parse", "HEAD"]); - self.chronicle().emit( - "GOV_PR_CREATED", + let envelope = GovernanceEnvelope::for_commit( + parse_git_sha(sha.trim()), + Some(&format!("refs/heads/{}", branch)), + &repo, + [0; 32], &actor_did, - sha.trim(), + ); + self.chronicle().emit_git_event( + "GOV_PR_CREATED", + &envelope, serde_json::json!({ - "kind": "GOV_PR_CREATED", "description": format!("PR: {} ({})", title, pr_url), - "git_commit": sha.trim(), "branch": branch, "pr_url": pr_url, "title": title,