Replace fake Forgejo push webhook construction with structured CloudEvents 1.0 via ChronicleClient. Git commit SHAs are now used as CloudEvent ids for COMMIT_CREATED and PUSH events, enabling direct correlation between Chronicle entries and git history. Event renames: - REPO_CLONED -> GOV_REPO_CLONED - COMMIT_CREATED -> GOV_COMMIT_CREATED - GOVERNED_PUSH -> GOV_PUSH - PR_CREATED -> GOV_PR_CREATED Signed-off-by: Tyler King <tking@guildhouse.dev>
425 lines
15 KiB
Rust
425 lines
15 KiB
Rust
//! Governed git subcommands.
|
|
//!
|
|
//! Wraps git operations with accord validation, corpus score checks,
|
|
//! and Chronicle attribution.
|
|
|
|
use crate::chronicle_client::ChronicleClient;
|
|
use crate::session::SessionContext;
|
|
use crate::traits::OrgCommands;
|
|
use std::process::Command;
|
|
|
|
pub struct GitConfig {
|
|
pub forgejo_url: String,
|
|
pub forgejo_token: Option<String>,
|
|
pub chronicle_webhook: String,
|
|
}
|
|
|
|
impl Default for GitConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
forgejo_url: "https://git.bxnet.io".into(),
|
|
forgejo_token: None,
|
|
chronicle_webhook: "http://localhost:8090/webhook/forgejo".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct GovernedGitCommands {
|
|
config: GitConfig,
|
|
}
|
|
|
|
impl GovernedGitCommands {
|
|
pub fn new(config: GitConfig) -> Self {
|
|
Self { config }
|
|
}
|
|
|
|
fn git(args: &[&str]) -> (String, String, i32) {
|
|
let output = Command::new("git")
|
|
.args(args)
|
|
.output()
|
|
.expect("git not found in PATH");
|
|
(
|
|
String::from_utf8_lossy(&output.stdout).to_string(),
|
|
String::from_utf8_lossy(&output.stderr).to_string(),
|
|
output.status.code().unwrap_or(-1),
|
|
)
|
|
}
|
|
|
|
fn corpus_score(entry_name: &str) -> Option<(u8, String, bool)> {
|
|
let output = Command::new("kubectl")
|
|
.args([
|
|
"get", "corpusentry", entry_name, "-o",
|
|
"jsonpath={.status.riskScore.composite}|{.status.riskScore.capabilityCeiling}|{.status.riskScore.bomTriadComplete}",
|
|
])
|
|
.output()
|
|
.ok()?;
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
let s = String::from_utf8_lossy(&output.stdout);
|
|
let parts: Vec<&str> = s.trim().split('|').collect();
|
|
Some((
|
|
parts.first().and_then(|v| v.parse().ok()).unwrap_or(0),
|
|
parts.get(1).unwrap_or(&"CAP_READ").to_string(),
|
|
parts.get(2).map(|v| *v == "true").unwrap_or(false),
|
|
))
|
|
}
|
|
|
|
fn changed_files() -> Vec<String> {
|
|
let (out, _, _) = Self::git(&["diff", "--name-only", "HEAD"]);
|
|
let mut files: Vec<String> = out.lines().map(|s| s.to_string()).collect();
|
|
// Also include staged but not yet committed
|
|
let (staged, _, _) = Self::git(&["diff", "--cached", "--name-only"]);
|
|
for f in staged.lines() {
|
|
if !files.contains(&f.to_string()) {
|
|
files.push(f.to_string());
|
|
}
|
|
}
|
|
files
|
|
}
|
|
|
|
fn infer_corpus_entry(path: &str) -> Option<String> {
|
|
std::path::Path::new(path)
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.map(|s| s.to_string())
|
|
}
|
|
|
|
fn chronicle(&self) -> ChronicleClient {
|
|
ChronicleClient::from_legacy_webhook(&self.config.chronicle_webhook)
|
|
}
|
|
|
|
fn cmd_status(&self, _ctx: &SessionContext) -> anyhow::Result<()> {
|
|
let (out, _, _) = Self::git(&["status", "--short"]);
|
|
if out.is_empty() {
|
|
println!("Nothing to commit, working tree clean.");
|
|
return Ok(());
|
|
}
|
|
println!("{}", out.trim());
|
|
|
|
// Governance overlay
|
|
let changed = Self::changed_files();
|
|
if changed.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
println!("\n-- Governance Impact --");
|
|
for file in &changed {
|
|
if let Some(entry) = Self::infer_corpus_entry(file) {
|
|
if let Some((score, ceiling, triad)) = Self::corpus_score(&entry) {
|
|
let triad_mark = if triad { "Y" } else { "N" };
|
|
println!(
|
|
" {} -> corpus:{} score={}/100 {} triad={}",
|
|
file, entry, score, ceiling, triad_mark
|
|
);
|
|
} else {
|
|
println!(" {} (no corpus entry)", file);
|
|
}
|
|
} else {
|
|
println!(" {}", file);
|
|
}
|
|
}
|
|
println!("--");
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_clone(&self, repo: &str, ctx: &SessionContext) -> anyhow::Result<()> {
|
|
let url = if repo.contains("://") {
|
|
repo.to_string()
|
|
} else {
|
|
format!("{}/{}.git", self.config.forgejo_url, repo)
|
|
};
|
|
println!("Cloning {}...", url);
|
|
let (out, err, rc) = Self::git(&["clone", &url]);
|
|
if rc != 0 {
|
|
anyhow::bail!("git clone failed: {}", err);
|
|
}
|
|
if !out.is_empty() {
|
|
println!("{}", out.trim());
|
|
}
|
|
let actor_did = format!("did:web:{}/user/operator", ctx.trust_domain);
|
|
self.chronicle().emit(
|
|
"GOV_REPO_CLONED",
|
|
&actor_did,
|
|
&ChronicleClient::generate_id(),
|
|
serde_json::json!({
|
|
"kind": "GOV_REPO_CLONED",
|
|
"description": format!("repo={}", repo),
|
|
"repo": repo,
|
|
}),
|
|
);
|
|
println!("Cloned. Chronicle: GOV_REPO_CLONED");
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_commit(&self, message: &str, _ctx: &SessionContext) -> anyhow::Result<()> {
|
|
// Pre-commit checks
|
|
let changed = Self::changed_files();
|
|
let yaml_files: Vec<&String> = changed
|
|
.iter()
|
|
.filter(|f| f.ends_with(".yml") || f.ends_with(".yaml"))
|
|
.collect();
|
|
|
|
// Basic secret scan
|
|
for f in &yaml_files {
|
|
if let Ok(content) = std::fs::read_to_string(f) {
|
|
let lc = content.to_lowercase();
|
|
for pat in &["password:", "secret:", "api_key:", "private_key:"] {
|
|
if lc.contains(pat) {
|
|
eprintln!(" [secrets] {}: possible secret ({}) — review", f, pat);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let (out, err, rc) = Self::git(&["commit", "-m", message]);
|
|
if rc != 0 {
|
|
anyhow::bail!("git commit failed: {}", err);
|
|
}
|
|
println!("{}", out.trim());
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_push(
|
|
&self,
|
|
remote: &str,
|
|
branch: &str,
|
|
ctx: &SessionContext,
|
|
) -> anyhow::Result<()> {
|
|
println!("-- Pre-push governance checks --");
|
|
|
|
// Corpus score check for changed files
|
|
let changed = Self::changed_files();
|
|
let mut blocked = false;
|
|
|
|
for file in &changed {
|
|
if let Some(entry) = Self::infer_corpus_entry(file) {
|
|
if let Some((score, ceiling, _)) = Self::corpus_score(&entry) {
|
|
print!(" corpus [{}]: {}/100 {}", entry, score, ceiling);
|
|
if branch == "main" && score < 70 {
|
|
println!(" BLOCKED (main requires >= 70)");
|
|
blocked = true;
|
|
} else {
|
|
println!(" OK");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if blocked {
|
|
anyhow::bail!("Push blocked by governance checks.");
|
|
}
|
|
|
|
// Governance summary
|
|
let (log, _, _) = Self::git(&["log", "--oneline", "-3"]);
|
|
println!("\n Commits:");
|
|
for line in log.lines() {
|
|
println!(" {}", line);
|
|
}
|
|
println!(" Target: {}/{}", remote, branch);
|
|
println!(" Actor: did:web:{}/user/operator", ctx.trust_domain);
|
|
println!("--");
|
|
|
|
// Emit GOV_COMMIT_CREATED for each commit in the push range
|
|
let actor_did = format!("did:web:{}/user/operator", ctx.trust_domain);
|
|
let chronicle = self.chronicle();
|
|
let (commit_log, _, _) =
|
|
Self::git(&["log", &format!("{}/{}..HEAD", remote, branch), "--format=%H|%s", "--no-merges"]);
|
|
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",
|
|
&actor_did,
|
|
commit_sha,
|
|
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),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Chronicle: GOV_PUSH
|
|
let (sha, _, _) = Self::git(&["rev-parse", "HEAD"]);
|
|
let head_sha = sha.trim();
|
|
chronicle.emit(
|
|
"GOV_PUSH",
|
|
&actor_did,
|
|
head_sha,
|
|
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,
|
|
}),
|
|
);
|
|
|
|
// Actual push
|
|
let (out, err, rc) = Self::git(&["push", remote, branch]);
|
|
if rc != 0 {
|
|
anyhow::bail!("git push failed: {}", err);
|
|
}
|
|
if !out.is_empty() {
|
|
println!("{}", out.trim());
|
|
}
|
|
println!("Chronicle: GOV_PUSH recorded");
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_pr_create(&self, title: &str, ctx: &SessionContext) -> anyhow::Result<()> {
|
|
let (branch, _, _) = Self::git(&["branch", "--show-current"]);
|
|
let branch = branch.trim();
|
|
|
|
let (remote_url, _, _) = Self::git(&["remote", "get-url", "origin"]);
|
|
let repo = remote_url
|
|
.trim()
|
|
.trim_end_matches(".git")
|
|
.rsplit('/')
|
|
.take(2)
|
|
.collect::<Vec<_>>()
|
|
.into_iter()
|
|
.rev()
|
|
.collect::<Vec<_>>()
|
|
.join("/");
|
|
|
|
let url = format!("{}/api/v1/repos/{}/pulls", self.config.forgejo_url, repo);
|
|
let mut req = reqwest::blocking::Client::new().post(&url).json(&serde_json::json!({
|
|
"title": title,
|
|
"head": branch,
|
|
"base": "main",
|
|
"body": format!(
|
|
"## Governance\n\nActor: did:web:{}/user/operator\n\n*Created via guildhouse-ops git pr create*",
|
|
ctx.trust_domain
|
|
)
|
|
}));
|
|
if let Some(ref tok) = self.config.forgejo_token {
|
|
req = req.bearer_auth(tok);
|
|
}
|
|
|
|
match req.send() {
|
|
Ok(resp) if resp.status().is_success() => {
|
|
let data: serde_json::Value = resp.json().unwrap_or_default();
|
|
let pr_url = data["html_url"].as_str().unwrap_or("?");
|
|
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",
|
|
&actor_did,
|
|
sha.trim(),
|
|
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,
|
|
}),
|
|
);
|
|
}
|
|
Ok(resp) => eprintln!("PR creation failed: {}", resp.status()),
|
|
Err(e) => eprintln!("PR creation error: {}", e),
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl OrgCommands for GovernedGitCommands {
|
|
fn commands(&self) -> Vec<clap::Command> {
|
|
use clap::{Arg, Command};
|
|
vec![Command::new("git")
|
|
.about("Governed git operations with Chronicle attribution")
|
|
.subcommand(
|
|
Command::new("clone")
|
|
.about("Clone a governed repository")
|
|
.arg(Arg::new("repo").required(true)),
|
|
)
|
|
.subcommand(Command::new("status").about("git status with governance overlay"))
|
|
.subcommand(
|
|
Command::new("add")
|
|
.about("Stage files")
|
|
.arg(Arg::new("files").num_args(1..)),
|
|
)
|
|
.subcommand(
|
|
Command::new("commit")
|
|
.about("Commit with pre-commit checks")
|
|
.arg(Arg::new("message").short('m').required(true)),
|
|
)
|
|
.subcommand(
|
|
Command::new("push")
|
|
.about("Governed push: corpus check -> Chronicle -> push")
|
|
.arg(Arg::new("remote").default_value("origin"))
|
|
.arg(Arg::new("branch").default_value("main")),
|
|
)
|
|
.subcommand(
|
|
Command::new("pr").about("PR governance").subcommand(
|
|
Command::new("create")
|
|
.about("Create governed PR")
|
|
.arg(Arg::new("title").short('t').required(true)),
|
|
),
|
|
)]
|
|
}
|
|
|
|
fn handles(&self, name: &str) -> bool {
|
|
name == "git"
|
|
}
|
|
|
|
fn handle(
|
|
&self,
|
|
_name: &str,
|
|
matches: &clap::ArgMatches,
|
|
ctx: &SessionContext,
|
|
) -> anyhow::Result<()> {
|
|
match matches.subcommand() {
|
|
Some(("status", _)) => self.cmd_status(ctx),
|
|
Some(("clone", sub)) => {
|
|
let repo = sub.get_one::<String>("repo").unwrap();
|
|
self.cmd_clone(repo, ctx)
|
|
}
|
|
Some(("add", sub)) => {
|
|
let files: Vec<&str> = sub
|
|
.get_many::<String>("files")
|
|
.unwrap_or_default()
|
|
.map(|s| s.as_str())
|
|
.collect();
|
|
let mut args = vec!["add"];
|
|
args.extend(files);
|
|
let (_, err, rc) = Self::git(&args);
|
|
if rc != 0 {
|
|
anyhow::bail!(err);
|
|
}
|
|
Ok(())
|
|
}
|
|
Some(("commit", sub)) => {
|
|
let msg = sub.get_one::<String>("message").unwrap();
|
|
self.cmd_commit(msg, ctx)
|
|
}
|
|
Some(("push", sub)) => {
|
|
let remote = sub.get_one::<String>("remote").map(|s| s.as_str()).unwrap_or("origin");
|
|
let branch = sub.get_one::<String>("branch").map(|s| s.as_str()).unwrap_or("main");
|
|
self.cmd_push(remote, branch, ctx)
|
|
}
|
|
Some(("pr", sub)) => match sub.subcommand() {
|
|
Some(("create", s)) => {
|
|
let title = s.get_one::<String>("title").unwrap();
|
|
self.cmd_pr_create(title, ctx)
|
|
}
|
|
_ => {
|
|
println!("Usage: git pr create -t 'title'");
|
|
Ok(())
|
|
}
|
|
},
|
|
_ => {
|
|
println!("Usage: guildhouse-ops git <clone|status|add|commit|push|pr>");
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
}
|