Phase 1.A: webhook foundation for quire-ci service extraction
- Lift PushEvent/PushRef into quire-core; rename the ref field to
ref_name to match the webhook contract. quire-server re-exports
from quire-core. Update all construction sites and tests.
- Add quire-ci/migrations/0001_initial.sql: schema with lifecycle
derived from timestamp columns (no state column), outcome enum,
and pending/repo indexes.
- Add quire-ci/src/db.rs: open + migrate plumbing, test-only
open_in_memory.
- Flesh out quire-ci/src/quire.rs: GlobalConfig gains webhook_secret
and server_url; QuireCi owns the db path and runs migrations at
startup.
- Add POST /webhook to quire-ci/src/server.rs: HMAC-SHA256
verification (skipped when no secret configured), PushEvent
deserialization, one runs row per updated ref. Tests cover valid
HMAC, unsigned-with-no-secret, and wrong-HMAC paths.
https://claude.ai/code/session_01YXVu67hVnQSAY8wzmfmRHw
diff --git a/Cargo.lock b/Cargo.lock
index e87f313..2d0d6ae 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -485,6 +485,15 @@ dependencies = [
"generic-array",
]
+[[package]]
+name = "block-buffer"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
+dependencies = [
+ "hybrid-array",
+]
+
[[package]]
name = "block2"
version = "0.6.2"
@@ -641,6 +650,12 @@ dependencies = [
"cc",
]
+[[package]]
+name = "cmov"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
+
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -663,6 +678,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca"
+[[package]]
+name = "const-oid"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+
[[package]]
name = "convert_case"
version = "0.10.0"
@@ -697,6 +718,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -722,6 +752,24 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "crypto-common"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "ctutils"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
+dependencies = [
+ "cmov",
+]
+
[[package]]
name = "debugid"
version = "0.8.0"
@@ -776,8 +824,20 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
- "block-buffer",
- "crypto-common",
+ "block-buffer 0.10.4",
+ "crypto-common 0.1.7",
+]
+
+[[package]]
+name = "digest"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
+dependencies = [
+ "block-buffer 0.12.0",
+ "const-oid",
+ "crypto-common 0.2.2",
+ "ctutils",
]
[[package]]
@@ -1407,6 +1467,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+[[package]]
+name = "hmac"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
+dependencies = [
+ "digest 0.11.3",
+]
+
[[package]]
name = "hostname"
version = "0.4.2"
@@ -1474,6 +1543,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+[[package]]
+name = "hybrid-array"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
+dependencies = [
+ "typenum",
+]
+
[[package]]
name = "hyper"
version = "1.9.0"
@@ -2552,6 +2630,9 @@ dependencies = [
"facet",
"figue",
"fs-err",
+ "hex",
+ "hmac",
+ "http-body-util",
"jiff",
"miette",
"mlua",
@@ -2559,15 +2640,21 @@ dependencies = [
"opentelemetry_sdk",
"quire-core",
"reqwest",
+ "rusqlite",
+ "rusqlite_migration",
"sentry",
"serde",
"serde_json",
+ "sha2",
+ "subtle",
"tempfile",
"thiserror",
"tokio",
+ "tower",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
+ "uuid",
]
[[package]]
@@ -3233,8 +3320,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
- "cpufeatures",
- "digest",
+ "cpufeatures 0.2.17",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "sha2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "digest 0.11.3",
]
[[package]]
diff --git a/quire-ci/Cargo.toml b/quire-ci/Cargo.toml
index 5fedb85..689fd96 100644
--- a/quire-ci/Cargo.toml
+++ b/quire-ci/Cargo.toml
@@ -8,14 +8,19 @@ axum = { workspace = true }
facet = { workspace = true }
figue = "*"
fs-err = { workspace = true }
+hmac = "*"
jiff = { workspace = true }
miette = { workspace = true, features = ["fancy"] }
mlua = { workspace = true }
quire-core = { path = "../quire-core" }
reqwest = { version = "*", features = ["blocking", "json", "rustls"], default-features = false }
+rusqlite = { version = "*", features = ["bundled"] }
+rusqlite_migration = "*"
sentry = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+sha2 = "*"
+subtle = "*"
tempfile = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["full"] }
@@ -24,3 +29,9 @@ opentelemetry_sdk = { workspace = true }
tracing = { workspace = true }
tracing-opentelemetry = { workspace = true }
tracing-subscriber = { workspace = true }
+uuid = { version = "*", features = ["v7"] }
+hex = "0.4.3"
+
+[dev-dependencies]
+http-body-util = "*"
+tower = { version = "*", features = ["util"] }
diff --git a/quire-ci/migrations/0001_initial.sql b/quire-ci/migrations/0001_initial.sql
new file mode 100644
index 0000000..771144c
--- /dev/null
+++ b/quire-ci/migrations/0001_initial.sql
@@ -0,0 +1,33 @@
+CREATE TABLE runs (
+ id TEXT PRIMARY KEY,
+ repo TEXT NOT NULL,
+ ref_name TEXT NOT NULL,
+ sha TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ dispatched_at INTEGER,
+ resolved_at INTEGER,
+ outcome TEXT,
+ traceparent TEXT,
+
+ -- timestamps move forward
+ CHECK (dispatched_at IS NULL OR dispatched_at >= created_at),
+ CHECK (resolved_at IS NULL OR resolved_at >= created_at),
+ CHECK (resolved_at IS NULL OR dispatched_at IS NULL
+ OR resolved_at >= dispatched_at),
+
+ -- resolved_at and outcome travel together
+ CHECK ((resolved_at IS NULL) = (outcome IS NULL)),
+
+ -- outcome enum
+ CHECK (outcome IS NULL OR outcome IN (
+ 'succeeded',
+ 'failed-pipeline', 'failed-orphaned', 'failed-internal',
+ 'superseded'
+ ))
+);
+
+-- Pending work: queue scans only touch unresolved rows.
+CREATE INDEX runs_pending ON runs(created_at) WHERE outcome IS NULL;
+
+-- Listing runs per repo, most recent first.
+CREATE INDEX runs_repo_created_at ON runs(repo, created_at DESC);
diff --git a/quire-ci/src/db.rs b/quire-ci/src/db.rs
new file mode 100644
index 0000000..fabfada
--- /dev/null
+++ b/quire-ci/src/db.rs
@@ -0,0 +1,79 @@
+use std::path::Path;
+
+use rusqlite::Connection;
+use rusqlite_migration::{M, Migrations};
+
+static MIGRATIONS: std::sync::LazyLock<Migrations<'static>> = std::sync::LazyLock::new(|| {
+ Migrations::new(vec![M::up(include_str!("../migrations/0001_initial.sql"))])
+});
+
+#[derive(Debug, thiserror::Error)]
+pub enum MigrationError {
+ #[error(transparent)]
+ Sqlite(#[from] rusqlite::Error),
+ #[error("migration error: {0}")]
+ Migration(#[from] rusqlite_migration::Error),
+}
+
+pub fn open(path: &Path) -> Result<Connection, rusqlite::Error> {
+ let conn = Connection::open(path)?;
+ conn.execute_batch(
+ "PRAGMA journal_mode = WAL;
+ PRAGMA foreign_keys = ON;
+ PRAGMA busy_timeout = 5000;",
+ )?;
+ Ok(conn)
+}
+
+pub fn migrate(conn: &mut Connection) -> Result<(), MigrationError> {
+ MIGRATIONS.to_latest(conn)?;
+ Ok(())
+}
+
+#[cfg(test)]
+pub fn open_in_memory() -> Result<Connection, MigrationError> {
+ let mut conn = Connection::open_in_memory()?;
+ conn.execute_batch("PRAGMA foreign_keys = ON;")?;
+ MIGRATIONS.to_latest(&mut conn)?;
+ Ok(conn)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn migrations_apply_without_panicking() {
+ open_in_memory().expect("migrations should apply cleanly");
+ }
+
+ #[test]
+ fn runs_table_has_expected_columns() {
+ let conn = open_in_memory().expect("open_in_memory");
+ let mut stmt = conn
+ .prepare("PRAGMA table_info(runs)")
+ .expect("prepare pragma");
+ let columns: Vec<String> = stmt
+ .query_map([], |row| row.get(1))
+ .expect("query")
+ .map(|r| r.expect("column name"))
+ .collect();
+
+ for expected in &[
+ "id",
+ "repo",
+ "ref_name",
+ "sha",
+ "created_at",
+ "dispatched_at",
+ "resolved_at",
+ "outcome",
+ "traceparent",
+ ] {
+ assert!(
+ columns.contains(&expected.to_string()),
+ "missing column: {expected}"
+ );
+ }
+ }
+}
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 88c6b06..61cce60 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -1,3 +1,4 @@
+mod db;
mod quire;
mod server;
mod sink;
diff --git a/quire-ci/src/quire.rs b/quire-ci/src/quire.rs
index 83b27f1..80d090c 100644
--- a/quire-ci/src/quire.rs
+++ b/quire-ci/src/quire.rs
@@ -1,9 +1,12 @@
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
use miette::{IntoDiagnostic, Result};
+use rusqlite::Connection;
use quire_core::fennel::Fennel;
+use crate::db;
+
/// Parsed global configuration (`<base-dir>/config.fnl`).
#[derive(serde::Deserialize, Debug, Clone)]
#[serde(rename_all = "kebab-case")]
@@ -13,6 +16,12 @@ pub struct GlobalConfig {
/// TCP port the HTTP server binds to on all interfaces (`0.0.0.0`).
#[serde(default = "default_port")]
pub port: u16,
+ #[serde(default)]
+ pub webhook_secret: Option<quire_core::secret::SecretString>,
+ // Reserved for future use: quire-ci will clone from this URL.
+ #[allow(dead_code)]
+ #[serde(default)]
+ pub server_url: Option<String>,
}
fn default_port() -> u16 {
@@ -28,6 +37,7 @@ pub use quire_core::telemetry::SentryConfig;
#[derive(Clone)]
pub struct QuireCi {
config: GlobalConfig,
+ db_path: PathBuf,
}
impl QuireCi {
@@ -39,12 +49,23 @@ impl QuireCi {
} else {
GlobalConfig::default()
};
- Ok(Self { config })
+ let db_path = base_dir.join("quire-ci.db");
+ let mut conn = db::open(&db_path).into_diagnostic()?;
+ db::migrate(&mut conn).into_diagnostic()?;
+ Ok(Self { config, db_path })
}
pub fn config(&self) -> &GlobalConfig {
&self.config
}
+
+ pub fn db_path(&self) -> &Path {
+ &self.db_path
+ }
+
+ pub fn open_db(&self) -> Result<Connection, rusqlite::Error> {
+ db::open(self.db_path())
+ }
}
impl Default for GlobalConfig {
@@ -52,6 +73,8 @@ impl Default for GlobalConfig {
Self {
sentry: None,
port: default_port(),
+ webhook_secret: None,
+ server_url: None,
}
}
}
@@ -107,4 +130,12 @@ mod tests {
.expect("sentry should be present");
assert_eq!(sentry.dsn.reveal().unwrap(), "https://key@sentry.io/123");
}
+
+ #[test]
+ fn db_is_created_at_expected_path() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let q = QuireCi::new(dir.path().to_path_buf()).expect("should load");
+ assert_eq!(q.db_path(), dir.path().join("quire-ci.db"));
+ assert!(q.db_path().exists(), "db file should exist after new()");
+ }
}
diff --git a/quire-ci/src/server.rs b/quire-ci/src/server.rs
index ac37287..ded931d 100644
--- a/quire-ci/src/server.rs
+++ b/quire-ci/src/server.rs
@@ -1,8 +1,13 @@
use std::net::SocketAddr;
use axum::Router;
-use axum::routing::get;
+use axum::extract::State;
+use axum::http::{HeaderMap, StatusCode};
+use axum::routing::{get, post};
+use hmac::{Hmac, KeyInit, Mac};
+use quire_core::event::PushEvent;
use quire_core::telemetry::{self, FmtMode};
+use sha2::Sha256;
use crate::quire::QuireCi;
@@ -16,6 +21,78 @@ async fn index() -> String {
format!("quire-ci {VERSION}\n")
}
+async fn webhook(
+ State(quire): State<QuireCi>,
+ headers: HeaderMap,
+ body: axum::body::Bytes,
+) -> StatusCode {
+ if let Some(secret) = quire.config().webhook_secret.as_ref() {
+ let secret_bytes = match secret.reveal() {
+ Ok(s) => s.as_bytes().to_vec(),
+ Err(_) => return StatusCode::INTERNAL_SERVER_ERROR,
+ };
+
+ let auth_header = match headers
+ .get("Authorization")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|s| s.strip_prefix("HMAC-SHA256 "))
+ {
+ Some(hex) => hex.to_string(),
+ None => return StatusCode::UNAUTHORIZED,
+ };
+
+ let provided_bytes = match hex::decode(&auth_header) {
+ Ok(b) => b,
+ Err(_) => return StatusCode::UNAUTHORIZED,
+ };
+
+ let mut mac =
+ Hmac::<Sha256>::new_from_slice(&secret_bytes).expect("HMAC accepts any key length");
+ mac.update(&body);
+ if mac.verify_slice(&provided_bytes).is_err() {
+ return StatusCode::UNAUTHORIZED;
+ }
+ }
+
+ let event: PushEvent = match serde_json::from_slice(&body) {
+ Ok(e) => e,
+ Err(_) => return StatusCode::BAD_REQUEST,
+ };
+
+ let traceparent = headers
+ .get("traceparent")
+ .and_then(|v| v.to_str().ok())
+ .map(|s| s.to_string());
+
+ let conn = match quire.open_db() {
+ Ok(c) => c,
+ Err(_) => return StatusCode::INTERNAL_SERVER_ERROR,
+ };
+
+ let now_ms = jiff::Timestamp::now().as_millisecond();
+
+ for push_ref in event.updated_refs() {
+ let id = uuid::Uuid::now_v7().to_string();
+ let result = conn.execute(
+ "INSERT INTO runs (id, repo, ref_name, sha, created_at, traceparent)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
+ rusqlite::params![
+ id,
+ event.repo,
+ push_ref.ref_name,
+ push_ref.new_sha,
+ now_ms,
+ traceparent,
+ ],
+ );
+ if result.is_err() {
+ return StatusCode::INTERNAL_SERVER_ERROR;
+ }
+ }
+
+ StatusCode::NO_CONTENT
+}
+
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum Error {
#[error("io error: {0}")]
@@ -43,7 +120,9 @@ pub async fn run(quire: QuireCi) -> Result<()> {
let app = Router::new()
.route("/health", get(health))
- .route("/", get(index));
+ .route("/", get(index))
+ .route("/webhook", post(webhook))
+ .with_state(quire);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!(%addr, "starting HTTP server");
@@ -54,3 +133,131 @@ pub async fn run(quire: QuireCi) -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use axum::body::Body;
+ use axum::http::{Request, StatusCode};
+ use hmac::{Hmac, KeyInit, Mac};
+ use sha2::Sha256;
+ use tower::ServiceExt;
+
+ use crate::quire::QuireCi;
+
+ fn make_app(quire: QuireCi) -> axum::Router {
+ axum::Router::new()
+ .route("/webhook", axum::routing::post(super::webhook))
+ .with_state(quire)
+ }
+
+ fn quire_without_secret() -> (tempfile::TempDir, QuireCi) {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let quire = QuireCi::new(dir.path().to_path_buf()).expect("QuireCi::new");
+ (dir, quire)
+ }
+
+ fn quire_with_secret(secret: &str) -> (tempfile::TempDir, QuireCi) {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let config_path = dir.path().join("config.fnl");
+ fs_err::write(&config_path, format!(r#"{{:webhook-secret "{secret}"}}"#))
+ .expect("write config");
+ let quire = QuireCi::new(dir.path().to_path_buf()).expect("QuireCi::new");
+ (dir, quire)
+ }
+
+ fn push_event_body() -> Vec<u8> {
+ serde_json::to_vec(&serde_json::json!({
+ "type": "push",
+ "repo": "test/repo.git",
+ "pushed_at": "2026-05-01T00:00:00Z",
+ "refs": [
+ {
+ "ref_name": "refs/heads/main",
+ "old_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "new_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+ }
+ ]
+ }))
+ .expect("serialize")
+ }
+
+ fn hmac_header(secret: &str, body: &[u8]) -> String {
+ let mut mac =
+ Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
+ mac.update(body);
+ let result = mac.finalize();
+ format!("HMAC-SHA256 {}", hex::encode(result.into_bytes()))
+ }
+
+ #[tokio::test]
+ async fn valid_hmac_creates_run_row() {
+ let secret = "test-secret-key";
+ let (_dir, quire) = quire_with_secret(secret);
+ let db_path = quire.db_path().to_path_buf();
+ let app = make_app(quire);
+
+ let body = push_event_body();
+ let auth = hmac_header(secret, &body);
+
+ let req = Request::builder()
+ .method("POST")
+ .uri("/webhook")
+ .header("Authorization", auth)
+ .header("content-type", "application/json")
+ .body(Body::from(body))
+ .unwrap();
+
+ let resp = app.oneshot(req).await.unwrap();
+ assert_eq!(resp.status(), StatusCode::NO_CONTENT);
+
+ let conn = crate::db::open(&db_path).expect("open db");
+ let count: i64 = conn
+ .query_row("SELECT COUNT(*) FROM runs", [], |row| row.get(0))
+ .expect("count");
+ assert_eq!(count, 1);
+ }
+
+ #[tokio::test]
+ async fn no_secret_configured_allows_unsigned_post() {
+ let (_dir, quire) = quire_without_secret();
+ let db_path = quire.db_path().to_path_buf();
+ let app = make_app(quire);
+
+ let body = push_event_body();
+
+ let req = Request::builder()
+ .method("POST")
+ .uri("/webhook")
+ .header("content-type", "application/json")
+ .body(Body::from(body))
+ .unwrap();
+
+ let resp = app.oneshot(req).await.unwrap();
+ assert_eq!(resp.status(), StatusCode::NO_CONTENT);
+
+ let conn = crate::db::open(&db_path).expect("open db");
+ let count: i64 = conn
+ .query_row("SELECT COUNT(*) FROM runs", [], |row| row.get(0))
+ .expect("count");
+ assert_eq!(count, 1);
+ }
+
+ #[tokio::test]
+ async fn wrong_hmac_returns_401() {
+ let (_dir, quire) = quire_with_secret("correct-secret");
+ let app = make_app(quire);
+
+ let body = push_event_body();
+
+ let req = Request::builder()
+ .method("POST")
+ .uri("/webhook")
+ .header("Authorization", "HMAC-SHA256 deadbeefdeadbeef")
+ .header("content-type", "application/json")
+ .body(Body::from(body))
+ .unwrap();
+
+ let resp = app.oneshot(req).await.unwrap();
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+ }
+}
diff --git a/quire-core/src/event.rs b/quire-core/src/event.rs
new file mode 100644
index 0000000..55adfcd
--- /dev/null
+++ b/quire-core/src/event.rs
@@ -0,0 +1,104 @@
+/// A single ref update from a push.
+#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
+pub struct PushRef {
+ pub ref_name: String,
+ pub old_sha: String,
+ pub new_sha: String,
+}
+
+/// A push event sent from hook to server over the event socket, and
+/// from quire-server to quire-ci over the webhook.
+#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
+pub struct PushEvent {
+ // Kept for backward compat with the unix socket protocol.
+ pub r#type: String,
+ pub repo: String,
+ pub pushed_at: jiff::Timestamp,
+ pub refs: Vec<PushRef>,
+}
+
+impl PushEvent {
+ pub fn new(repo: String, refs: Vec<PushRef>) -> Self {
+ Self {
+ r#type: "push".to_string(),
+ repo,
+ pushed_at: jiff::Timestamp::now(),
+ refs,
+ }
+ }
+
+ /// Refs that are not deletions (non-zero new sha).
+ pub fn updated_refs(&self) -> Vec<&PushRef> {
+ self.refs
+ .iter()
+ .filter(|r| r.new_sha != "0000000000000000000000000000000000000000")
+ .collect()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn push_event_new_populates_fields() {
+ let refs = vec![PushRef {
+ old_sha: "a".to_string(),
+ new_sha: "b".to_string(),
+ ref_name: "refs/heads/main".to_string(),
+ }];
+ let event = PushEvent::new("foo.git".to_string(), refs.clone());
+
+ assert_eq!(event.r#type, "push");
+ assert_eq!(event.repo, "foo.git");
+ assert_eq!(event.refs, refs);
+ assert!(event.pushed_at > jiff::Timestamp::UNIX_EPOCH);
+ }
+
+ #[test]
+ fn updated_refs_filters_deletions() {
+ let refs = vec![
+ PushRef {
+ old_sha: "aaa".to_string(),
+ new_sha: "bbb".to_string(),
+ ref_name: "refs/heads/main".to_string(),
+ },
+ PushRef {
+ old_sha: "ccc".to_string(),
+ new_sha: "0000000000000000000000000000000000000000".to_string(),
+ ref_name: "refs/heads/feature".to_string(),
+ },
+ ];
+ let event = PushEvent::new("foo.git".to_string(), refs);
+
+ let updated = event.updated_refs();
+ assert_eq!(updated.len(), 1);
+ assert_eq!(updated[0].ref_name, "refs/heads/main");
+ }
+
+ #[test]
+ fn push_event_round_trips_json() {
+ let refs = vec![
+ PushRef {
+ old_sha: "aaa".to_string(),
+ new_sha: "bbb".to_string(),
+ ref_name: "refs/heads/main".to_string(),
+ },
+ PushRef {
+ old_sha: "ccc".to_string(),
+ new_sha: "ddd".to_string(),
+ ref_name: "refs/heads/feature".to_string(),
+ },
+ ];
+ let event = PushEvent::new("work/foo.git".to_string(), refs);
+
+ let json = serde_json::to_string(&event).expect("serialize");
+ let parsed: PushEvent = serde_json::from_str(&json).expect("deserialize");
+
+ assert_eq!(parsed.r#type, "push");
+ assert_eq!(parsed.repo, "work/foo.git");
+ assert_eq!(parsed.refs.len(), 2);
+ assert_eq!(parsed.refs[0].ref_name, "refs/heads/main");
+ assert_eq!(parsed.refs[1].ref_name, "refs/heads/feature");
+ }
+}
diff --git a/quire-core/src/lib.rs b/quire-core/src/lib.rs
index e967709..df3d6a5 100644
--- a/quire-core/src/lib.rs
+++ b/quire-core/src/lib.rs
@@ -3,6 +3,7 @@
pub mod api;
pub mod ci;
+pub mod event;
pub mod fennel;
pub mod secret;
pub mod telemetry;
diff --git a/quire-server/src/bin/quire/commands/hook.rs b/quire-server/src/bin/quire/commands/hook.rs
index 76a5c27..000d1b6 100644
--- a/quire-server/src/bin/quire/commands/hook.rs
+++ b/quire-server/src/bin/quire/commands/hook.rs
@@ -70,7 +70,7 @@ fn post_receive(quire: &Quire) -> Result<()> {
refs.push(quire::event::PushRef {
old_sha: parts[0].to_string(),
new_sha: parts[1].to_string(),
- r#ref: parts[2].to_string(),
+ ref_name: parts[2].to_string(),
});
}
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index f7097c5..6570021 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -219,7 +219,7 @@ fn run_ref_inner(
let meta = RunMeta {
sha: push_ref.new_sha.clone(),
- r#ref: push_ref.r#ref.clone(),
+ r#ref: push_ref.ref_name.clone(),
pushed_at,
};
@@ -233,7 +233,7 @@ fn run_ref_inner(
tracing::info!(
run_id = %run.id(), // cov-excl-line
sha = %push_ref.new_sha,
- r#ref = %push_ref.r#ref,
+ ref_name = %push_ref.ref_name,
"created CI run"
);
@@ -487,7 +487,7 @@ exit 0
let push_ref = PushRef {
old_sha: "0000000000000000000000000000000000000000".to_string(),
new_sha: sha.clone(),
- r#ref: "refs/heads/main".to_string(),
+ ref_name: "refs/heads/main".to_string(),
};
let (_fake_dir, fake_path) = fake_quire_ci(0);
@@ -542,7 +542,7 @@ exit 0
let push_ref = PushRef {
old_sha: "0000000000000000000000000000000000000000".to_string(),
new_sha: sha.clone(),
- r#ref: "refs/heads/main".to_string(),
+ ref_name: "refs/heads/main".to_string(),
};
let (_fake_dir, fake_path) = fake_quire_ci(1);
@@ -588,7 +588,7 @@ exit 0
let push_ref = PushRef {
old_sha: "0000000000000000000000000000000000000000".to_string(),
new_sha: sha,
- r#ref: "refs/heads/main".to_string(),
+ ref_name: "refs/heads/main".to_string(),
};
let db_path = quire.db_path();
@@ -610,7 +610,7 @@ exit 0
vec![PushRef {
old_sha: "0000000000000000000000000000000000000000".to_string(),
new_sha: sha.to_string(),
- r#ref: "refs/heads/main".to_string(),
+ ref_name: "refs/heads/main".to_string(),
}],
)
}
@@ -658,12 +658,12 @@ exit 0
PushRef {
old_sha: "0000000000000000000000000000000000000000".to_string(),
new_sha: sha.clone(),
- r#ref: "refs/heads/main".to_string(),
+ ref_name: "refs/heads/main".to_string(),
},
PushRef {
old_sha: "0000000000000000000000000000000000000000".to_string(),
new_sha: sha.clone(),
- r#ref: "refs/tags/v1".to_string(),
+ ref_name: "refs/tags/v1".to_string(),
},
],
);
diff --git a/quire-server/src/event.rs b/quire-server/src/event.rs
index 217c8b1..ef63d21 100644
--- a/quire-server/src/event.rs
+++ b/quire-server/src/event.rs
@@ -1,105 +1 @@
-/// A single ref update from a push.
-#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
-pub struct PushRef {
- pub r#ref: String,
- pub old_sha: String,
- pub new_sha: String,
-}
-
-/// A push event sent from hook to serve over the event socket.
-#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
-pub struct PushEvent {
- pub r#type: String,
- pub repo: String,
- pub pushed_at: jiff::Timestamp,
- pub refs: Vec<PushRef>,
-}
-
-impl PushEvent {
- /// Build a push event from the repo name and updated refs.
- ///
- /// `repo` is the repo name relative to the repos dir (e.g. "foo.git").
- pub fn new(repo: String, refs: Vec<PushRef>) -> Self {
- Self {
- r#type: "push".to_string(),
- repo,
- pushed_at: jiff::Timestamp::now(),
- refs,
- }
- }
-
- /// Refs that are not deletions (non-zero new sha).
- pub fn updated_refs(&self) -> Vec<&PushRef> {
- self.refs
- .iter()
- .filter(|r| r.new_sha != "0000000000000000000000000000000000000000")
- .collect()
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn push_event_new_populates_fields() {
- let refs = vec![PushRef {
- old_sha: "a".to_string(),
- new_sha: "b".to_string(),
- r#ref: "refs/heads/main".to_string(),
- }];
- let event = PushEvent::new("foo.git".to_string(), refs.clone());
-
- assert_eq!(event.r#type, "push");
- assert_eq!(event.repo, "foo.git");
- assert_eq!(event.refs, refs);
- assert!(event.pushed_at > jiff::Timestamp::UNIX_EPOCH);
- }
-
- #[test]
- fn updated_refs_filters_deletions() {
- let refs = vec![
- PushRef {
- old_sha: "aaa".to_string(),
- new_sha: "bbb".to_string(),
- r#ref: "refs/heads/main".to_string(),
- },
- PushRef {
- old_sha: "ccc".to_string(),
- new_sha: "0000000000000000000000000000000000000000".to_string(),
- r#ref: "refs/heads/feature".to_string(),
- },
- ];
- let event = PushEvent::new("foo.git".to_string(), refs);
-
- let updated = event.updated_refs();
- assert_eq!(updated.len(), 1);
- assert_eq!(updated[0].r#ref, "refs/heads/main");
- }
-
- #[test]
- fn push_event_round_trips_json() {
- let refs = vec![
- PushRef {
- old_sha: "aaa".to_string(),
- new_sha: "bbb".to_string(),
- r#ref: "refs/heads/main".to_string(),
- },
- PushRef {
- old_sha: "ccc".to_string(),
- new_sha: "ddd".to_string(),
- r#ref: "refs/heads/feature".to_string(),
- },
- ];
- let event = PushEvent::new("work/foo.git".to_string(), refs);
-
- let json = serde_json::to_string(&event).expect("serialize");
- let parsed: PushEvent = serde_json::from_str(&json).expect("deserialize");
-
- assert_eq!(parsed.r#type, "push");
- assert_eq!(parsed.repo, "work/foo.git");
- assert_eq!(parsed.refs.len(), 2);
- assert_eq!(parsed.refs[0].r#ref, "refs/heads/main");
- assert_eq!(parsed.refs[1].r#ref, "refs/heads/feature");
- }
-}
+pub use quire_core::event::{PushEvent, PushRef};
diff --git a/quire-server/tests/cli.rs b/quire-server/tests/cli.rs
index 6862ac6..f6c3ca6 100644
--- a/quire-server/tests/cli.rs
+++ b/quire-server/tests/cli.rs
@@ -34,7 +34,7 @@ fn push_event_round_trips_through_socket() {
refs: vec![quire::event::PushRef {
old_sha: "0000000000000000000000000000000000000000".to_string(),
new_sha: "abc123".to_string(),
- r#ref: "refs/heads/main".to_string(),
+ ref_name: "refs/heads/main".to_string(),
}],
};
@@ -63,7 +63,7 @@ fn push_event_round_trips_through_socket() {
assert_eq!(parsed.r#type, "push");
assert_eq!(parsed.repo, "test.git");
assert_eq!(parsed.refs.len(), 1);
- assert_eq!(parsed.refs[0].r#ref, "refs/heads/main");
+ assert_eq!(parsed.refs[0].ref_name, "refs/heads/main");
assert_eq!(parsed.refs[0].new_sha, "abc123");
}
@@ -82,12 +82,12 @@ fn push_event_multiple_refs_round_trip() {
quire::event::PushRef {
old_sha: "aaa".to_string(),
new_sha: "bbb".to_string(),
- r#ref: "refs/heads/main".to_string(),
+ ref_name: "refs/heads/main".to_string(),
},
quire::event::PushRef {
old_sha: "ccc".to_string(),
new_sha: "0000000000000000000000000000000000000000".to_string(),
- r#ref: "refs/heads/feature".to_string(),
+ ref_name: "refs/heads/feature".to_string(),
},
],
};
@@ -111,8 +111,8 @@ fn push_event_multiple_refs_round_trip() {
let parsed: quire::event::PushEvent = serde_json::from_str(&buf).expect("deserialize");
assert_eq!(parsed.refs.len(), 2);
- assert_eq!(parsed.refs[0].r#ref, "refs/heads/main");
- assert_eq!(parsed.refs[1].r#ref, "refs/heads/feature");
+ assert_eq!(parsed.refs[0].ref_name, "refs/heads/main");
+ assert_eq!(parsed.refs[1].ref_name, "refs/heads/feature");
// Deletion ref included in event (server decides how to handle).
assert_eq!(
parsed.refs[1].new_sha,
diff --git a/quire-server/tests/property.rs b/quire-server/tests/property.rs
index 837f7d0..b4a9d2e 100644
--- a/quire-server/tests/property.rs
+++ b/quire-server/tests/property.rs
@@ -13,7 +13,7 @@ const MIN_REDACT_LEN: usize = 8;
#[hegel::composite]
fn push_ref(tc: TestCase) -> PushRef {
PushRef {
- r#ref: tc.draw(text()),
+ ref_name: tc.draw(text()),
old_sha: tc.draw(text()),
// Mix in zero-shas so updated_refs() actually exercises its filter.
new_sha: tc.draw(one_of![text(), just(ZERO_SHA.to_string())]),
@@ -113,7 +113,7 @@ fn push_event_updated_refs_is_subtractive(tc: TestCase) {
event
.refs
.iter()
- .any(|r| r.r#ref == kept_ref.r#ref && r.new_sha == kept_ref.new_sha)
+ .any(|r| r.ref_name == kept_ref.ref_name && r.new_sha == kept_ref.new_sha)
);
}
}