From c3f51d1a66d64c1318b53ba8ed00c414f1e79143 Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Tue, 26 May 2026 13:11:46 +0200 Subject: [PATCH 1/6] feat(entity): derive Default on ID newtypes and active enums (issue #183) --- backend/entity/src/admin_task.rs | 4 ++++ backend/entity/src/build.rs | 2 ++ backend/entity/src/evaluation.rs | 2 ++ backend/entity/src/evaluation_message.rs | 3 ++- backend/entity/src/ids.rs | 7 +++++++ backend/entity/src/organization_cache.rs | 3 ++- 6 files changed, 19 insertions(+), 2 deletions(-) diff --git a/backend/entity/src/admin_task.rs b/backend/entity/src/admin_task.rs index 8a1c66bf..fc2c7ac0 100644 --- a/backend/entity/src/admin_task.rs +++ b/backend/entity/src/admin_task.rs @@ -16,6 +16,7 @@ use crate::ids::{AdminTaskId, UserId}; Debug, Clone, Copy, + Default, PartialEq, Eq, DeriveActiveEnum, @@ -27,6 +28,7 @@ use crate::ids::{AdminTaskId, UserId}; )] #[sea_orm(rs_type = "i32", db_type = "Integer")] pub enum AdminTaskKind { + #[default] #[sea_orm(num_value = 0)] DeepGc = 0, } @@ -44,6 +46,7 @@ impl AdminTaskKind { Debug, Clone, Copy, + Default, PartialEq, Eq, DeriveActiveEnum, @@ -55,6 +58,7 @@ impl AdminTaskKind { )] #[sea_orm(rs_type = "i32", db_type = "Integer")] pub enum AdminTaskStatus { + #[default] #[sea_orm(num_value = 0)] Pending = 0, #[sea_orm(num_value = 1)] diff --git a/backend/entity/src/build.rs b/backend/entity/src/build.rs index f72722d5..38b64b8e 100644 --- a/backend/entity/src/build.rs +++ b/backend/entity/src/build.rs @@ -16,6 +16,7 @@ use crate::ids::{BuildId, DerivationId, EvaluationId}; Debug, Clone, Copy, + Default, PartialEq, Eq, DeriveActiveEnum, @@ -27,6 +28,7 @@ use crate::ids::{BuildId, DerivationId, EvaluationId}; )] #[sea_orm(rs_type = "i32", db_type = "Integer")] pub enum BuildStatus { + #[default] #[sea_orm(num_value = 0)] Created = 0, #[sea_orm(num_value = 1)] diff --git a/backend/entity/src/evaluation.rs b/backend/entity/src/evaluation.rs index d9786ff2..b1ae4047 100644 --- a/backend/entity/src/evaluation.rs +++ b/backend/entity/src/evaluation.rs @@ -16,6 +16,7 @@ use crate::ids::{CommitId, EvaluationId, ProjectId, ProjectTriggerId}; Debug, Clone, Copy, + Default, PartialEq, Eq, DeriveActiveEnum, @@ -27,6 +28,7 @@ use crate::ids::{CommitId, EvaluationId, ProjectId, ProjectTriggerId}; )] #[sea_orm(rs_type = "i32", db_type = "Integer")] pub enum EvaluationStatus { + #[default] #[sea_orm(num_value = 0)] Queued = 0, #[sea_orm(num_value = 1)] diff --git a/backend/entity/src/evaluation_message.rs b/backend/entity/src/evaluation_message.rs index 30b99620..04ed7921 100644 --- a/backend/entity/src/evaluation_message.rs +++ b/backend/entity/src/evaluation_message.rs @@ -11,9 +11,10 @@ use serde::{Deserialize, Serialize}; use crate::ids::{EvaluationId, EvaluationMessageId}; /// Severity level of an evaluation message, matching Nix's verbosity levels. -#[derive(Debug, Clone, PartialEq, Eq, DeriveActiveEnum, EnumIter, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, DeriveActiveEnum, EnumIter, Deserialize, Serialize)] #[sea_orm(rs_type = "i32", db_type = "Integer")] pub enum MessageLevel { + #[default] #[sea_orm(num_value = 0)] Error, #[sea_orm(num_value = 1)] diff --git a/backend/entity/src/ids.rs b/backend/entity/src/ids.rs index fd201194..8cb30211 100644 --- a/backend/entity/src/ids.rs +++ b/backend/entity/src/ids.rs @@ -20,6 +20,7 @@ macro_rules! id_newtype { #[derive( Copy, Clone, + Default, Eq, PartialEq, Hash, @@ -175,6 +176,12 @@ mod tests { assert!(::try_from_u64(42).is_err()); } + #[test] + fn default_id_is_nil() { + assert_eq!(UserId::default(), UserId::nil()); + assert_eq!(OrganizationId::default().into_inner(), Uuid::nil()); + } + #[test] fn distinct_types_compile_to_different_types() { let u = Uuid::now_v7(); diff --git a/backend/entity/src/organization_cache.rs b/backend/entity/src/organization_cache.rs index 26cce30e..ed107a4c 100644 --- a/backend/entity/src/organization_cache.rs +++ b/backend/entity/src/organization_cache.rs @@ -9,10 +9,11 @@ use serde::{Deserialize, Serialize}; use crate::ids::{CacheId, OrganizationCacheId, OrganizationId}; -#[derive(Debug, Clone, PartialEq, Eq, DeriveActiveEnum, EnumIter, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, DeriveActiveEnum, EnumIter, Deserialize, Serialize)] #[sea_orm(rs_type = "i32", db_type = "Integer")] pub enum CacheSubscriptionMode { /// Read from and write to this cache (default). + #[default] #[sea_orm(num_value = 0)] ReadWrite, /// Only read (use as binary cache substituter, never push to it). From d37b964eb2ad8855fef766f2da0abd3e2dc5c4b4 Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Tue, 26 May 2026 13:20:28 +0200 Subject: [PATCH 2/6] feat(entity): derive Default on all Model structs (issue #183) --- backend/entity/src/admin_task.rs | 2 +- backend/entity/src/api.rs | 2 +- backend/entity/src/audit_log.rs | 2 +- backend/entity/src/build.rs | 2 +- backend/entity/src/build_product.rs | 2 +- backend/entity/src/build_request_blob.rs | 2 +- backend/entity/src/cache.rs | 2 +- backend/entity/src/cache_derivation.rs | 2 +- backend/entity/src/cache_metric.rs | 2 +- backend/entity/src/cache_role.rs | 2 +- backend/entity/src/cache_upstream.rs | 2 +- backend/entity/src/cache_user.rs | 2 +- backend/entity/src/cached_path.rs | 2 +- backend/entity/src/cached_path_signature.rs | 2 +- backend/entity/src/commit.rs | 2 +- backend/entity/src/derivation.rs | 2 +- backend/entity/src/derivation_dependency.rs | 2 +- backend/entity/src/derivation_feature.rs | 2 +- backend/entity/src/derivation_output.rs | 2 +- backend/entity/src/derivation_output_signature.rs | 2 +- backend/entity/src/entry_point.rs | 2 +- backend/entity/src/entry_point_message.rs | 2 +- backend/entity/src/evaluation.rs | 2 +- backend/entity/src/evaluation_flake_input_override.rs | 2 +- backend/entity/src/evaluation_message.rs | 2 +- backend/entity/src/feature.rs | 2 +- backend/entity/src/integration.rs | 2 +- backend/entity/src/organization.rs | 2 +- backend/entity/src/organization_cache.rs | 2 +- backend/entity/src/organization_user.rs | 2 +- backend/entity/src/project.rs | 2 +- backend/entity/src/project_action.rs | 2 +- backend/entity/src/project_action_delivery.rs | 2 +- backend/entity/src/project_flake_input_override.rs | 2 +- backend/entity/src/project_trigger.rs | 2 +- backend/entity/src/role.rs | 2 +- backend/entity/src/session.rs | 2 +- backend/entity/src/upload_session.rs | 2 +- backend/entity/src/user.rs | 2 +- backend/entity/src/worker_registration.rs | 2 +- 40 files changed, 40 insertions(+), 40 deletions(-) diff --git a/backend/entity/src/admin_task.rs b/backend/entity/src/admin_task.rs index fc2c7ac0..8e91ff7c 100644 --- a/backend/entity/src/admin_task.rs +++ b/backend/entity/src/admin_task.rs @@ -84,7 +84,7 @@ impl AdminTaskStatus { } } -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "admin_task")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/api.rs b/backend/entity/src/api.rs index c5bf602a..acacfa3a 100644 --- a/backend/entity/src/api.rs +++ b/backend/entity/src/api.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{ApiId, CacheId, OrganizationId, UserId}; -#[derive(Clone, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "api")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/audit_log.rs b/backend/entity/src/audit_log.rs index 164eef7c..201bbec6 100644 --- a/backend/entity/src/audit_log.rs +++ b/backend/entity/src/audit_log.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{AuditLogId, UserId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "audit_log")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/build.rs b/backend/entity/src/build.rs index 38b64b8e..b3d1d00c 100644 --- a/backend/entity/src/build.rs +++ b/backend/entity/src/build.rs @@ -62,7 +62,7 @@ impl BuildStatus { } } -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "build")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/build_product.rs b/backend/entity/src/build_product.rs index d71d7460..1a5f175f 100644 --- a/backend/entity/src/build_product.rs +++ b/backend/entity/src/build_product.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{BuildProductId, DerivationOutputId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "build_product")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/build_request_blob.rs b/backend/entity/src/build_request_blob.rs index 3c943b69..dbbc7422 100644 --- a/backend/entity/src/build_request_blob.rs +++ b/backend/entity/src/build_request_blob.rs @@ -13,7 +13,7 @@ use crate::ids::{BuildRequestBlobId, OrganizationId}; /// Content-addressed source-file blob uploaded as part of a build request. /// The 32-byte BLAKE3 `hash` identifies the payload on the configured /// storage backend; the row is the GC truth source via `last_used_at`. -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "build_request_blob")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/cache.rs b/backend/entity/src/cache.rs index c933118f..20bc4929 100644 --- a/backend/entity/src/cache.rs +++ b/backend/entity/src/cache.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{CacheId, UserId}; -#[derive(Clone, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "cache")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/cache_derivation.rs b/backend/entity/src/cache_derivation.rs index 75eee15d..e624a829 100644 --- a/backend/entity/src/cache_derivation.rs +++ b/backend/entity/src/cache_derivation.rs @@ -16,7 +16,7 @@ use crate::ids::{CacheDerivationId, CacheId, DerivationId}; /// the derivation is `is_cached = true` AND every transitive dependency also /// has a `cache_derivation` row for the same cache. One row therefore answers /// "is the full closure of this derivation available in this cache". -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "cache_derivation")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/cache_metric.rs b/backend/entity/src/cache_metric.rs index 5f797b3d..465a0d94 100644 --- a/backend/entity/src/cache_metric.rs +++ b/backend/entity/src/cache_metric.rs @@ -9,7 +9,7 @@ use sea_orm::entity::prelude::*; use crate::ids::{CacheId, CacheMetricId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel)] #[sea_orm(table_name = "cache_metric")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/cache_role.rs b/backend/entity/src/cache_role.rs index 780c39e6..cc2a5d1d 100644 --- a/backend/entity/src/cache_role.rs +++ b/backend/entity/src/cache_role.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{CacheId, RoleId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "cache_role")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/cache_upstream.rs b/backend/entity/src/cache_upstream.rs index 98a2f61f..22256a64 100644 --- a/backend/entity/src/cache_upstream.rs +++ b/backend/entity/src/cache_upstream.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; /// /// Exactly one of `upstream_cache` (internal) or `url`+`public_key` (external) /// must be populated - enforced at the application level. -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "cache_upstream")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/cache_user.rs b/backend/entity/src/cache_user.rs index 6b3ed1de..7b4c855d 100644 --- a/backend/entity/src/cache_user.rs +++ b/backend/entity/src/cache_user.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{CacheId, CacheUserId, RoleId, UserId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "cache_user")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/cached_path.rs b/backend/entity/src/cached_path.rs index 7deb31fa..88faab32 100644 --- a/backend/entity/src/cached_path.rs +++ b/backend/entity/src/cached_path.rs @@ -16,7 +16,7 @@ use crate::ids::CachedPathId; /// build outputs, or anything else. The NAR data is stored once (keyed by /// `hash`). Association with specific caches and their signatures is via /// `cached_path_signature`. -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "cached_path")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/cached_path_signature.rs b/backend/entity/src/cached_path_signature.rs index 306558e1..9950c5ba 100644 --- a/backend/entity/src/cached_path_signature.rs +++ b/backend/entity/src/cached_path_signature.rs @@ -20,7 +20,7 @@ use crate::ids::{CacheId, CachedPathId, CachedPathSignatureId}; /// `signature` stores the raw 64-byte Ed25519 signature. The narinfo wire /// form (`:`) is reconstructed at read time from /// `cache.name` + the deployment's `serve_url`. -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "cached_path_signature")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/commit.rs b/backend/entity/src/commit.rs index 94ade947..8f6f0cd6 100644 --- a/backend/entity/src/commit.rs +++ b/backend/entity/src/commit.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{CommitId, UserId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "commit")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/derivation.rs b/backend/entity/src/derivation.rs index 9803d6ff..a8aae028 100644 --- a/backend/entity/src/derivation.rs +++ b/backend/entity/src/derivation.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{DerivationId, OrganizationId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "derivation")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/derivation_dependency.rs b/backend/entity/src/derivation_dependency.rs index a63af824..f500f4b7 100644 --- a/backend/entity/src/derivation_dependency.rs +++ b/backend/entity/src/derivation_dependency.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{DerivationDependencyId, DerivationId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "derivation_dependency")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/derivation_feature.rs b/backend/entity/src/derivation_feature.rs index 6596254a..3524f276 100644 --- a/backend/entity/src/derivation_feature.rs +++ b/backend/entity/src/derivation_feature.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{DerivationFeatureId, DerivationId, FeatureId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "derivation_feature")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/derivation_output.rs b/backend/entity/src/derivation_output.rs index 19128ea8..9aaead6b 100644 --- a/backend/entity/src/derivation_output.rs +++ b/backend/entity/src/derivation_output.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{CachedPathId, DerivationId, DerivationOutputId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "derivation_output")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/derivation_output_signature.rs b/backend/entity/src/derivation_output_signature.rs index a3dbcabf..19184ca0 100644 --- a/backend/entity/src/derivation_output_signature.rs +++ b/backend/entity/src/derivation_output_signature.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{CacheId, DerivationOutputId, DerivationOutputSignatureId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "derivation_output_signature")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/entry_point.rs b/backend/entity/src/entry_point.rs index 4ed88ed8..939d5f35 100644 --- a/backend/entity/src/entry_point.rs +++ b/backend/entity/src/entry_point.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{BuildId, EntryPointId, EvaluationId, ProjectId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "entry_point")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/entry_point_message.rs b/backend/entity/src/entry_point_message.rs index 8ab7d417..59a2989e 100644 --- a/backend/entity/src/entry_point_message.rs +++ b/backend/entity/src/entry_point_message.rs @@ -13,7 +13,7 @@ use crate::ids::{EntryPointId, EntryPointMessageId, EvaluationMessageId}; /// An `evaluation_message` with zero `entry_point_message` rows is evaluation-scoped /// (pipeline-level error or global warning). With one or more rows the message is /// attributed to those specific entry points (attribute paths). -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel)] #[sea_orm(table_name = "entry_point_message")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/evaluation.rs b/backend/entity/src/evaluation.rs index b1ae4047..f97609eb 100644 --- a/backend/entity/src/evaluation.rs +++ b/backend/entity/src/evaluation.rs @@ -72,7 +72,7 @@ impl EvaluationStatus { ]; } -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "evaluation")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/evaluation_flake_input_override.rs b/backend/entity/src/evaluation_flake_input_override.rs index 4e38cf1c..823c77bd 100644 --- a/backend/entity/src/evaluation_flake_input_override.rs +++ b/backend/entity/src/evaluation_flake_input_override.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{EvaluationFlakeInputOverrideId, EvaluationId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "evaluation_flake_input_override")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] diff --git a/backend/entity/src/evaluation_message.rs b/backend/entity/src/evaluation_message.rs index 04ed7921..826b6da3 100644 --- a/backend/entity/src/evaluation_message.rs +++ b/backend/entity/src/evaluation_message.rs @@ -35,7 +35,7 @@ pub enum MessageLevel { /// - `"nix-eval:"` - resolution of a specific attribute path /// - `"dep-graph"` - dependency graph walk error /// - `"db-insert"` - internal: batch insert failure -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "evaluation_message")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/feature.rs b/backend/entity/src/feature.rs index c4ca829a..0bd17108 100644 --- a/backend/entity/src/feature.rs +++ b/backend/entity/src/feature.rs @@ -25,7 +25,7 @@ pub enum FeatureKind { Architecture, } -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "system_requirement")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/integration.rs b/backend/entity/src/integration.rs index 63c8df2b..f588a1ca 100644 --- a/backend/entity/src/integration.rs +++ b/backend/entity/src/integration.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{IntegrationId, OrganizationId, UserId}; -#[derive(Clone, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "integration")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/organization.rs b/backend/entity/src/organization.rs index 5c56fa43..53d80f7f 100644 --- a/backend/entity/src/organization.rs +++ b/backend/entity/src/organization.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{OrganizationId, UserId}; -#[derive(Clone, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "organization")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/organization_cache.rs b/backend/entity/src/organization_cache.rs index ed107a4c..28e412a8 100644 --- a/backend/entity/src/organization_cache.rs +++ b/backend/entity/src/organization_cache.rs @@ -24,7 +24,7 @@ pub enum CacheSubscriptionMode { WriteOnly, } -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "organization_cache")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/organization_user.rs b/backend/entity/src/organization_user.rs index c04edc93..46e4d0b2 100644 --- a/backend/entity/src/organization_user.rs +++ b/backend/entity/src/organization_user.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{OrganizationId, OrganizationUserId, RoleId, UserId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "organization_user")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/project.rs b/backend/entity/src/project.rs index 1f3d0243..165cf6d4 100644 --- a/backend/entity/src/project.rs +++ b/backend/entity/src/project.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{EvaluationId, OrganizationId, ProjectId, UserId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "project")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/project_action.rs b/backend/entity/src/project_action.rs index 2298ecbe..122d9610 100644 --- a/backend/entity/src/project_action.rs +++ b/backend/entity/src/project_action.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{ProjectActionId, ProjectId, UserId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "project_action")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] diff --git a/backend/entity/src/project_action_delivery.rs b/backend/entity/src/project_action_delivery.rs index 62921375..c6157f27 100644 --- a/backend/entity/src/project_action_delivery.rs +++ b/backend/entity/src/project_action_delivery.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{ProjectActionDeliveryId, ProjectActionId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "project_action_delivery")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] diff --git a/backend/entity/src/project_flake_input_override.rs b/backend/entity/src/project_flake_input_override.rs index 9b237581..6e9db2df 100644 --- a/backend/entity/src/project_flake_input_override.rs +++ b/backend/entity/src/project_flake_input_override.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{FlakeInputOverrideId, ProjectId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "project_flake_input_override")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] diff --git a/backend/entity/src/project_trigger.rs b/backend/entity/src/project_trigger.rs index 6b1ed357..278c3e89 100644 --- a/backend/entity/src/project_trigger.rs +++ b/backend/entity/src/project_trigger.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{ProjectId, ProjectTriggerId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "project_trigger")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] diff --git a/backend/entity/src/role.rs b/backend/entity/src/role.rs index 85514625..a2988378 100644 --- a/backend/entity/src/role.rs +++ b/backend/entity/src/role.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{OrganizationId, RoleId}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "role")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/session.rs b/backend/entity/src/session.rs index 6c40fbf5..b85b59b5 100644 --- a/backend/entity/src/session.rs +++ b/backend/entity/src/session.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{SessionId, UserId}; -#[derive(Clone, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "session")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/upload_session.rs b/backend/entity/src/upload_session.rs index 00fbd386..7b057f19 100644 --- a/backend/entity/src/upload_session.rs +++ b/backend/entity/src/upload_session.rs @@ -14,7 +14,7 @@ use crate::ids::{OrganizationId, UploadSessionId}; /// `{path, hash, size}` objects describing the full repo snapshot; /// `missing` is a JSONB array of BLAKE3 hex strings the client still /// owes the server before `dispatch` can proceed. -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "upload_session")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/user.rs b/backend/entity/src/user.rs index 7e665560..3f3e2673 100644 --- a/backend/entity/src/user.rs +++ b/backend/entity/src/user.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::UserId; -#[derive(Clone, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "user")] pub struct Model { #[sea_orm(primary_key)] diff --git a/backend/entity/src/worker_registration.rs b/backend/entity/src/worker_registration.rs index b5a63c20..7ddbbbdc 100644 --- a/backend/entity/src/worker_registration.rs +++ b/backend/entity/src/worker_registration.rs @@ -12,7 +12,7 @@ use crate::ids::{OrganizationId, UserId, WorkerRegistrationId}; /// Tracks which peers (orgs, caches, proxies) have registered a given worker ID /// and holds the SHA-256 hash of the peer-issued token for challenge-response auth. -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] #[sea_orm(table_name = "worker_registration")] pub struct Model { #[sea_orm(primary_key)] From 5983a3f212f43256087339f22f30f35b0d9f772f Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Tue, 26 May 2026 13:20:32 +0200 Subject: [PATCH 3/6] test(entity): smoke-check Model::default for representative entities (issue #183) --- backend/entity/src/lib.rs | 43 +++++++++++++++++++++++++++++++++++++++ docs/src/tests.md | 20 ++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/backend/entity/src/lib.rs b/backend/entity/src/lib.rs index 64d74ca0..f0c45456 100644 --- a/backend/entity/src/lib.rs +++ b/backend/entity/src/lib.rs @@ -46,3 +46,46 @@ pub mod session; pub mod upload_session; pub mod user; pub mod worker_registration; + +#[cfg(test)] +mod model_default_tests { + use super::*; + use chrono::NaiveDateTime; + use uuid::Uuid; + + #[test] + fn user_default_has_nil_id_and_blank_strings() { + let m = user::Model::default(); + assert_eq!(Uuid::from(m.id), Uuid::nil()); + assert_eq!(m.username, ""); + assert!(!m.email_verified); + assert!(m.password.is_none()); + } + + #[test] + fn build_default_has_initial_status() { + let m = build::Model::default(); + assert_eq!(m.status, build::BuildStatus::Created); + assert!(!m.external_cached); + } + + #[test] + fn evaluation_default_has_initial_status() { + let m = evaluation::Model::default(); + assert_eq!(m.status, evaluation::EvaluationStatus::Queued); + assert!(m.check_run_ids.is_none()); + } + + #[test] + fn audit_log_default_has_null_json() { + let m = audit_log::Model::default(); + assert!(m.metadata.is_none()); + assert_eq!(m.created_at, NaiveDateTime::default()); + } + + #[test] + fn organization_cache_default_uses_read_write_mode() { + let m = organization_cache::Model::default(); + assert_eq!(m.mode, organization_cache::CacheSubscriptionMode::ReadWrite); + } +} diff --git a/docs/src/tests.md b/docs/src/tests.md index 284cc4f0..f4b681fe 100644 --- a/docs/src/tests.md +++ b/docs/src/tests.md @@ -1315,6 +1315,26 @@ swaps. Unit tests (`cargo test -p entity --tests`) cover: - `serde` transparency (wire format identical to bare `Uuid`). - `FromStr` parsing (lets axum `Path` extract from URL segments). - `TryFromU64` returns `DbErr` (UUID PKs are never `u64`-derivable). +- `Default` resolves to `Uuid::nil()` so `Id::default() == Id::nil()` — + enables `Model { id, ..Default::default() }` ergonomics. + +## Model defaults (`entity::model_default_tests`) + +Every `DeriveEntityModel` struct derives `Default`, and every +`DeriveActiveEnum` column type has a `#[default]` variant (initial-state / +fail-noisy where applicable). Smoke tests in `backend/entity/src/lib.rs` +confirm the derive resolves for representative models: + +- `user::Model::default()` — strings empty, `id` is nil, no password. +- `build::Model::default()` — `status == BuildStatus::Created`. +- `evaluation::Model::default()` — `status == EvaluationStatus::Queued`. +- `audit_log::Model::default()` — JSON metadata is `None`, timestamp is + the 1970 epoch from `NaiveDateTime::default()`. +- `organization_cache::Model::default()` — `mode == ReadWrite`. + +A nil ID is a placeholder, not a persistable value: callers override `id` +(typically with `Id::now_v7()`) and use `..Default::default()` for the +remaining fields. A `trybuild` compile-fail test (`cargo test -p entity --test compile_fail`) locks the swap-prevention From 9629a768a5b12a1c8b042593878a7a844377d54a Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Tue, 26 May 2026 17:21:00 +0200 Subject: [PATCH 4/6] fix(ci): add issues:write to GitHub App manifest so issue_comment fires GitHub gates the `issue_comment` webhook on the `issues` permission, even for comments posted on the main conversation tab of a pull request. The manifest subscribed to the event but never received it, so `/ci run` triggers posted on PR conversations were silently dropped. `write` is required because the same `/repos/{owner}/{repo}/issues/{n}/comments` endpoint is used to reply with build status via the existing reporter. --- backend/core/src/ci/github_app_manifest.rs | 2 ++ docs/src/development/github-app-setup.md | 11 +++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/backend/core/src/ci/github_app_manifest.rs b/backend/core/src/ci/github_app_manifest.rs index eae27296..5fff9575 100644 --- a/backend/core/src/ci/github_app_manifest.rs +++ b/backend/core/src/ci/github_app_manifest.rs @@ -55,6 +55,7 @@ pub fn build_manifest(serve_url: &str) -> Value { "statuses": "write", "checks": "write", "pull_requests": "write", + "issues": "write", }, "default_events": [ "push", @@ -170,6 +171,7 @@ mod tests { assert_eq!(m["default_permissions"]["statuses"], "write"); assert_eq!(m["default_permissions"]["checks"], "write"); assert_eq!(m["default_permissions"]["pull_requests"], "write"); + assert_eq!(m["default_permissions"]["issues"], "write"); assert_eq!( m["default_events"], json!([ diff --git a/docs/src/development/github-app-setup.md b/docs/src/development/github-app-setup.md index 023f568a..33770a96 100644 --- a/docs/src/development/github-app-setup.md +++ b/docs/src/development/github-app-setup.md @@ -42,8 +42,15 @@ Match the values Gradient expects: |---|---| | Webhook URL | `{serveUrl}/api/v1/hooks/github` | | Setup URL | `{serveUrl}/admin/github-app` (optional) | -| Permissions | `metadata: read`, `contents: read`, `pull_requests: read`, `statuses: write`, `checks: write` | -| Events | `push`, `pull_request` (`installation` and `installation_repositories` are delivered automatically and are not selectable) | +| Permissions | `metadata: read`, `contents: read`, `pull_requests: write`, `issues: write`, `statuses: write`, `checks: write` | +| Events | `push`, `pull_request`, `release`, `check_run`, `issue_comment` (`installation` and `installation_repositories` are delivered automatically and are not selectable) | + +The `issues: write` permission is what gates `issue_comment` delivery — GitHub +routes every comment on a PR's main conversation tab through that event, so +without the permission Gradient never sees `/ci run` triggers posted there. +`write` is required because the `post_pr_comment` reporter uses the same +`/repos/{owner}/{repo}/issues/{n}/comments` endpoint to reply with build +status. Then download the private key, generate a webhook secret, and configure the env vars as below. From 0eb8b588d090fb58f9d842f140945531261c4982 Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Tue, 26 May 2026 17:59:13 +0200 Subject: [PATCH 5/6] feat(core): get_pull_request reporter method + wildcard_override on ApplyInput `/gradient run` needs the PR head SHA to lay down a fresh evaluation when no parked approval gate exists; the issue_comment webhook does not carry it. Adds `CiReporter::get_pull_request` (default no-op, implemented for the Gitea/Forgejo, GitLab, GitHub, and GitHub App reporters), returning a `PullRequestSnapshot { head_sha, head_branch, head_clone_url, is_fork }`. Also threads `wildcard_override: Option` through `ApplyInput` / `apply_trigger` / `trigger_evaluation` so a comment can re-target the run without editing project config. --- backend/core/src/ci/apply.rs | 8 + backend/core/src/ci/reporter.rs | 289 +++++++++++++++++- backend/core/src/ci/trigger.rs | 14 +- backend/scheduler/src/trigger_dispatch.rs | 1 + .../web/src/endpoints/projects/evaluations.rs | 1 + .../web/src/endpoints/projects/triggers.rs | 1 + 6 files changed, 307 insertions(+), 7 deletions(-) diff --git a/backend/core/src/ci/apply.rs b/backend/core/src/ci/apply.rs index a8f1e8f8..f498d2ad 100644 --- a/backend/core/src/ci/apply.rs +++ b/backend/core/src/ci/apply.rs @@ -63,6 +63,11 @@ pub struct ApplyInput { /// `project.repository` (which only has the base repo's history). `None` /// falls back to `project.repository`. pub repository_override: Option, + /// Override the evaluation's `wildcard` attribute pattern. Used by + /// `/gradient run ` so a maintainer can re-target a single + /// run without editing project config. `None` falls back to + /// `project.wildcard`. + pub wildcard_override: Option, } /// Identification of the pull request a maintainer must approve before the @@ -149,6 +154,7 @@ pub async fn apply_trigger( Some(input.trigger_id), concurrent_flag, input.repository_override, + input.wildcard_override, ) .await { @@ -337,6 +343,7 @@ mod tests { manual, gate_approval: None, repository_override: None, + wildcard_override: None, } } @@ -868,6 +875,7 @@ mod tests { pr_author: "external-contrib".into(), }), repository_override: None, + wildcard_override: None, }; let res = apply_trigger(&db, &project, applied).await.unwrap(); diff --git a/backend/core/src/ci/reporter.rs b/backend/core/src/ci/reporter.rs index f400570c..9f7d2bf5 100644 --- a/backend/core/src/ci/reporter.rs +++ b/backend/core/src/ci/reporter.rs @@ -57,6 +57,27 @@ pub struct RequestedAction { /// on when the forge echoes it back via `check_run.requested_action`. pub const APPROVAL_ACTION_ID: &str = "approve-and-run"; +/// Snapshot of a pull/merge request returned by [`CiReporter::get_pull_request`]. +/// +/// Used by the `/gradient run` comment handler to learn the PR's current head +/// SHA + ref so it can lay down a fresh evaluation when no parked approval +/// gate exists. `issue_comment` webhooks do not carry head metadata, hence +/// the round-trip via the forge API. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PullRequestSnapshot { + /// Full 40-character commit SHA of the PR head. + pub head_sha: String, + /// PR head branch name (without `refs/heads/` prefix). + pub head_branch: String, + /// Clone URL of the PR head repo when the PR is from a fork; `None` for + /// same-repo PRs. Mirrors the `head_repo_clone_url` extracted from + /// `pull_request` webhook payloads so the existing PR-trigger fanout can + /// route fetches to the fork. + pub head_clone_url: Option, + /// `true` when the PR head repo differs from the base repo. + pub is_fork: bool, +} + /// All parameters needed to report a CI status to an external provider. #[derive(Debug, Clone)] pub struct CiReport { @@ -120,7 +141,7 @@ pub trait CiReporter: Send + Sync + std::fmt::Debug + 'static { Ok(false) } - /// Post a reply comment to a PR/MR. Used by `/ci run ` to + /// Post a reply comment to a PR/MR. Used by `/gradient run ` to /// surface wildcard parse errors back to the commenter. /// /// Default impl is a no-op so reporters that do not implement @@ -134,6 +155,22 @@ pub trait CiReporter: Send + Sync + std::fmt::Debug + 'static { ) -> Result<()> { Ok(()) } + + /// Fetch the current head metadata of an open pull/merge request. + /// Used by the `/gradient run` comment handler so it can create a fresh + /// evaluation when no parked approval gate exists for the PR. + /// + /// Default impl returns `Ok(None)`. Implementations that cannot probe + /// the forge should fall through; the comment handler then logs and + /// declines to create a fresh evaluation. + async fn get_pull_request( + &self, + _owner: &str, + _repo: &str, + _pr_number: u64, + ) -> Result> { + Ok(None) + } } // ── NoopCiReporter ──────────────────────────────────────────────────────────── @@ -331,6 +368,77 @@ impl CiReporter for GiteaReporter { } Ok(()) } + + async fn get_pull_request( + &self, + owner: &str, + repo: &str, + pr_number: u64, + ) -> Result> { + let url = format!( + "{}/api/v1/repos/{}/{}/pulls/{}", + self.base_url, owner, repo, pr_number + ); + let resp = self + .client + .get(&url) + .header("Authorization", format!("token {}", self.token)) + .send() + .await + .context("Failed to query Gitea pull request")?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Gitea pull request query returned {}: {}", status, body); + } + #[derive(Deserialize)] + struct PrResponse { + head: GiteaPrRef, + #[serde(default)] + base: Option, + } + #[derive(Deserialize)] + struct GiteaPrRef { + sha: String, + #[serde(rename = "ref")] + ref_: Option, + #[serde(default)] + repo: Option, + } + #[derive(Deserialize)] + struct GiteaPrRepo { + #[serde(default)] + full_name: Option, + #[serde(default)] + clone_url: Option, + } + let pr: PrResponse = resp + .json() + .await + .context("Failed to parse Gitea pull request response")?; + let head_branch = pr.head.ref_.clone().unwrap_or_default(); + let head_full = pr.head.repo.as_ref().and_then(|r| r.full_name.clone()); + let base_full = pr + .base + .as_ref() + .and_then(|b| b.repo.as_ref()) + .and_then(|r| r.full_name.clone()); + let is_fork = matches!((head_full.as_deref(), base_full.as_deref()), (Some(h), Some(b)) if h != b); + let head_clone_url = if is_fork { + pr.head.repo.as_ref().and_then(|r| r.clone_url.clone()) + } else { + None + }; + Ok(Some(PullRequestSnapshot { + head_sha: pr.head.sha, + head_branch, + head_clone_url, + is_fork, + })) + } } // ── GitlabReporter ──────────────────────────────────────────────────────────── @@ -526,6 +634,63 @@ impl CiReporter for GitlabReporter { } Ok(()) } + + async fn get_pull_request( + &self, + owner: &str, + repo: &str, + pr_number: u64, + ) -> Result> { + let project_id = gitlab_project_id(owner, repo); + let url = format!( + "{}/api/v4/projects/{}/merge_requests/{}", + self.base_url, project_id, pr_number + ); + let resp = self + .client + .get(&url) + .header("PRIVATE-TOKEN", &self.token) + .send() + .await + .context("Failed to query GitLab merge request")?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("GitLab MR query returned {}: {}", status, body); + } + #[derive(Deserialize)] + struct MrResponse { + sha: String, + source_branch: String, + #[serde(default)] + source_project_id: Option, + #[serde(default)] + target_project_id: Option, + } + let mr: MrResponse = resp + .json() + .await + .context("Failed to parse GitLab merge request response")?; + let is_fork = matches!( + (mr.source_project_id, mr.target_project_id), + (Some(s), Some(t)) if s != t + ); + // GitLab's MR JSON does not surface the fork's clone URL directly; the + // `synchronize`-style PR webhook does (via `object_attributes.source`), + // but the GET endpoint omits it. For the comment-driven path we leave + // `head_clone_url` unset for forks — the existing fan-out keeps using + // `project.repository` for the worker fetch. Same-project MRs (the + // common case) are unaffected. + Ok(Some(PullRequestSnapshot { + head_sha: mr.sha, + head_branch: mr.source_branch, + head_clone_url: None, + is_fork, + })) + } } // ── GithubReporter ──────────────────────────────────────────────────────────── @@ -716,6 +881,40 @@ impl CiReporter for GithubReporter { } Ok(()) } + + async fn get_pull_request( + &self, + owner: &str, + repo: &str, + pr_number: u64, + ) -> Result> { + let url = format!( + "{}/repos/{}/{}/pulls/{}", + self.base_url, owner, repo, pr_number + ); + let resp = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", self.token)) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + .context("Failed to query GitHub pull request")?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("GitHub pull request query returned {}: {}", status, body); + } + let pr: GithubPrResponse = resp + .json() + .await + .context("Failed to parse GitHub pull request response")?; + Ok(Some(github_pr_response_to_snapshot(pr))) + } } #[derive(Debug, Deserialize)] @@ -723,6 +922,51 @@ struct GithubPermissionResponse { permission: String, } +#[derive(Deserialize)] +struct GithubPrResponse { + head: GithubPrRef, + #[serde(default)] + base: Option, +} + +#[derive(Deserialize)] +struct GithubPrRef { + sha: String, + #[serde(rename = "ref")] + ref_: String, + #[serde(default)] + repo: Option, +} + +#[derive(Deserialize)] +struct GithubPrRepo { + #[serde(default)] + full_name: Option, + #[serde(default)] + clone_url: Option, +} + +fn github_pr_response_to_snapshot(pr: GithubPrResponse) -> PullRequestSnapshot { + let head_full = pr.head.repo.as_ref().and_then(|r| r.full_name.clone()); + let base_full = pr + .base + .as_ref() + .and_then(|b| b.repo.as_ref()) + .and_then(|r| r.full_name.clone()); + let is_fork = matches!((head_full.as_deref(), base_full.as_deref()), (Some(h), Some(b)) if h != b); + let head_clone_url = if is_fork { + pr.head.repo.as_ref().and_then(|r| r.clone_url.clone()) + } else { + None + }; + PullRequestSnapshot { + head_sha: pr.head.sha, + head_branch: pr.head.ref_, + head_clone_url, + is_fork, + } +} + // ── GithubAppReporter ──────────────────────────────────────────────────────── /// CI reporter that creates and updates GitHub Check Runs as a GitHub App @@ -1033,6 +1277,49 @@ impl CiReporter for GithubAppReporter { } Ok(()) } + + async fn get_pull_request( + &self, + owner: &str, + repo: &str, + pr_number: u64, + ) -> Result> { + let token = crate::ci::github_app::get_installation_token( + &self.client, + self.app_id, + &self.private_key_pem, + self.installation_id, + ) + .await + .context("Failed to mint GitHub App installation token")?; + + let url = format!( + "{}/repos/{}/{}/pulls/{}", + self.api_base_url, owner, repo, pr_number + ); + let resp = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + .context("Failed to query GitHub pull request")?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("GitHub App PR query returned {}: {}", status, body); + } + let pr: GithubPrResponse = resp + .json() + .await + .context("Failed to parse GitHub pull request response")?; + Ok(Some(github_pr_response_to_snapshot(pr))) + } } // ── factory ────────────────────────────────────────────────────────────────── diff --git a/backend/core/src/ci/trigger.rs b/backend/core/src/ci/trigger.rs index a7103bc1..2b13ace7 100644 --- a/backend/core/src/ci/trigger.rs +++ b/backend/core/src/ci/trigger.rs @@ -49,6 +49,7 @@ pub async fn trigger_evaluation( trigger: Option, concurrent: bool, repository_override: Option, + wildcard_override: Option, ) -> Result { if !concurrent { let in_progress = EEvaluation::find() @@ -97,7 +98,7 @@ pub async fn trigger_evaluation( project: Set(Some(project.id)), repository: Set(repository_override.unwrap_or_else(|| project.repository.clone())), commit: Set(commit.id), - wildcard: Set(project.wildcard.clone()), + wildcard: Set(wildcard_override.unwrap_or_else(|| project.wildcard.clone())), status: Set(EvaluationStatus::Queued), previous: Set(previous), next: Set(None), @@ -405,7 +406,7 @@ mod tests { .into_connection(); let result = - trigger_evaluation(&db, &project, vec![0u8; 20], None, None, None, false, None).await; + trigger_evaluation(&db, &project, vec![0u8; 20], None, None, None, false, None, None).await; assert!(result.is_ok(), "expected Ok, got: {:?}", result.err()); assert_eq!(result.unwrap().status, EvaluationStatus::Queued); } @@ -446,7 +447,7 @@ mod tests { .into_connection(); let result = - trigger_evaluation(&db, &project, vec![0u8; 20], None, None, None, false, None).await; + trigger_evaluation(&db, &project, vec![0u8; 20], None, None, None, false, None, None).await; assert!(result.is_ok(), "expected Ok, got: {:?}", result.err()); } @@ -461,7 +462,7 @@ mod tests { .into_connection(); let result = - trigger_evaluation(&db, &project, vec![0u8; 20], None, None, None, false, None).await; + trigger_evaluation(&db, &project, vec![0u8; 20], None, None, None, false, None, None).await; assert!(matches!(result, Err(TriggerError::AlreadyInProgress))); } @@ -481,7 +482,7 @@ mod tests { .append_query_results([vec![make_eval(EvaluationId::now_v7(), status)]]) .into_connection(); let result = - trigger_evaluation(&db, &project, vec![0u8; 20], None, None, None, false, None) + trigger_evaluation(&db, &project, vec![0u8; 20], None, None, None, false, None, None) .await; assert!( matches!(result, Err(TriggerError::AlreadyInProgress)), @@ -549,7 +550,7 @@ mod tests { .into_connection(); let result = - trigger_evaluation(&db, &project, vec![0u8; 20], None, None, None, false, None).await; + trigger_evaluation(&db, &project, vec![0u8; 20], None, None, None, false, None, None).await; assert!(result.is_ok(), "terminal eval should not block new trigger"); } @@ -591,6 +592,7 @@ mod tests { Some(trig), false, None, + None, ) .await; assert!(result.is_ok()); diff --git a/backend/scheduler/src/trigger_dispatch.rs b/backend/scheduler/src/trigger_dispatch.rs index 2955a6d2..38a295b0 100644 --- a/backend/scheduler/src/trigger_dispatch.rs +++ b/backend/scheduler/src/trigger_dispatch.rs @@ -219,6 +219,7 @@ pub(crate) async fn dispatch_once(scheduler: &Scheduler) -> anyhow::Result<()> { manual: false, gate_approval: None, repository_override: None, + wildcard_override: None, }, ) .await diff --git a/backend/web/src/endpoints/projects/evaluations.rs b/backend/web/src/endpoints/projects/evaluations.rs index 66b9363b..272833c3 100644 --- a/backend/web/src/endpoints/projects/evaluations.rs +++ b/backend/web/src/endpoints/projects/evaluations.rs @@ -230,6 +230,7 @@ pub async fn post_project_evaluate( None, false, None, + None, ) .await .map_err(|e| match e { diff --git a/backend/web/src/endpoints/projects/triggers.rs b/backend/web/src/endpoints/projects/triggers.rs index 794c4e4e..5733596e 100644 --- a/backend/web/src/endpoints/projects/triggers.rs +++ b/backend/web/src/endpoints/projects/triggers.rs @@ -399,6 +399,7 @@ pub async fn fire_now( manual: true, gate_approval: None, repository_override: None, + wildcard_override: None, }; let outcome = apply_trigger(&state.web_db, &proj, input) From 128fb4d15af970c83682ebd1f07a4b5b0caf8f7b Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Tue, 26 May 2026 17:59:37 +0200 Subject: [PATCH 6/6] feat(web): /gradient PR comment commands (run + approve), maintainer-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous `/ci run` parser with a `GradientCommand` enum recognising `/gradient run [wildcard]` and `/gradient approve`. The legacy `/ci` prefix is hard-rejected (test covers the regression). Dispatch in `handle_issue_comment`: - Both commands run a `sender_is_trusted` (`is_repo_writer`) gate against the project's reporter — non-maintainers are dropped at debug level with no side effect. - `/gradient approve` only unparks an existing `Waiting + Approval` row. - `/gradient run` unparks if a parked eval exists for the PR (so it doubles as approve), and otherwise fetches the PR via `reporter.get_pull_request` and calls `trigger_pr_for_integration` with `manual=true` and `wildcard_override` from the comment so a fresh evaluation is laid down at the current PR head. Docs, the Nix state module, and the project-triggers UI are updated to reference `/gradient` everywhere; `docs/src/tests.md` lists the new parser tests and the renamed legacy-prefix regression. --- backend/core/src/types/triggers.rs | 4 +- backend/web/src/endpoints/forge_hooks/mod.rs | 4 + .../web/src/endpoints/forge_hooks/trigger.rs | 400 +++++++++++++----- docs/gradient-api.yaml | 22 +- docs/src/development/github-app-setup.md | 5 +- docs/src/tests.md | 15 +- frontend/src/app/core/models/trigger.model.ts | 2 +- .../project-triggers.component.html | 5 +- nix/modules/gradient-state.nix | 4 +- 9 files changed, 331 insertions(+), 130 deletions(-) diff --git a/backend/core/src/types/triggers.rs b/backend/core/src/types/triggers.rs index 034f81ff..21fcd238 100644 --- a/backend/core/src/types/triggers.rs +++ b/backend/core/src/types/triggers.rs @@ -66,8 +66,8 @@ pub enum TriggerConfig { /// When true (default, secure-by-default), PRs from contributors who /// are not repo writers on the forge are parked in /// `WaitingReason::Approval` until a maintainer either clicks the - /// "Approve and Run" check-run action (GitHub) or comments `/ci run` - /// (Gitea/Forgejo/GitLab). + /// "Approve and Run" check-run action (GitHub) or comments + /// `/gradient approve` (or `/gradient run`) on the PR. #[serde(default = "default_require_approval")] require_approval: bool, }, diff --git a/backend/web/src/endpoints/forge_hooks/mod.rs b/backend/web/src/endpoints/forge_hooks/mod.rs index 62730d50..bf7e8932 100644 --- a/backend/web/src/endpoints/forge_hooks/mod.rs +++ b/backend/web/src/endpoints/forge_hooks/mod.rs @@ -249,6 +249,8 @@ async fn dispatch_github_app_pr( None, approval_ctx.clone(), head_clone.clone(), + false, + None, ) .await; combined.projects_scanned += outcome.projects_scanned; @@ -431,6 +433,8 @@ pub async fn forge_webhook( None, approval_ctx, head_clone, + false, + None, ) .await; WebhookResponse { diff --git a/backend/web/src/endpoints/forge_hooks/trigger.rs b/backend/web/src/endpoints/forge_hooks/trigger.rs index 87f89122..63e525e4 100644 --- a/backend/web/src/endpoints/forge_hooks/trigger.rs +++ b/backend/web/src/endpoints/forge_hooks/trigger.rs @@ -23,7 +23,7 @@ use sea_orm::{ }; use serde::Deserialize; use std::sync::Arc; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; /// PR metadata extracted from the forge webhook payload, used by the approval /// gate to decide whether to park the evaluation pending maintainer approval. @@ -275,6 +275,8 @@ pub(super) async fn trigger_push_for_integration( }, None, None, + false, + None, ) .await } @@ -291,6 +293,8 @@ pub(super) async fn trigger_pr_for_integration( author_name: Option, approval_ctx: PullRequestApprovalContext, head_repo_clone_url: Option, + manual: bool, + wildcard_override: Option, ) -> WebhookTriggerOutcome { let action_owned = action.to_string(); let branch_owned = branch.map(str::to_string); @@ -327,6 +331,8 @@ pub(super) async fn trigger_pr_for_integration( }, Some(approval_ctx), head_repo_clone_url, + manual, + wildcard_override, ) .await } @@ -371,6 +377,8 @@ pub(super) async fn trigger_release_for_integration( }, None, None, + false, + None, ) .await } @@ -401,6 +409,8 @@ async fn fan_out_triggers( filter: F, approval_ctx: Option, repository_override: Option, + manual: bool, + wildcard_override: Option, ) -> WebhookTriggerOutcome where F: Fn(&TriggerConfig) -> FilterResult, @@ -472,9 +482,10 @@ where commit_hash: commit_hash.clone(), commit_message: commit_message.clone(), author_name: author_name.clone(), - manual: false, + manual, gate_approval, repository_override: repository_override.clone(), + wildcard_override: wildcard_override.clone(), }, ) .await; @@ -876,7 +887,7 @@ async fn sender_is_trusted( } } -// ── Approval unpark: `/ci run` comment on Gitea/Forgejo/GitLab + GitHub ──── +// ── /gradient commands: PR-comment dispatch (Gitea/Forgejo/GitLab + GitHub) ─ #[derive(Deserialize)] struct CommentPayload { @@ -947,15 +958,17 @@ struct GitlabNoteMr { iid: Option, } -/// Handle a `/ci run` comment on a PR. Re-queues the approval-gated -/// evaluation when the commenter passes the writer-trust probe. +/// Handle a `/gradient run [wildcard]` or `/gradient approve` comment on a PR. +/// Both commands are maintainer-only. `run` unparks an existing approval gate +/// if one exists for the PR, and otherwise creates a fresh evaluation; `approve` +/// only unparks. /// /// `integration_id` is `Some` for per-integration webhook routes (Gitea / /// Forgejo / GitLab) and `None` for the shared GitHub App route - for the /// latter we resolve the integration from `installation.id` in the body. pub(super) async fn handle_issue_comment( state: &Arc, - _scheduler: &Arc, + scheduler: &Arc, forge: ForgeType, integration_id: Option, body: &[u8], @@ -983,8 +996,7 @@ pub(super) async fn handle_issue_comment( (comment_body, pr_number, sender, owner_repo) } _ => { - // GitHub uses `action == "created"`; Gitea uses - // `action == "created"` too. + // GitHub and Gitea both use `action == "created"`. if payload.action.as_deref() != Some("created") { return; } @@ -999,20 +1011,20 @@ pub(super) async fn handle_issue_comment( } }; - let cmd = match parse_ci_run_command(&comment_body) { + let cmd = match parse_gradient_command(&comment_body) { Some(cmd) => cmd, None => return, }; let Some(pr_number) = pr_number else { - warn!("comment webhook: /ci run but no PR number"); + warn!("comment webhook: /gradient command but no PR number"); return; }; let Some(sender) = sender_login else { - warn!("comment webhook: /ci run but no sender"); + warn!("comment webhook: /gradient command but no sender"); return; }; let Some(owner_repo) = owner_repo else { - warn!("comment webhook: /ci run but no repo path"); + warn!("comment webhook: /gradient command but no repo path"); return; }; let Some((owner, repo)) = owner_repo.rsplit_once('/') else { @@ -1045,11 +1057,14 @@ pub(super) async fn handle_issue_comment( }; let wildcard_override: Option = match &cmd { - CiRunCommand::Plain => None, - CiRunCommand::WithWildcard(raw) => match raw.parse::() { + GradientCommand::Approve => None, + GradientCommand::Run { wildcard: None } => None, + GradientCommand::Run { + wildcard: Some(raw), + } => match raw.parse::() { Ok(_) => Some(raw.clone()), Err(e) => { - warn!(wildcard = %raw, error = %e, "/ci run wildcard rejected"); + warn!(wildcard = %raw, error = %e, "/gradient run wildcard rejected"); post_wildcard_error_comment( state, &integration_ids, @@ -1065,7 +1080,7 @@ pub(super) async fn handle_issue_comment( }, }; - let mut unparked_any = false; + let mut action_taken = false; for integration_id in &integration_ids { let project_ids = match active_project_ids_for_integration(state, *integration_id).await { Ok(rows) => rows, @@ -1074,21 +1089,24 @@ pub(super) async fn handle_issue_comment( continue; } }; - for project_id in project_ids { + let mut parked_unparked_in_integration = false; + let mut maintainer_verified = false; + for project_id in &project_ids { let Ok(Some(eval)) = - find_approval_gated_eval(&state.web_db, project_id, pr_number).await + find_approval_gated_eval(&state.web_db, *project_id, pr_number).await else { continue; }; - if !sender_is_trusted(state, project_id, owner, repo, &sender).await { + if !sender_is_trusted(state, *project_id, owner, repo, &sender).await { warn!( project_id = %project_id, pr_number, %sender, - "Rejecting /ci run - sender is not a repo writer" + "Rejecting /gradient command - sender is not a repo writer" ); continue; } + maintainer_verified = true; let unpark_result = match &wildcard_override { None => unpark_approval(&state.web_db, eval.id).await, Some(w) => unpark_approval_with_wildcard(&state.web_db, eval.id, w).await, @@ -1100,10 +1118,11 @@ pub(super) async fn handle_issue_comment( pr_number, %sender, wildcard_override = wildcard_override.as_deref(), - "PR approval gate cleared via /ci run comment" + "PR approval gate cleared via /gradient comment" ); dispatch_approval_granted(state, &unparked).await; - unparked_any = true; + parked_unparked_in_integration = true; + action_taken = true; } Ok(None) => {} Err(e) => { @@ -1111,13 +1130,140 @@ pub(super) async fn handle_issue_comment( } } } + + if parked_unparked_in_integration { + continue; + } + if !matches!(cmd, GradientCommand::Run { .. }) { + continue; + } + + // No parked eval to unpark — create a fresh evaluation for the PR. + // Maintainer check: if no project unparked above, run the trust probe + // explicitly against the first project that has a usable reporter. + if !maintainer_verified { + let Some(probe_project) = first_project_with_reporter(state, &project_ids).await + else { + continue; + }; + if !sender_is_trusted(state, probe_project, owner, repo, &sender).await { + warn!( + %integration_id, + pr_number, + %sender, + "Rejecting /gradient run - sender is not a repo writer" + ); + continue; + } + } + + let Some(snapshot) = fetch_pr_snapshot(state, &project_ids, owner, repo, pr_number).await + else { + warn!( + %integration_id, + pr_number, + "/gradient run: could not fetch PR head via any project reporter" + ); + continue; + }; + let commit_hash = match hex::decode(&snapshot.head_sha) { + Ok(b) => b, + Err(e) => { + warn!(error = %e, sha = %snapshot.head_sha, "/gradient run: invalid head SHA"); + continue; + } + }; + let approval_ctx = PullRequestApprovalContext { + pr_number: Some(pr_number), + pr_author: None, + is_fork: Some(snapshot.is_fork), + }; + let outcome = trigger_pr_for_integration( + state, + scheduler, + *integration_id, + Some(snapshot.head_branch.as_str()), + "synchronize", + commit_hash, + None, + Some(sender.clone()), + approval_ctx, + snapshot.head_clone_url.clone(), + true, + wildcard_override.clone(), + ) + .await; + if !outcome.queued.is_empty() { + info!( + %integration_id, + pr_number, + %sender, + wildcard_override = wildcard_override.as_deref(), + queued = outcome.queued.len(), + "/gradient run created fresh evaluation" + ); + action_taken = true; + } else { + debug!( + %integration_id, + pr_number, + "/gradient run: trigger fan-out queued nothing" + ); + } + } + if !action_taken { + debug!( + pr_number, + "/gradient comment had no parked evaluation and produced no fresh fire" + ); } - if !unparked_any { - debug_no_match(pr_number); +} + +/// Walk `project_ids` and return the first project that resolves to a usable +/// `CiReporter`. Used by the `/gradient run` fresh-eval path to obtain a +/// reporter for both the maintainer trust check and the PR-snapshot fetch. +async fn first_project_with_reporter( + state: &Arc, + project_ids: &[ProjectId], +) -> Option { + for project_id in project_ids { + if let Ok(Some(_)) = + gradient_core::ci::actions::reporter_for_project(state, *project_id).await + { + return Some(*project_id); + } } + None } -/// Posts a reply comment to the PR explaining a `/ci run ` +/// Fetch a [`PullRequestSnapshot`] using the first project's reporter. +/// Returns `None` if no project has a reporter, the reporter call errors, or +/// the PR is missing (closed/404). +async fn fetch_pr_snapshot( + state: &Arc, + project_ids: &[ProjectId], + owner: &str, + repo: &str, + pr_number: u64, +) -> Option { + for project_id in project_ids { + let reporter = + match gradient_core::ci::actions::reporter_for_project(state, *project_id).await { + Ok(Some(r)) => r, + _ => continue, + }; + match reporter.get_pull_request(owner, repo, pr_number).await { + Ok(Some(snap)) => return Some(snap), + Ok(None) => return None, + Err(e) => { + warn!(error = %e, %project_id, "/gradient run: PR fetch failed, trying next project"); + } + } + } + None +} + +/// Posts a reply comment to the PR explaining a `/gradient run ` /// parse failure. Walks every project owned by the matching /// integrations and uses the first project with a usable /// `ForgeStatusReport` action's reporter. Failures are logged and @@ -1141,7 +1287,7 @@ async fn post_wildcard_error_comment( let project_ids = match active_project_ids_for_integration(state, *integration_id).await { Ok(rows) => rows, Err(e) => { - warn!(error = %e, %integration_id, "/ci run wildcard: failed to load projects for reply comment"); + warn!(error = %e, %integration_id, "/gradient run wildcard: failed to load projects for reply comment"); continue; } }; @@ -1151,48 +1297,45 @@ async fn post_wildcard_error_comment( Ok(Some(r)) => r, Ok(None) => continue, Err(e) => { - warn!(error = %e, %project_id, "/ci run wildcard: resolving reporter for reply comment"); + warn!(error = %e, %project_id, "/gradient run wildcard: resolving reporter for reply comment"); continue; } }; match reporter.post_pr_comment(owner, repo, pr_number, &body).await { Ok(()) => return, Err(e) => { - warn!(error = %e, %project_id, "/ci run wildcard: reply comment post failed, trying next project"); + warn!(error = %e, %project_id, "/gradient run wildcard: reply comment post failed, trying next project"); } } } } } -fn debug_no_match(pr_number: u64) { - tracing::debug!( - pr_number, - "/ci run comment had no matching parked evaluation" - ); -} - -/// Outcome of parsing a `/ci run [wildcard]` comment. +/// Outcome of parsing a `/gradient …` PR comment. #[derive(Debug, PartialEq, Eq)] -pub(super) enum CiRunCommand { - /// Bare `/ci run` — unpark with the wildcard already on the eval row. - Plain, - /// `/ci run ` — unpark and override the eval row's wildcard - /// with this raw string. Not yet validated; the caller must pass it - /// through `Wildcard::from_str` and reject (with a reply comment) on - /// parse failure. - WithWildcard(String), +pub(super) enum GradientCommand { + /// `/gradient run [wildcard]` — re-run CI for this PR. Unparks an + /// existing approval-gated eval if one exists, otherwise creates a fresh + /// evaluation. The optional `wildcard` overrides the project's default + /// attribute path for this run; it is not yet validated, the caller must + /// pass it through `Wildcard::from_str` and reply on parse failure. + Run { wildcard: Option }, + /// `/gradient approve` — explicitly clear the approval gate for this PR. + /// No-op if there is no parked eval. + Approve, } -/// Lifts `/ci run` (with or without a wildcard argument) from a comment -/// body. The command must appear on its own line (after trimming -/// whitespace). Blank lines and forge quote-reply lines (`> …`) are -/// skipped so a maintainer can quote the PR context above the command; -/// any other prose before or after the command disqualifies the comment. -pub(super) fn parse_ci_run_command(body: &str) -> Option { - const PREFIX: &str = "/ci run"; +/// Lifts a `/gradient ` instruction from a PR comment. The +/// command must appear on its own line (after trimming whitespace). Blank +/// lines and forge quote-reply lines (`> …`) are skipped so a maintainer +/// can quote PR context above the command; any other prose before or after +/// disqualifies the comment. +/// +/// Recognised subcommands: `run [wildcard]` and `approve`. +pub(super) fn parse_gradient_command(body: &str) -> Option { + const PREFIX: &str = "/gradient"; - let mut found: Option = None; + let mut found: Option = None; for line in body.lines() { let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('>') { @@ -1208,19 +1351,28 @@ pub(super) fn parse_ci_run_command(body: &str) -> Option { if !prefix.eq_ignore_ascii_case(PREFIX) { return None; } - if rest.is_empty() { - found = Some(CiRunCommand::Plain); - continue; - } if !rest.starts_with(|c: char| c.is_ascii_whitespace()) { return None; } - let arg = rest.trim(); - if arg.is_empty() { - found = Some(CiRunCommand::Plain); + let rest = rest.trim(); + let (verb, arg) = match rest.split_once(|c: char| c.is_ascii_whitespace()) { + Some((v, a)) => (v, a.trim()), + None => (rest, ""), + }; + if verb.eq_ignore_ascii_case("run") { + found = Some(GradientCommand::Run { + wildcard: (!arg.is_empty()).then(|| arg.to_string()), + }); + continue; + } + if verb.eq_ignore_ascii_case("approve") { + if !arg.is_empty() { + return None; + } + found = Some(GradientCommand::Approve); continue; } - found = Some(CiRunCommand::WithWildcard(arg.to_string())); + return None; } found } @@ -1271,7 +1423,8 @@ async fn active_project_ids_for_integration( mod tests { use super::super::WebhookTriggerOutcome; use super::{ - CiRunCommand, glob_match_pattern, glob_matches, normalize_repo_url, parse_ci_run_command, + GradientCommand, glob_match_pattern, glob_matches, normalize_repo_url, + parse_gradient_command, }; #[test] @@ -1320,80 +1473,113 @@ mod tests { } #[test] - fn parse_ci_run_command_plain_returns_plain() { - assert!(matches!( - parse_ci_run_command("/ci run"), - Some(CiRunCommand::Plain) - )); - assert!(matches!( - parse_ci_run_command(" /ci run "), - Some(CiRunCommand::Plain) - )); - assert!(matches!( - parse_ci_run_command("\n/ci run\n"), - Some(CiRunCommand::Plain) - )); + fn parse_gradient_run_bare_returns_run_without_wildcard() { + assert_eq!( + parse_gradient_command("/gradient run"), + Some(GradientCommand::Run { wildcard: None }) + ); + assert_eq!( + parse_gradient_command(" /gradient run "), + Some(GradientCommand::Run { wildcard: None }) + ); + assert_eq!( + parse_gradient_command("\n/gradient run\n"), + Some(GradientCommand::Run { wildcard: None }) + ); } #[test] - fn parse_ci_run_command_case_insensitive_prefix() { - assert!(matches!( - parse_ci_run_command("/CI Run"), - Some(CiRunCommand::Plain) - )); - assert!(matches!( - parse_ci_run_command("/Ci RUN packages.*.*"), - Some(CiRunCommand::WithWildcard(ref w)) if w == "packages.*.*" - )); + fn parse_gradient_run_is_case_insensitive() { + assert_eq!( + parse_gradient_command("/GRADIENT Run"), + Some(GradientCommand::Run { wildcard: None }) + ); + assert_eq!( + parse_gradient_command("/Gradient RUN packages.*.*"), + Some(GradientCommand::Run { + wildcard: Some("packages.*.*".to_string()) + }) + ); } #[test] - fn parse_ci_run_command_with_wildcard() { - assert!(matches!( - parse_ci_run_command("/ci run packages.*.*"), - Some(CiRunCommand::WithWildcard(ref w)) if w == "packages.*.*" - )); + fn parse_gradient_run_with_wildcard() { + assert_eq!( + parse_gradient_command("/gradient run packages.*.*"), + Some(GradientCommand::Run { + wildcard: Some("packages.*.*".to_string()) + }) + ); } #[test] - fn parse_ci_run_command_with_complex_wildcard() { - let body = "/ci run packages.*.foo,!packages.x86_64-linux.broken"; - let Some(CiRunCommand::WithWildcard(w)) = parse_ci_run_command(body) else { - panic!("expected WithWildcard"); + fn parse_gradient_run_with_complex_wildcard_preserves_raw() { + let body = "/gradient run packages.*.foo,!packages.x86_64-linux.broken"; + let Some(GradientCommand::Run { wildcard: Some(w) }) = parse_gradient_command(body) else { + panic!("expected Run with wildcard"); }; assert_eq!(w, "packages.*.foo,!packages.x86_64-linux.broken"); } #[test] - fn parse_ci_run_command_trims_trailing_whitespace_around_wildcard() { - let body = " /ci run packages.*.* "; - let Some(CiRunCommand::WithWildcard(w)) = parse_ci_run_command(body) else { - panic!("expected WithWildcard"); + fn parse_gradient_run_trims_whitespace_around_wildcard() { + let body = " /gradient run packages.*.* "; + let Some(GradientCommand::Run { wildcard: Some(w) }) = parse_gradient_command(body) else { + panic!("expected Run with wildcard"); }; assert_eq!(w, "packages.*.*"); } #[test] - fn parse_ci_run_command_after_quote_reply() { - let body = - "> @maintainer asked us to retrigger\n> after rebasing main\n\n/ci run packages.*.*"; - let Some(CiRunCommand::WithWildcard(w)) = parse_ci_run_command(body) else { - panic!("expected WithWildcard"); + fn parse_gradient_run_after_quote_reply() { + let body = "> @maintainer asked us to retrigger\n> after rebasing main\n\n/gradient run packages.*.*"; + let Some(GradientCommand::Run { wildcard: Some(w) }) = parse_gradient_command(body) else { + panic!("expected Run with wildcard"); }; assert_eq!(w, "packages.*.*"); } #[test] - fn parse_ci_run_command_rejects_unrelated() { - assert!(parse_ci_run_command("looks good").is_none()); - assert!(parse_ci_run_command("/ci runfoo").is_none()); - assert!(parse_ci_run_command("foo\n/ci run\nbar").is_none()); + fn parse_gradient_approve() { + assert_eq!( + parse_gradient_command("/gradient approve"), + Some(GradientCommand::Approve) + ); + assert_eq!( + parse_gradient_command(" /GRADIENT Approve "), + Some(GradientCommand::Approve) + ); + } + + #[test] + fn parse_gradient_approve_rejects_trailing_args() { + assert!(parse_gradient_command("/gradient approve packages.*").is_none()); + } + + #[test] + fn parse_gradient_rejects_unknown_subcommand() { + assert!(parse_gradient_command("/gradient yolo").is_none()); + assert!(parse_gradient_command("/gradient").is_none()); + } + + #[test] + fn parse_gradient_rejects_unrelated() { + assert!(parse_gradient_command("looks good").is_none()); + assert!(parse_gradient_command("/gradientrun").is_none()); + assert!(parse_gradient_command("foo\n/gradient run\nbar").is_none()); assert!( - parse_ci_run_command("quote-reply context\n\n/ci run").is_none(), - "non-quote prose before /ci run must reject" + parse_gradient_command("quote-reply context\n\n/gradient run").is_none(), + "non-quote prose before /gradient must reject" ); } + #[test] + fn parse_gradient_legacy_ci_prefix_rejected() { + // Hard rename: old /ci prefix no longer recognised. + assert!(parse_gradient_command("/ci run").is_none()); + assert!(parse_gradient_command("/ci approve").is_none()); + } + #[test] fn trigger_outcome_default_is_empty() { let o = WebhookTriggerOutcome::default(); diff --git a/docs/gradient-api.yaml b/docs/gradient-api.yaml index 79b30c60..2d51755b 100644 --- a/docs/gradient-api.yaml +++ b/docs/gradient-api.yaml @@ -6448,10 +6448,12 @@ components: - `approval`: the PR comes from a contributor who is not a forge repo writer, and the trigger has `require_approval = true`. The evaluation stays parked until a maintainer clicks "Approve and - run" on the GitHub Check or comments `/ci run` on - Gitea/Forgejo/GitLab. The command optionally accepts a wildcard - (`/ci run packages.x86_64-linux.foo`) that overrides the - project's wildcard for this one run only. + run" on the GitHub Check or comments `/gradient approve` (or + `/gradient run`) on the PR. `/gradient run` optionally accepts a + wildcard (`/gradient run packages.x86_64-linux.foo`) that + overrides the project's wildcard for this run only. With no + parked gate, `/gradient run` from a maintainer creates a fresh + evaluation for the PR head. - `no_cache`: the project's organisation has no writable cache subscription, so build outputs would have nowhere to land. The evaluation auto-unparks the moment a writable cache is @@ -7459,11 +7461,13 @@ components: When `true` (default), PRs from contributors who are not forge repo writers are parked in `Waiting + WaitingReason::Approval` until a maintainer clicks "Approve and run" on the GitHub Check - or comments `/ci run` on Gitea/Forgejo/GitLab. The comment - command optionally accepts a wildcard - (`/ci run packages.x86_64-linux.foo`) that overrides the - project's wildcard for this one run only. Set to `false` to - disable the gate and run every PR build automatically. + or comments `/gradient approve` (or `/gradient run`) on the PR. + `/gradient run` optionally accepts a wildcard + (`/gradient run packages.x86_64-linux.foo`) that overrides the + project's wildcard for this run only. With no parked gate, + `/gradient run` from a maintainer creates a fresh evaluation + for the PR head. Set this to `false` to disable the gate and + run every PR build automatically. TimeTriggerConfig: type: object diff --git a/docs/src/development/github-app-setup.md b/docs/src/development/github-app-setup.md index 33770a96..d337db6b 100644 --- a/docs/src/development/github-app-setup.md +++ b/docs/src/development/github-app-setup.md @@ -47,8 +47,9 @@ Match the values Gradient expects: The `issues: write` permission is what gates `issue_comment` delivery — GitHub routes every comment on a PR's main conversation tab through that event, so -without the permission Gradient never sees `/ci run` triggers posted there. -`write` is required because the `post_pr_comment` reporter uses the same +without the permission Gradient never sees `/gradient run` or +`/gradient approve` triggers posted there. `write` is required because the +`post_pr_comment` reporter uses the same `/repos/{owner}/{repo}/issues/{n}/comments` endpoint to reply with build status. diff --git a/docs/src/tests.md b/docs/src/tests.md index f4b681fe..e5575a29 100644 --- a/docs/src/tests.md +++ b/docs/src/tests.md @@ -2529,11 +2529,16 @@ immediately. Coverage: - `web/src/endpoints/forge_hooks/events.rs` - extraction of `pr_number`, `pr_author`, `is_fork`, `base_owner`, `base_repo` from GitHub / Gitea / GitLab payloads. -- `web/src/endpoints/forge_hooks/trigger.rs::parse_ci_run_command_*` - - recogniser for the `/ci run [wildcard]` comment unpark command - (case insensitive, allows leading quote-reply lines, rejects - multi-line prose, captures an optional trailing wildcard string - for one-shot overrides; issue #274). +- `web/src/endpoints/forge_hooks/trigger.rs::parse_gradient_*` - + recogniser for the `/gradient run [wildcard]` and `/gradient approve` + PR comments (case insensitive, allows leading quote-reply lines, rejects + multi-line prose and unknown subcommands, captures an optional trailing + wildcard string for one-shot overrides; legacy `/ci` prefix is rejected + by `parse_gradient_legacy_ci_prefix_rejected`). Both commands are + maintainer-only via `sender_is_trusted`; `/gradient run` falls through + to `trigger_pr_for_integration` with `manual=true` and the snapshot + fetched via `CiReporter::get_pull_request` when no parked approval gate + exists. - `core/src/ci/unpark.rs::unpark_approval_with_wildcard_*` (issue #274) - the new helper writes the maintainer-supplied wildcard into the same row update that flips `Waiting -> Queued`, diff --git a/frontend/src/app/core/models/trigger.model.ts b/frontend/src/app/core/models/trigger.model.ts index aa6485e3..88958009 100644 --- a/frontend/src/app/core/models/trigger.model.ts +++ b/frontend/src/app/core/models/trigger.model.ts @@ -41,7 +41,7 @@ export interface ReporterPullRequestTriggerConfig { actions?: string[]; /** When true (default), PRs from non-writer contributors are parked until * a maintainer approves them via the forge's check-run action (GitHub) - * or a `/ci run` comment (Gitea/Forgejo/GitLab). */ + * or a `/gradient approve` (or `/gradient run`) comment. */ require_approval?: boolean; } diff --git a/frontend/src/app/features/projects/project-triggers/project-triggers.component.html b/frontend/src/app/features/projects/project-triggers/project-triggers.component.html index 4eedb4e7..6fc9470b 100644 --- a/frontend/src/app/features/projects/project-triggers/project-triggers.component.html +++ b/frontend/src/app/features/projects/project-triggers/project-triggers.component.html @@ -267,8 +267,9 @@

No triggers configured

When enabled (secure-by-default), PRs from contributors who are not repo writers on the forge are parked until a maintainer clicks - “Approve and run” on the GitHub Check or comments /ci run - on Gitea/Forgejo/GitLab. Disable to run every PR build automatically. + “Approve and run” on the GitHub Check or comments + /gradient approve (or /gradient run) on the PR. + Disable to run every PR build automatically. } diff --git a/nix/modules/gradient-state.nix b/nix/modules/gradient-state.nix index 67ac2031..0d67837f 100644 --- a/nix/modules/gradient-state.nix +++ b/nix/modules/gradient-state.nix @@ -508,8 +508,8 @@ `require_approval` (PR triggers only, default `true`) parks evaluations for PRs from contributors who are not repo writers on the forge until a maintainer clicks "Approve and run" on the GitHub check or comments - `/ci run` on Gitea/Forgejo/GitLab. Set to `false` to disable the gate - and run every PR build automatically. + `/gradient approve` (or `/gradient run`) on the PR. Set to `false` to + disable the gate and run every PR build automatically. ''; example = literalExpression '' { interval_secs = 60; }