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 <tking@guildhouse.dev>
This commit is contained in:
Tyler J King 2026-04-12 12:06:43 -04:00
parent 4ce225654d
commit 9a4eb378bf
2 changed files with 148 additions and 17 deletions

View file

@ -9,6 +9,8 @@
use std::time::Duration; use std::time::Duration;
use governance_types::GovernanceEnvelope;
/// CloudEvents type prefix for Guildhouse Chronicle events. /// CloudEvents type prefix for Guildhouse Chronicle events.
const CE_TYPE_PREFIX: &str = "dev.guildhouse.chronicle."; const CE_TYPE_PREFIX: &str = "dev.guildhouse.chronicle.";
@ -92,6 +94,47 @@ impl ChronicleClient {
pub fn generate_id() -> String { pub fn generate_id() -> String {
uuid::Uuid::new_v4().to_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. /// RFC 3339 timestamp for CloudEvents `time` field.
@ -224,6 +267,67 @@ mod tests {
assert_eq!((y, m, d), (1970, 1, 1)); 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] #[test]
fn test_days_to_ymd_known_date() { fn test_days_to_ymd_known_date() {
// 2026-04-12 = day 20555 since epoch // 2026-04-12 = day 20555 since epoch

View file

@ -6,8 +6,19 @@
use crate::chronicle_client::ChronicleClient; use crate::chronicle_client::ChronicleClient;
use crate::session::SessionContext; use crate::session::SessionContext;
use crate::traits::OrgCommands; use crate::traits::OrgCommands;
use governance_types::GovernanceEnvelope;
use std::process::Command; 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 struct GitConfig {
pub forgejo_url: String, pub forgejo_url: String,
pub forgejo_token: Option<String>, pub forgejo_token: Option<String>,
@ -225,20 +236,29 @@ impl GovernedGitCommands {
let chronicle = self.chronicle(); let chronicle = self.chronicle();
let (commit_log, _, _) = let (commit_log, _, _) =
Self::git(&["log", &format!("{}/{}..HEAD", remote, branch), "--format=%H|%s", "--no-merges"]); 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::<Vec<_>>().into_iter().rev()
.collect::<Vec<_>>().join("/");
for line in commit_log.lines().filter(|l| !l.is_empty()) { for line in commit_log.lines().filter(|l| !l.is_empty()) {
let parts: Vec<&str> = line.splitn(2, '|').collect(); let parts: Vec<&str> = line.splitn(2, '|').collect();
if parts.len() >= 2 { if parts.len() >= 2 {
let commit_sha = parts[0].trim(); let commit_sha = parts[0].trim();
chronicle.emit( let envelope = GovernanceEnvelope::for_commit(
"GOV_COMMIT_CREATED", parse_git_sha(commit_sha),
Some(&ref_name),
&repo_name,
[0; 32], // accord_hash populated when accord context is available
&actor_did, &actor_did,
commit_sha, );
chronicle.emit_git_event(
"GOV_COMMIT_CREATED",
&envelope,
serde_json::json!({ serde_json::json!({
"kind": "GOV_COMMIT_CREATED",
"description": format!("sha={} msg={}", commit_sha, parts[1]), "description": format!("sha={} msg={}", commit_sha, parts[1]),
"git_commit": commit_sha,
"message": parts[1], "message": parts[1],
"git_ref": format!("refs/heads/{}", branch),
}), }),
); );
} }
@ -247,15 +267,18 @@ impl GovernedGitCommands {
// Chronicle: GOV_PUSH // Chronicle: GOV_PUSH
let (sha, _, _) = Self::git(&["rev-parse", "HEAD"]); let (sha, _, _) = Self::git(&["rev-parse", "HEAD"]);
let head_sha = sha.trim(); let head_sha = sha.trim();
chronicle.emit( let push_envelope = GovernanceEnvelope::for_commit(
"GOV_PUSH", parse_git_sha(head_sha),
Some(&ref_name),
&repo_name,
[0; 32],
&actor_did, &actor_did,
head_sha, );
chronicle.emit_git_event(
"GOV_PUSH",
&push_envelope,
serde_json::json!({ serde_json::json!({
"kind": "GOV_PUSH",
"description": format!("{}@{} -> {}/{}", head_sha, branch, remote, branch), "description": format!("{}@{} -> {}/{}", head_sha, branch, remote, branch),
"git_commit": head_sha,
"git_ref": format!("refs/heads/{}", branch),
"remote": remote, "remote": remote,
"branch": branch, "branch": branch,
}), }),
@ -310,14 +333,18 @@ impl GovernedGitCommands {
println!("PR created: {}", pr_url); println!("PR created: {}", pr_url);
let actor_did = format!("did:web:{}/user/operator", ctx.trust_domain); let actor_did = format!("did:web:{}/user/operator", ctx.trust_domain);
let (sha, _, _) = Self::git(&["rev-parse", "HEAD"]); let (sha, _, _) = Self::git(&["rev-parse", "HEAD"]);
self.chronicle().emit( let envelope = GovernanceEnvelope::for_commit(
"GOV_PR_CREATED", parse_git_sha(sha.trim()),
Some(&format!("refs/heads/{}", branch)),
&repo,
[0; 32],
&actor_did, &actor_did,
sha.trim(), );
self.chronicle().emit_git_event(
"GOV_PR_CREATED",
&envelope,
serde_json::json!({ serde_json::json!({
"kind": "GOV_PR_CREATED",
"description": format!("PR: {} ({})", title, pr_url), "description": format!("PR: {} ({})", title, pr_url),
"git_commit": sha.trim(),
"branch": branch, "branch": branch,
"pr_url": pr_url, "pr_url": pr_url,
"title": title, "title": title,