From c99b15cd0847d1670713eb301a79f81a80c76aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Casta=C3=B1o=20Arteaga?= Date: Fri, 12 Dec 2025 13:31:56 +0100 Subject: [PATCH 1/3] Audit: display vote details for votes in progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sergio Castaño Arteaga --- src/db.rs | 13 ++ src/handlers.rs | 327 +++++++++++++++++++++++++++++- src/tmpl.rs | 8 + templates/audit-vote-details.html | 132 ++++++++++++ templates/audit.html | 199 ++++++------------ 5 files changed, 543 insertions(+), 136 deletions(-) create mode 100644 templates/audit-vote-details.html diff --git a/src/db.rs b/src/db.rs index 6052d3a..5066d0c 100644 --- a/src/db.rs +++ b/src/db.rs @@ -43,6 +43,9 @@ pub(crate) trait DB { /// Get pending status checks. async fn get_pending_status_checks(&self) -> Result>; + /// Get vote by id. + async fn get_vote(&self, vote_id: Uuid) -> Result>; + /// Check if the issue/pr provided has a vote. async fn has_vote(&self, repository_full_name: &str, issue_number: i64) -> Result; @@ -248,6 +251,16 @@ impl DB for PgDB { Ok(inputs) } + /// [`DB::get_vote`] + async fn get_vote(&self, vote_id: Uuid) -> Result> { + let db = self.pool.get().await?; + let vote = db + .query_opt("select * from vote where vote_id = $1::uuid", &[&vote_id]) + .await? + .map(|row| Vote::from(&row)); + Ok(vote) + } + /// [`DB::has_vote`] async fn has_vote(&self, repository_full_name: &str, issue_number: i64) -> Result { let db = self.pool.get().await?; diff --git a/src/handlers.rs b/src/handlers.rs index fceacc2..ff6c18e 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -7,7 +7,7 @@ use axum::{ Router, body::Bytes, extract::{FromRef, Path, State}, - http::{HeaderMap, HeaderValue, StatusCode}, + http::{HeaderMap, HeaderValue, StatusCode, header}, response::{Html, IntoResponse}, routing::{get, post}, }; @@ -21,6 +21,7 @@ use sha2::Sha256; use tower::ServiceBuilder; use tower_http::trace::TraceLayer; use tracing::{error, instrument, trace}; +use uuid::Uuid; use crate::{ cfg_repo::{Cfg as RepoCfg, CfgError}, @@ -31,7 +32,7 @@ use crate::{ self, CheckDetails, DynGH, Event, EventError, PullRequestEvent, PullRequestEventAction, split_full_name, }, - tmpl, + results, tmpl, }; /// Header representing the kind of the event received. @@ -61,6 +62,7 @@ pub(crate) fn setup_router( .route("/", get(index)) .route("/api/events", post(event)) .route("/audit/{owner}/{repo}", get(audit)) + .route("/audit/{owner}/{repo}/vote/{vote_id}", get(audit_vote_details)) .layer(ServiceBuilder::new().layer(TraceLayer::new_for_http())) .with_state(RouterState { db, @@ -113,7 +115,7 @@ async fn audit( }; let template = tmpl::Audit::new(repository_full_name, votes); match template.render() { - Ok(html) => Ok(Html(html)), + Ok(html) => Ok(([(header::CACHE_CONTROL, "max-age=900")], Html(html))), Err(err) => { error!(?err, "error rendering audit template"); Err(StatusCode::INTERNAL_SERVER_ERROR) @@ -121,6 +123,68 @@ async fn audit( } } +/// Handler that returns vote details HTML fragment for a given vote. +async fn audit_vote_details( + State(db): State, + State(gh): State, + Path((owner, repo, vote_id)): Path<(String, String, Uuid)>, +) -> impl IntoResponse { + let repository_full_name = format!("{owner}/{repo}"); + + // Check if audit page is enabled for the repository + let audit_enabled = match audit_is_enabled(gh.clone(), repository_full_name.clone()).await { + Ok(enabled) => enabled, + Err(err) => { + error!(?err, %vote_id, "error checking audit configuration"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + if !audit_enabled { + return Err(StatusCode::NOT_FOUND); + } + + // Get vote from database + let vote = match db.get_vote(vote_id).await { + Ok(Some(vote)) => vote, + Ok(None) => return Err(StatusCode::NOT_FOUND), + Err(err) => { + error!(?err, %vote_id, "error getting vote"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + // Verify vote belongs to the requested repository + if vote.repository_full_name != repository_full_name { + return Err(StatusCode::NOT_FOUND); + } + + // Get results (from DB if closed, or calculate from GitHub API if open) + let results = if let Some(results) = &vote.results { + results.clone() + } else { + match results::calculate(gh, &owner, &repo, &vote).await { + Ok(results) => results, + Err(err) => { + error!(?err, %vote_id, "error calculating vote results"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + }; + + // Render template + let template = tmpl::AuditVoteDetails { + results: &results, + vote: &vote, + }; + match template.render() { + Ok(html) => Ok(([(header::CACHE_CONTROL, "max-age=900")], Html(html))), + Err(err) => { + error!(?err, %vote_id, "error rendering audit vote details template"); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + /// Handler that processes webhook events from GitHub. #[allow(clippy::let_with_type_underscore)] #[instrument(skip_all, err(Debug))] @@ -400,6 +464,263 @@ profiles: assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); + assert_eq!(response.headers()["cache-control"], "max-age=900"); + assert!(!get_body(response).await.is_empty()); + } + + #[tokio::test] + async fn audit_vote_details_audit_disabled_returns_not_found() { + let cfg = setup_test_config(); + + let mut db = MockDB::new(); + db.expect_get_vote().never(); + let db = Arc::new(db); + + let mut gh = MockGH::new(); + gh.expect_get_repository_installation_id() + .with(eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r" +audit: + enabled: false +profiles: + default: + duration: 1m + pass_threshold: 50 +"; + Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) + }); + let gh = Arc::new(gh); + + let (cmds_tx, _) = async_channel::unbounded(); + let router = setup_router(&cfg, db, gh, cmds_tx); + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn audit_vote_details_vote_not_found() { + let cfg = setup_test_config(); + + let mut db = MockDB::new(); + let vote_id = Uuid::parse_str(VOTE_ID).unwrap(); + db.expect_get_vote() + .with(eq(vote_id)) + .times(1) + .returning(|_| Box::pin(future::ready(Ok(None)))); + let db = Arc::new(db); + + let mut gh = MockGH::new(); + gh.expect_get_repository_installation_id() + .with(eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r" +audit: + enabled: true +profiles: + default: + duration: 1m + pass_threshold: 50 +"; + Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) + }); + let gh = Arc::new(gh); + + let (cmds_tx, _) = async_channel::unbounded(); + let router = setup_router(&cfg, db, gh, cmds_tx); + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn audit_vote_details_vote_wrong_repo_returns_not_found() { + let cfg = setup_test_config(); + + let mut db = MockDB::new(); + let vote_id = Uuid::parse_str(VOTE_ID).unwrap(); + db.expect_get_vote().with(eq(vote_id)).times(1).returning(|_| { + let mut vote = setup_test_vote(); + vote.repository_full_name = "other/repo".to_string(); + Box::pin(future::ready(Ok(Some(vote)))) + }); + let db = Arc::new(db); + + let mut gh = MockGH::new(); + gh.expect_get_repository_installation_id() + .with(eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r" +audit: + enabled: true +profiles: + default: + duration: 1m + pass_threshold: 50 +"; + Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) + }); + let gh = Arc::new(gh); + + let (cmds_tx, _) = async_channel::unbounded(); + let router = setup_router(&cfg, db, gh, cmds_tx); + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn audit_vote_details_closed_vote_renders_template() { + let cfg = setup_test_config(); + + let mut db = MockDB::new(); + let vote_id = Uuid::parse_str(VOTE_ID).unwrap(); + db.expect_get_vote().with(eq(vote_id)).times(1).returning(|_| { + let mut vote = setup_test_vote(); + vote.results = Some(setup_test_vote_results()); + Box::pin(future::ready(Ok(Some(vote)))) + }); + let db = Arc::new(db); + + let mut gh = MockGH::new(); + gh.expect_get_repository_installation_id() + .with(eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r" +audit: + enabled: true +profiles: + default: + duration: 1m + pass_threshold: 50 +"; + Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) + }); + let gh = Arc::new(gh); + + let (cmds_tx, _) = async_channel::unbounded(); + let router = setup_router(&cfg, db, gh, cmds_tx); + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); + assert_eq!(response.headers()["cache-control"], "max-age=900"); + assert!(!get_body(response).await.is_empty()); + } + + #[tokio::test] + async fn audit_vote_details_open_vote_renders_template() { + let cfg = setup_test_config(); + + let mut db = MockDB::new(); + let vote_id = Uuid::parse_str(VOTE_ID).unwrap(); + db.expect_get_vote().with(eq(vote_id)).times(1).returning(|_| { + let vote = setup_test_vote(); + Box::pin(future::ready(Ok(Some(vote)))) + }); + let db = Arc::new(db); + + let mut gh = MockGH::new(); + gh.expect_get_repository_installation_id() + .with(eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r" +audit: + enabled: true +profiles: + default: + duration: 1m + pass_threshold: 50 +"; + Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) + }); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(vec![])))); + gh.expect_get_allowed_voters() + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(vec![USER1.to_string()])))); + let gh = Arc::new(gh); + + let (cmds_tx, _) = async_channel::unbounded(); + let router = setup_router(&cfg, db, gh, cmds_tx); + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); + assert_eq!(response.headers()["cache-control"], "max-age=900"); assert!(!get_body(response).await.is_empty()); } diff --git a/src/tmpl.rs b/src/tmpl.rs index a1f8c0f..796750a 100644 --- a/src/tmpl.rs +++ b/src/tmpl.rs @@ -26,6 +26,14 @@ pub(crate) struct Audit { pub votes: Vec, } +/// Template for the audit vote details fragment. +#[derive(Debug, Clone, Template)] +#[template(path = "audit-vote-details.html")] +pub(crate) struct AuditVoteDetails<'a> { + pub results: &'a VoteResults, + pub vote: &'a Vote, +} + impl Audit { /// Create a new `Audit` template. pub(crate) fn new(repository_full_name: String, votes: Vec) -> Self { diff --git a/templates/audit-vote-details.html b/templates/audit-vote-details.html new file mode 100644 index 0000000..225f292 --- /dev/null +++ b/templates/audit-vote-details.html @@ -0,0 +1,132 @@ + +
+

Vote details

+
+ +
+
Created by
+
@{{ vote.created_by }}
+
+
+
Created
+
{{ vote.created_at }}
+
+
+
Closed
+
+ {% if let Some(closed_at) = vote.closed_at %} + {{ closed_at }} + {% endif -%} +
+
+
+
+ +
+

Results

+
+
+ In favor: {{ "{:.0}"|format(results.in_favor_percentage) }}% + {% if results.passed %} + Passed + {% else %} + Failed + {% endif %} + Passing threshold: {{ results.pass_threshold }}% +
+ +
+
+
+

Summary

+
+
+
In favor
+
{{ results.in_favor }}
+
+
+
Against
+
{{ results.against }}
+
+
+
Abstain
+
{{ results.abstain }}
+
+
+
Not voted
+
{{ results.not_voted }}
+
+
+
+
+

Binding votes

+
+ + + +
+
+ diff --git a/templates/audit.html b/templates/audit.html index 7c0536d..22c4c14 100644 --- a/templates/audit.html +++ b/templates/audit.html @@ -971,7 +971,41 @@ .btn:hover { background: #f8fafc; } + + .loading-spinner { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 1.5rem; + color: var(--muted); + font-size: 0.875rem; + } + + .loading-spinner::before { + content: ""; + width: 2rem; + height: 2rem; + border: 3px solid var(--border); + border-top-color: var(--ink-800); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 1rem; + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + + .loading-error { + color: var(--chip-fail-text); + text-align: center; + padding: 2rem; + } + @@ -1069,7 +1103,14 @@

{{ repository_full_name }}

{% for vote in votes %} {% let results = vote.results -%} - +
@@ -1205,6 +1246,7 @@

{{ year }}

{% for vote in votes %} {% if let Some(results) = vote.results -%} + + {% else %} + +