Use miette error aggregation in mirror::trigger
- Restore RepoConfig/RepoGithubConfig as named public structs
- Repo not found is now an Err (not a silent warn+Ok)
- Per-ref loop collects errors into a MirrorErrors aggregate (#[related])
  instead of tracing::warn/error per ref; the call site logs a single %e

https://claude.ai/code/session_01MtUMXi7Z3GCDWQFY8puWpu
change
commit 5c70b3d5775759f0a086b5f13c2358b715dd6b05
author Claude <noreply@anthropic.com>
date
parent 6b6cf4c2
diff --git a/quire-server/src/bin/quire/server.rs b/quire-server/src/bin/quire/server.rs
index 6db216e..b39b434 100644
--- a/quire-server/src/bin/quire/server.rs
+++ b/quire-server/src/bin/quire/server.rs
@@ -158,10 +158,6 @@ async fn handle_event_connection(mut stream: tokio::net::UnixStream, quire: Quir
 
     ci::trigger(&quire, &event);
     if let Err(e) = mirror::trigger(&quire, &event) {
-        tracing::error!(
-            repo = %event.repo,
-            error = &e as &(dyn std::error::Error + 'static),
-            "mirror failed",
-        );
+        tracing::error!(repo = %event.repo, error = %e, "mirror failed");
     }
 }
diff --git a/quire-server/src/mirror.rs b/quire-server/src/mirror.rs
index 35302d7..983bb3f 100644
--- a/quire-server/src/mirror.rs
+++ b/quire-server/src/mirror.rs
@@ -2,68 +2,70 @@
 //!
 //! Triggered from the push event handler, independent of CI.
 
-use quire_core::event::PushEvent;
+use miette::{Diagnostic, IntoDiagnostic as _};
+use quire_core::event::{PushEvent, PushRef};
+use thiserror::Error;
 
 use crate::quire::Quire;
 
+#[derive(Debug, Error, Diagnostic)]
+#[error("mirror: {count} ref(s) failed")]
+struct MirrorErrors {
+    count: usize,
+    #[related]
+    errors: Vec<miette::Report>,
+}
+
 /// Mirror updated refs to a configured remote.
 ///
 /// Reads `github.mirror-token` from global config for auth. For each updated
 /// ref, reads `.quire/config.fnl` at the new SHA to obtain the `github.mirror`
 /// URL. Skips repos with no mirror URL configured.
-pub fn trigger(quire: &Quire, event: &PushEvent) -> crate::Result<()> {
+pub fn trigger(quire: &Quire, event: &PushEvent) -> miette::Result<()> {
     let repo = match quire.repo(&event.repo) {
         Ok(r) if r.exists() => r,
         Ok(_) => {
-            tracing::warn!(repo = %event.repo, "mirror: repo not found on disk");
-            return Ok(());
-        }
-        Err(e) => {
-            return Err(crate::Error::Io(std::io::Error::other(e.to_string())));
+            return Err(miette::miette!("repo not found on disk: {}", event.repo));
         }
+        Err(e) => return Err(e),
     };
 
-    let config = quire.global_config()?;
+    let config = quire.global_config().into_diagnostic()?;
     let mirror_token = config
         .github
         .mirror_token
         .map(|s| s.reveal().map(str::to_owned))
-        .transpose()?;
+        .transpose()
+        .into_diagnostic()?;
 
-    for push_ref in event.updated_refs() {
-        let mirror_url = match repo.mirror_url(&push_ref.new_sha) {
-            Ok(Some(url)) => url,
-            Ok(None) => continue,
-            Err(e) => {
-                tracing::warn!(
-                    ref_name = %push_ref.ref_name,
-                    sha = %push_ref.new_sha,
-                    error = &e as &(dyn std::error::Error + 'static),
-                    "mirror: failed to read repo config, skipping ref",
-                );
-                continue;
-            }
-        };
+    let mut errors: Vec<miette::Report> = vec![];
 
-        let Some(token) = mirror_token.as_deref() else {
-            tracing::warn!(
-                ref_name = %push_ref.ref_name,
-                "mirror: mirror-token not configured, skipping ref",
-            );
-            continue;
-        };
-
-        if let Err(e) = push_to_mirror(&repo, &push_ref.ref_name, &mirror_url, token) {
-            tracing::error!(
-                ref_name = %push_ref.ref_name,
-                mirror_url,
-                error = &e as &(dyn std::error::Error + 'static),
-                "mirror: push failed",
-            );
+    for push_ref in event.updated_refs() {
+        if let Err(e) = mirror_ref(&repo, push_ref, mirror_token.as_deref()) {
+            errors.push(miette::miette!("{}: {e}", push_ref.ref_name));
         }
     }
 
-    Ok(())
+    if errors.is_empty() {
+        Ok(())
+    } else {
+        let count = errors.len();
+        Err(MirrorErrors { count, errors }.into())
+    }
+}
+
+fn mirror_ref(
+    repo: &crate::quire::Repo,
+    push_ref: &PushRef,
+    token: Option<&str>,
+) -> crate::Result<()> {
+    let repo_config = repo.repo_config(&push_ref.new_sha)?;
+    let Some(mirror_url) = repo_config.github.mirror else {
+        return Ok(());
+    };
+    let token = token
+        .ok_or_else(|| crate::Error::Io(std::io::Error::other("mirror-token not configured")))?;
+    push_to_mirror(repo, &push_ref.ref_name, &mirror_url, token)
 }
 
 fn push_to_mirror(
diff --git a/quire-server/src/quire/mod.rs b/quire-server/src/quire/mod.rs
index 8aaba98..0907f56 100644
--- a/quire-server/src/quire/mod.rs
+++ b/quire-server/src/quire/mod.rs
@@ -156,21 +156,10 @@ impl Repo {
         Ci::new(self.path())
     }
 
-    /// Read `github.mirror` URL from `.quire/config.fnl` at the given commit SHA.
+    /// Read and parse `.quire/config.fnl` at the given commit SHA.
     ///
-    /// Returns `None` if unconfigured or the file does not exist at that commit.
-    pub fn mirror_url(&self, sha: &str) -> AppResult<Option<String>> {
-        #[derive(serde::Deserialize, Default)]
-        #[serde(default, rename_all = "kebab-case")]
-        struct Config {
-            github: Github,
-        }
-        #[derive(serde::Deserialize, Default)]
-        #[serde(default, rename_all = "kebab-case")]
-        struct Github {
-            mirror: Option<String>,
-        }
-
+    /// Returns defaults if the file does not exist at that commit.
+    pub fn repo_config(&self, sha: &str) -> AppResult<RepoConfig> {
         let output = self
             .git(&["show", &format!("{sha}:.quire/config.fnl")])
             .stdout(std::process::Stdio::piped())
@@ -180,7 +169,7 @@ impl Repo {
         if !output.status.success() {
             let stderr = String::from_utf8_lossy(&output.stderr);
             if stderr.contains("does not exist") || stderr.contains("not found") {
-                return Ok(None);
+                return Ok(RepoConfig::default());
             }
             return Err(Error::Io(std::io::Error::other(format!(
                 "failed to read .quire/config.fnl at {sha}: {stderr}"
@@ -188,8 +177,7 @@ impl Repo {
         }
 
         let source = String::from_utf8(output.stdout)?;
-        let cfg: Config = Fennel::new()?.load_string(&source, ".quire/config.fnl")?;
-        Ok(cfg.github.mirror)
+        Ok(Fennel::new()?.load_string(&source, ".quire/config.fnl")?)
     }
 
     /// The base directory for CI runs (`runs/<repo>/`).
@@ -207,6 +195,22 @@ impl Repo {
     }
 }
 
+/// Per-repo CI configuration parsed from `.quire/config.fnl`.
+#[derive(serde::Deserialize, Debug, Default, Clone)]
+#[serde(default, rename_all = "kebab-case")]
+pub struct RepoConfig {
+    pub github: RepoGithubConfig,
+}
+
+/// Per-repo GitHub configuration.
+#[derive(serde::Deserialize, Debug, Default, Clone)]
+#[serde(default, rename_all = "kebab-case")]
+pub struct RepoGithubConfig {
+    /// Remote URL to mirror every pushed ref to.
+    /// E.g. `"https://github.com/user/repo.git"`.
+    pub mirror: Option<String>,
+}
+
 /// Application runtime context.
 ///
 /// Carries configuration and provides resolved paths to repositories.