+ Original + + + {{ example.original.severity }} + + {{ example.original.issue_type }} + + + Key + {{ example.original.key }} + File + + {{ example.original.file_path }} + + +
+ + New state + + + + Hidden from reports + + + + + {{ example.effectiveSeverity }} + + {{ example.original.issue_type }} + + {{ example.summary }} + +
+ {{ rule.name }} @@ -257,6 +292,112 @@ {{ rule.rule }} + + + + Affected details ({{ rule.affected_details_count.toLocaleString() }}) + + + No details are affected in the current revision. + + + + + + + + {{ detail.project_name ?? detail.project_id }} + + Unattached trace + + · + + {{ detail.version_name ?? detail.version_number ?? detail.version_id }} + + + + + + {{ detail.severity ?? detail.original_severity }} + + + + Hidden + + {{ detail.issue_type }} + + + + {{ detail.jar }} + + + {{ detail.file_path }} + + + + + + View + + + + View + + + + + + + + + {{ expandedAffectedDetails.has(rule.id) ? 'Show less' : 'Show more' }} + + + + + + @@ -266,8 +407,10 @@ import { type Labrinth, SseParser } from '@modrinth/api-client' import { ArrowLeftIcon, + ChevronRightIcon, EditIcon, EyeOffIcon, + ExternalIcon, LoaderCircleIcon, PlayIcon, PlusIcon, @@ -275,6 +418,7 @@ import { } from '@modrinth/assets' import { ButtonStyled, + Avatar, ConfirmModal, EmptyState, injectModrinthClient, @@ -335,13 +479,20 @@ const isLoading = ref(true) const isSaving = ref(false) const isScanning = ref(false) const isTestingRule = ref(false) +const isLoadingRuleSchema = ref(false) const isRuleModalOpen = ref(false) const loadFailed = ref(false) +const ruleSchemaError = ref(null) +const ruleSchema = ref(null) const editingRuleId = ref(null) const ruleToDelete = ref(null) const ruleTestEffects = ref>([]) const ruleTestError = ref(null) const scanProgress = ref(null) +const expandedAffectedDetails = reactive( + new Map(), +) +const loadingAffectedRuleIds = reactive(new Set()) const form = reactive({ name: '', rule: DEFAULT_RULE, @@ -358,6 +509,12 @@ onMounted(async () => { }) const modalTitle = computed(() => (editingRuleId.value === null ? 'Create rule' : 'Edit rule')) +const ruleInputSchemaText = computed(() => + ruleSchema.value ? formatRuleSchema(ruleSchema.value.input, ruleSchema.value.components) : '', +) +const ruleOutputSchemaText = computed(() => + ruleSchema.value ? formatRuleSchema(ruleSchema.value.output, ruleSchema.value.components) : '', +) const previewExamples = computed(() => TEST_TRACES.map((original, index) => { const effect = ruleTestEffects.value[index] ?? null @@ -399,6 +556,92 @@ function getSeverityBadgeColor(severity: Labrinth.TechReview.Internal.DelphiSeve } } +function isSchema(value: unknown): value is Labrinth.TechReview.Internal.DelphiRuleSchema { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function formatRuleSchema( + schema: Labrinth.TechReview.Internal.DelphiRuleSchema, + components: Record, + depth = 0, + visitedReferences = new Set(), +): string { + if (typeof schema.$ref === 'string') { + const name = decodeURIComponent(schema.$ref.split('/').at(-1) ?? '') + if (visitedReferences.has(name)) return name || 'unknown' + + const referencedSchema = components[name] + if (!referencedSchema) return name || 'unknown' + + const visited = new Set(visitedReferences) + visited.add(name) + return formatRuleSchema(referencedSchema, components, depth, visited) + } + + const resolved = schema + const alternatives = [resolved.oneOf, resolved.anyOf].find(Array.isArray) + if (alternatives) { + return alternatives + .filter(isSchema) + .map((alternative) => formatRuleSchema(alternative, components, depth, visitedReferences)) + .join(' | ') + } + + if (Array.isArray(resolved.enum)) { + return resolved.enum.map((value) => JSON.stringify(value)).join(' | ') + } + + const declaredTypes = Array.isArray(resolved.type) + ? resolved.type.filter((type): type is string => typeof type === 'string') + : typeof resolved.type === 'string' + ? [resolved.type] + : [] + const nullable = resolved.nullable === true || declaredTypes.includes('null') + const type = declaredTypes.find((value) => value !== 'null') + let formatted: string + + if (type === 'object' || isSchema(resolved.properties) || resolved.additionalProperties) { + const properties = isSchema(resolved.properties) ? resolved.properties : {} + const required = new Set( + Array.isArray(resolved.required) + ? resolved.required.filter((name): name is string => typeof name === 'string') + : [], + ) + const indentation = ' '.repeat(depth) + const childIndentation = ' '.repeat(depth + 1) + const lines = Object.entries(properties) + .filter((entry): entry is [string, Labrinth.TechReview.Internal.DelphiRuleSchema] => + isSchema(entry[1]), + ) + .map( + ([name, property]) => + `${childIndentation}${JSON.stringify(name)}${required.has(name) ? '' : '?'}: ${formatRuleSchema(property, components, depth + 1, visitedReferences)}`, + ) + + if (isSchema(resolved.additionalProperties)) { + lines.push( + `${childIndentation}[key: string]: ${formatRuleSchema(resolved.additionalProperties, components, depth + 1, visitedReferences)}`, + ) + } else if (resolved.additionalProperties === true) { + lines.push(`${childIndentation}[key: string]: unknown`) + } + + formatted = lines.length === 0 ? '{}' : `{\n${lines.join(',\n')}\n${indentation}}` + } else if (type === 'array') { + formatted = isSchema(resolved.items) + ? `Array<${formatRuleSchema(resolved.items, components, depth, visitedReferences)}>` + : 'unknown[]' + } else if (type === 'integer' || type === 'number') { + formatted = 'number' + } else if (type === 'boolean' || type === 'string' || type === 'null') { + formatted = type + } else { + formatted = 'unknown' + } + + return nullable && formatted !== 'null' ? `${formatted} | null` : formatted +} + function onRuleEditorInit(editor: Ace.Editor) { ruleEditorInstance.value = editor editor.session.setUseWrapMode(true) @@ -460,6 +703,7 @@ async function loadRules() { loadFailed.value = false try { rules.value = await client.labrinth.tech_review_internal.getRules() + expandedAffectedDetails.clear() } catch (error) { console.error('Failed to load Delphi rules', error) loadFailed.value = true @@ -468,6 +712,64 @@ async function loadRules() { } } +async function loadRuleSchema() { + if (ruleSchema.value || isLoadingRuleSchema.value) return + + isLoadingRuleSchema.value = true + ruleSchemaError.value = null + try { + ruleSchema.value = await client.labrinth.tech_review_internal.getRuleSchema() + } catch (error) { + console.error('Failed to load Delphi rule schema', error) + ruleSchemaError.value = 'The rule input and output schema could not be loaded.' + } finally { + isLoadingRuleSchema.value = false + } +} + +function getVisibleRuleDetails( + rule: Labrinth.TechReview.Internal.DelphiRule, +): Labrinth.TechReview.Internal.DelphiRuleAffectedDetail[] { + return expandedAffectedDetails.get(rule.id) ?? rule.affected_details +} + +function getAffectedDetailLink( + detail: Labrinth.TechReview.Internal.DelphiRuleAffectedDetail, +): string { + return `/moderation/technical-review/${detail.project_id}?detail=${encodeURIComponent(detail.detail_id)}` +} + +function getProjectLink(detail: Labrinth.TechReview.Internal.DelphiRuleAffectedDetail): string { + return `/project/${detail.project_id}` +} + +function getVersionLink(detail: Labrinth.TechReview.Internal.DelphiRuleAffectedDetail): string { + return `/project/${detail.project_id}/version/${detail.version_id}` +} + +async function toggleAffectedDetails(rule: Labrinth.TechReview.Internal.DelphiRule) { + if (expandedAffectedDetails.has(rule.id)) { + expandedAffectedDetails.delete(rule.id) + return + } + if (loadingAffectedRuleIds.has(rule.id)) return + + loadingAffectedRuleIds.add(rule.id) + try { + const details = await client.labrinth.tech_review_internal.getRuleAffectedDetails(rule.id) + expandedAffectedDetails.set(rule.id, details) + } catch (error) { + console.error('Failed to load details affected by Delphi rule', error) + addNotification({ + type: 'error', + title: 'Failed to load affected details', + text: 'The complete list of affected details could not be loaded.', + }) + } finally { + loadingAffectedRuleIds.delete(rule.id) + } +} + function openCreateModal() { if (isScanning.value) return editingRuleId.value = null @@ -476,6 +778,7 @@ function openCreateModal() { isRuleModalOpen.value = true ruleModal.value?.show() nextTick(() => ruleEditorInstance.value?.resize(true)) + void loadRuleSchema() void testRule() } @@ -487,6 +790,7 @@ function openEditModal(rule: Labrinth.TechReview.Internal.DelphiRule) { isRuleModalOpen.value = true ruleModal.value?.show() nextTick(() => ruleEditorInstance.value?.resize(true)) + void loadRuleSchema() void testRule() } diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index d54c86c742..6dc3734377 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -117,9 +117,11 @@ pub fn config(cfg: &mut web::ServiceConfig) { moderation::tech_review::global::get_global_issue_detail, moderation::tech_review::rules::get_rules, moderation::tech_review::rules::test_rule, + moderation::tech_review::rules::get_rule_affected_details, moderation::tech_review::rules::create_rule, moderation::tech_review::rules::update_rule, moderation::tech_review::rules::delete_rule, + moderation::tech_review::rules_scan::get_rule_schema, moderation::tech_review::rules_scan::scan_rules, moderation::tech_review::get_project_report, moderation::tech_review::submit_report, diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs index 9a0a55d23c..00c2f4a91a 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs @@ -5,13 +5,23 @@ use chrono::{DateTime, Utc}; use eyre::eyre; use serde::{Deserialize, Serialize}; +use super::rules_scan::{ + RuleArtifact, RuleInput, RuleScan, RuleScope, RuleTrace, +}; use crate::{ auth::check_is_moderator_from_headers, database::{ - PgPool, ReadOnlyPgPool, models::delphi_report_item::DelphiSeverity, + PgPool, ReadOnlyPgPool, + models::{ + DBProjectId, DBVersionId, DelphiReportIssueDetailsId, + DelphiReportIssueId, delphi_report_item::DelphiSeverity, + }, redis::RedisPool, }, - models::pats::Scopes, + models::{ + ids::{ProjectId, VersionId}, + pats::Scopes, + }, queue::session::AuthQueue, routes::ApiError, util::error::Context, @@ -24,6 +34,7 @@ const MAX_RULE_TEST_TRACES: usize = 10; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(get_rules) .service(test_rule) + .service(get_rule_affected_details) .service(create_rule) .service(update_rule) .service(delete_rule); @@ -39,6 +50,27 @@ pub struct DelphiRule { pub updated_at: DateTime, pub created_by: Option, pub updated_by: Option, + pub affected_details_count: i64, + pub affected_details: Vec, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct DelphiRuleAffectedDetail { + pub detail_id: DelphiReportIssueDetailsId, + pub issue_id: DelphiReportIssueId, + pub project_id: Option, + pub project_name: Option, + pub project_icon_url: Option, + pub version_id: Option, + pub version_name: Option, + pub version_number: Option, + pub issue_type: String, + pub key: String, + pub jar: Option, + pub file_path: String, + pub original_severity: DelphiSeverity, + pub severity: Option, + pub hidden: bool, } #[derive(Debug, Deserialize, utoipa::ToSchema)] @@ -77,33 +109,6 @@ pub struct DelphiRuleEffect { pub hidden: bool, } -#[derive(Serialize)] -struct TestRuleInput<'a> { - schema_version: u32, - trace: &'a TestDelphiRuleTrace, - scan: TestRuleScan, - artifact: TestRuleArtifact, - scope: TestRuleScope, -} - -#[derive(Serialize)] -struct TestRuleScan { - delphi_version: i32, -} - -#[derive(Serialize)] -struct TestRuleArtifact { - size: u32, - hashes: BTreeMap, -} - -#[derive(Serialize)] -struct TestRuleScope { - project_id: String, - version_id: String, - file_id: String, -} - struct ValidatedRule { name: String, rule: String, @@ -203,22 +208,29 @@ pub async fn test_rule( Ok(web::Json(TestDelphiRuleResponse { effects })) } -fn test_rule_input(trace: &TestDelphiRuleTrace) -> TestRuleInput<'_> { - TestRuleInput { +fn test_rule_input(trace: &TestDelphiRuleTrace) -> RuleInput { + RuleInput { schema_version: 1, - trace, - scan: TestRuleScan { delphi_version: 17 }, - artifact: TestRuleArtifact { - size: 412_892, + trace: RuleTrace { + key: trace.key.clone(), + issue_type: trace.issue_type.clone(), + severity: trace.severity, + jar: trace.jar.clone(), + file_path: trace.file_path.clone(), + data: trace.data.clone(), + }, + scan: RuleScan { delphi_version: 17 }, + artifact: RuleArtifact { + size: Some(412_892), hashes: BTreeMap::from([ ("sha1".to_string(), "0123456789abcdef".to_string()), ("sha512".to_string(), "fedcba9876543210".to_string()), ]), }, - scope: TestRuleScope { - project_id: "example-project".to_string(), - version_id: "example-version".to_string(), - file_id: "example-file".to_string(), + scope: RuleScope { + project_id: Some("example-project".to_string()), + version_id: Some("example-version".to_string()), + file_id: Some("example-file".to_string()), }, } } @@ -250,27 +262,81 @@ pub async fn get_rules( let rules = sqlx::query!( r#" SELECT - id, - name, - rule, - revision, - created_at, - updated_at, - created_by, - updated_by - FROM delphi_rules - WHERE NOT delete_on_next_revision - ORDER BY id + delphi_rule.id, + delphi_rule.name, + delphi_rule.rule, + delphi_rule.revision, + delphi_rule.created_at, + delphi_rule.updated_at, + delphi_rule.created_by, + delphi_rule.updated_by, + COALESCE(preview.affected_details_count, 0) + AS "affected_details_count!", + preview.detail_id AS "detail_id?: DelphiReportIssueDetailsId", + preview.issue_id AS "issue_id?: DelphiReportIssueId", + preview.project_id AS "project_id?: DBProjectId", + preview.project_name AS "project_name?", + preview.project_icon_url AS "project_icon_url?", + preview.version_id AS "version_id?: DBVersionId", + preview.version_name AS "version_name?", + preview.version_number AS "version_number?", + preview.issue_type AS "issue_type?", + preview.key AS "key?", + preview.jar AS "jar?", + preview.file_path AS "file_path?", + preview.original_severity AS "original_severity?: DelphiSeverity", + preview.severity AS "effect_severity?: DelphiSeverity", + preview.hidden AS "hidden?" + FROM delphi_rules delphi_rule + LEFT JOIN LATERAL ( + SELECT + effect.detail_id, + detail.issue_id, + version.mod_id AS project_id, + project.name AS project_name, + project.icon_url AS project_icon_url, + version.id AS version_id, + version.name AS version_name, + version.version_number, + issue.issue_type, + detail.key, + detail.jar, + detail.file_path, + detail.severity AS original_severity, + effect.severity, + effect.hidden, + COUNT(*) OVER () AS affected_details_count + FROM delphi_rule_effects effect + INNER JOIN delphi_rule_revisions published + ON published.revision = effect.revision + INNER JOIN delphi_report_issue_details detail + ON detail.id = effect.detail_id + INNER JOIN delphi_report_issues issue + ON issue.id = detail.issue_id + INNER JOIN delphi_reports report + ON report.id = issue.report_id + LEFT JOIN files file ON file.id = report.file_id + LEFT JOIN versions version ON version.id = file.version_id + LEFT JOIN mods project ON project.id = version.mod_id + WHERE effect.rule_id = delphi_rule.id + ORDER BY effect.detail_id DESC + LIMIT 3 + ) preview ON TRUE + WHERE NOT delphi_rule.delete_on_next_revision + ORDER BY delphi_rule.id, preview.detail_id DESC "#, ) .fetch_all(&***ro_pool) .await .wrap_internal_err("failed to fetch delphi rules")?; - Ok(web::Json( - rules - .into_iter() - .map(|rule| DelphiRule { + let mut response = Vec::::new(); + for rule in rules { + if response + .last() + .is_none_or(|existing| existing.id != rule.id) + { + response.push(DelphiRule { id: rule.id, name: rule.name, rule: rule.rule, @@ -279,6 +345,137 @@ pub async fn get_rules( updated_at: rule.updated_at, created_by: rule.created_by, updated_by: rule.updated_by, + affected_details_count: rule.affected_details_count, + affected_details: Vec::new(), + }); + } + + if let ( + Some(detail_id), + Some(issue_id), + Some(issue_type), + Some(key), + Some(file_path), + Some(original_severity), + Some(hidden), + ) = ( + rule.detail_id, + rule.issue_id, + rule.issue_type, + rule.key, + rule.file_path, + rule.original_severity, + rule.hidden, + ) { + response + .last_mut() + .expect("a delphi rule was inserted above") + .affected_details + .push(DelphiRuleAffectedDetail { + detail_id, + issue_id, + project_id: rule.project_id.map(ProjectId::from), + project_name: rule.project_name, + project_icon_url: rule.project_icon_url, + version_id: rule.version_id.map(VersionId::from), + version_name: rule.version_name, + version_number: rule.version_number, + issue_type, + key, + jar: rule.jar, + file_path, + original_severity, + severity: rule.effect_severity, + hidden, + }); + } + } + + Ok(web::Json(response)) +} + +/// List all details affected by a Delphi rule in the published revision. +#[utoipa::path( + context_path = "/moderation/tech-review", + tag = "moderation", + security(("bearer_auth" = [])), + responses((status = OK, body = Vec)) +)] +#[get("/rules/{id}/effects")] +pub async fn get_rule_affected_details( + req: HttpRequest, + pool: web::Data, + ro_pool: web::Data, + redis: web::Data, + session_queue: web::Data, + path: web::Path<(i64,)>, +) -> Result>, ApiError> { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ, + ) + .await?; + let (rule_id,) = path.into_inner(); + + let details = sqlx::query!( + r#" + SELECT + effect.detail_id AS "detail_id!: DelphiReportIssueDetailsId", + detail.issue_id AS "issue_id!: DelphiReportIssueId", + version.mod_id AS "project_id?: DBProjectId", + project.name AS "project_name?", + project.icon_url AS "project_icon_url?", + version.id AS "version_id?: DBVersionId", + version.name AS "version_name?", + version.version_number AS "version_number?", + issue.issue_type, + detail.key, + detail.jar, + detail.file_path, + detail.severity AS "original_severity!: DelphiSeverity", + effect.severity AS "effect_severity: DelphiSeverity", + effect.hidden + FROM delphi_rule_effects effect + INNER JOIN delphi_rule_revisions published + ON published.revision = effect.revision + INNER JOIN delphi_report_issue_details detail + ON detail.id = effect.detail_id + INNER JOIN delphi_report_issues issue ON issue.id = detail.issue_id + INNER JOIN delphi_reports report ON report.id = issue.report_id + LEFT JOIN files file ON file.id = report.file_id + LEFT JOIN versions version ON version.id = file.version_id + LEFT JOIN mods project ON project.id = version.mod_id + WHERE effect.rule_id = $1 + ORDER BY effect.detail_id DESC + "#, + rule_id, + ) + .fetch_all(&***ro_pool) + .await + .wrap_internal_err("failed to fetch details affected by delphi rule")?; + + Ok(web::Json( + details + .into_iter() + .map(|detail| DelphiRuleAffectedDetail { + detail_id: detail.detail_id, + issue_id: detail.issue_id, + project_id: detail.project_id.map(ProjectId::from), + project_name: detail.project_name, + project_icon_url: detail.project_icon_url, + version_id: detail.version_id.map(VersionId::from), + version_name: detail.version_name, + version_number: detail.version_number, + issue_type: detail.issue_type, + key: detail.key, + jar: detail.jar, + file_path: detail.file_path, + original_severity: detail.original_severity, + severity: detail.effect_severity, + hidden: detail.hidden, }) .collect(), )) @@ -354,6 +551,8 @@ pub async fn create_rule( updated_at: rule.updated_at, created_by: rule.created_by, updated_by: rule.updated_by, + affected_details_count: 0, + affected_details: Vec::new(), })) } @@ -427,6 +626,8 @@ pub async fn update_rule( updated_at: rule.updated_at, created_by: rule.created_by, updated_by: rule.updated_by, + affected_details_count: 0, + affected_details: Vec::new(), })) } diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs index 4ee7f4c595..e6895558ac 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, HashMap}; -use actix_web::{HttpRequest, HttpResponse, post, web}; +use actix_web::{HttpRequest, HttpResponse, get, post, web}; use ariadne::ids::base62_impl::to_base62; use bytes::Bytes; use eyre::{Context as _, Result, eyre}; @@ -9,6 +9,7 @@ use serde::Serialize; use sqlx::types::Json; use tokio::sync::mpsc; use tokio_stream::wrappers::UnboundedReceiverStream; +use utoipa::{PartialSchema, ToSchema}; use super::rules::DelphiRuleEffect; use crate::{ @@ -26,7 +27,7 @@ const RULE_SCAN_LOCK_ID: i64 = 0x6465_6c70_6869_7275; const PROGRESS_INTERVAL: usize = 50; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { - cfg.service(scan_rules); + cfg.service(get_rule_schema).service(scan_rules); } #[derive(Serialize)] @@ -43,41 +44,41 @@ struct RuleScanErrorEvent<'a> { message: &'a str, } -#[derive(Serialize)] -struct RuleInput { - schema_version: u32, - trace: RuleTrace, - scan: RuleScan, - artifact: RuleArtifact, - scope: RuleScope, +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct RuleInput { + pub(super) schema_version: u32, + pub(super) trace: RuleTrace, + pub(super) scan: RuleScan, + pub(super) artifact: RuleArtifact, + pub(super) scope: RuleScope, } -#[derive(Serialize)] -struct RuleTrace { - key: String, - issue_type: String, - severity: DelphiSeverity, - jar: Option, - file_path: String, - data: HashMap, +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct RuleTrace { + pub(super) key: String, + pub(super) issue_type: String, + pub(super) severity: DelphiSeverity, + pub(super) jar: Option, + pub(super) file_path: String, + pub(super) data: HashMap, } -#[derive(Serialize)] -struct RuleScan { - delphi_version: i32, +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct RuleScan { + pub(super) delphi_version: i32, } -#[derive(Serialize)] -struct RuleArtifact { - size: Option, - hashes: BTreeMap, +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct RuleArtifact { + pub(super) size: Option, + pub(super) hashes: BTreeMap, } -#[derive(Serialize)] -struct RuleScope { - project_id: Option, - version_id: Option, - file_id: Option, +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct RuleScope { + pub(super) project_id: Option, + pub(super) version_id: Option, + pub(super) file_id: Option, } struct CompiledRule { @@ -98,6 +99,62 @@ struct ScanSummary { effects: usize, } +#[derive(Serialize, utoipa::ToSchema)] +pub struct DelphiRuleSchemaResponse { + pub input: serde_json::Value, + pub output: serde_json::Value, + pub components: BTreeMap, +} + +/// Get the schemas for the CEL input and output values. +#[utoipa::path( + context_path = "/moderation/tech-review", + tag = "moderation", + security(("bearer_auth" = [])), + responses((status = OK, body = DelphiRuleSchemaResponse)) +)] +#[get("/rules/schema")] +pub async fn get_rule_schema( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result, ApiError> { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ, + ) + .await?; + + let mut schemas = Vec::new(); + ::schemas(&mut schemas); + as ToSchema>::schemas(&mut schemas); + + Ok(web::Json(DelphiRuleSchemaResponse { + input: schema_to_value(::schema())?, + output: schema_to_value( + as PartialSchema>::schema(), + )?, + components: schemas + .into_iter() + .map(|(name, schema)| Ok((name, schema_to_value(schema)?))) + .collect::>()?, + })) +} + +fn schema_to_value( + schema: T, +) -> Result { + serde_json::to_value(schema).map_err(|error| { + ApiError::Internal( + eyre!(error).wrap_err("failed to serialize Delphi rule schema"), + ) + }) +} + /// Re-evaluate every Delphi issue detail and atomically publish a new rule revision. #[utoipa::path( context_path = "/moderation/tech-review", @@ -316,12 +373,13 @@ async fn run_scan( }; for rule in &rules { - let effect = evaluate_rule(&rule.program, &input).wrap_err_with(|| { - format!( - "failed to evaluate delphi rule {} for detail {detail_id}", - rule.id - ) - })?; + let effect = evaluate_rule(&rule.program, &input) + .wrap_err_with(|| { + format!( + "failed to evaluate delphi rule {} for detail {detail_id}", + rule.id + ) + })?; if let Some(effect) = effect { effects.push(MaterializedEffect { detail_id, diff --git a/packages/api-client/src/modules/labrinth/tech-review/internal.ts b/packages/api-client/src/modules/labrinth/tech-review/internal.ts index c66520baa8..5cf7e278fb 100644 --- a/packages/api-client/src/modules/labrinth/tech-review/internal.ts +++ b/packages/api-client/src/modules/labrinth/tech-review/internal.ts @@ -17,6 +17,30 @@ export class LabrinthTechReviewInternalModule extends AbstractModule { ) } + public async getRuleSchema(): Promise { + return this.client.request( + '/moderation/tech-review/rules/schema', + { + api: 'labrinth', + version: 'internal', + method: 'GET', + }, + ) + } + + public async getRuleAffectedDetails( + id: number, + ): Promise { + return this.client.request( + `/moderation/tech-review/rules/${id}/effects`, + { + api: 'labrinth', + version: 'internal', + method: 'GET', + }, + ) + } + public async testRule( request: Labrinth.TechReview.Internal.TestDelphiRuleRequest, ): Promise { diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 9d97f1ec5a..8f9e55939d 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -2235,6 +2235,26 @@ export namespace Labrinth { updated_at: string created_by: number | null updated_by: number | null + affected_details_count: number + affected_details: DelphiRuleAffectedDetail[] + } + + export type DelphiRuleAffectedDetail = { + detail_id: string + issue_id: string + project_id: string | null + project_name: string | null + project_icon_url: string | null + version_id: string | null + version_name: string | null + version_number: string | null + issue_type: string + key: string + jar: string | null + file_path: string + original_severity: DelphiSeverity + severity: DelphiSeverity | null + hidden: boolean } export type WriteDelphiRule = { @@ -2261,6 +2281,14 @@ export namespace Labrinth { hidden: boolean } + export type DelphiRuleSchema = Record + + export type DelphiRuleSchemaResponse = { + input: DelphiRuleSchema + output: DelphiRuleSchema + components: Record + } + export type TestDelphiRuleResponse = { effects: Array } From 4b07ed720ed92aae253447d75a52546bc97cba34 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:12:37 +0100 Subject: [PATCH 06/21] remove old ui --- .../moderation/technical-review/rules.vue | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/apps/frontend/src/pages/moderation/technical-review/rules.vue b/apps/frontend/src/pages/moderation/technical-review/rules.vue index 6cf23d476a..254415f84d 100644 --- a/apps/frontend/src/pages/moderation/technical-review/rules.vue +++ b/apps/frontend/src/pages/moderation/technical-review/rules.vue @@ -226,28 +226,6 @@ /> - - CEL contract and input - - - Effects are maps such as - { "severity": "low", "hidden": false }. Severity can be low, - medium, high, or severe. - - - Rules run in the order shown below. The first rule that returns a non-null effect wins. - - - The input object contains schema_version, - trace (key, issue_type, severity, - jar, file_path, data), - scan.delphi_version, artifact (size, - hashes), and stable IDs under scope (project_id, - version_id, file_id). - - - - From 8d82c228cf032752a90220b9f6db4a82bcf54fdc Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:49:20 +0100 Subject: [PATCH 07/21] rules affect tech review detail statuses --- .../moderation/GlobalDetailLocalTraceCard.vue | 22 +- .../ui/moderation/GlobalDetailTracesList.vue | 11 +- .../ui/moderation/IssueDetailPath.vue | 74 +++ .../ui/moderation/ModerationTechRevCard.vue | 82 ++- .../pages/moderation/global-traces/[key].vue | 11 +- .../moderation/technical-review/rules.vue | 13 +- ...23120000_delphi_effective_rule_details.sql | 37 ++ .../src/routes/internal/delphi/mod.rs | 11 +- .../src/routes/internal/delphi/rescan.rs | 2 + .../internal/delphi/tech_review_sync.rs | 111 ++-- apps/labrinth/src/routes/internal/mod.rs | 1 + .../src/routes/internal/moderation/mod.rs | 4 + .../routes/internal/moderation/tech_review.rs | 54 +- .../internal/moderation/tech_review/global.rs | 4 + .../moderation/tech_review/rules_scan.rs | 484 ++++++++++++++---- .../modules/labrinth/tech-review/internal.ts | 13 + .../api-client/src/modules/labrinth/types.ts | 24 + 17 files changed, 754 insertions(+), 204 deletions(-) create mode 100644 apps/frontend/src/components/ui/moderation/IssueDetailPath.vue create mode 100644 apps/labrinth/migrations/20260723120000_delphi_effective_rule_details.sql diff --git a/apps/frontend/src/components/ui/moderation/GlobalDetailLocalTraceCard.vue b/apps/frontend/src/components/ui/moderation/GlobalDetailLocalTraceCard.vue index a6756ce443..8329b64b33 100644 --- a/apps/frontend/src/components/ui/moderation/GlobalDetailLocalTraceCard.vue +++ b/apps/frontend/src/components/ui/moderation/GlobalDetailLocalTraceCard.vue @@ -5,14 +5,8 @@ {{ trace.project_name }} - - {{ trace.version_number }} - - {{ decodeTracePath(trace.file_name) }} - - - {{ decodeTracePath(trace.jar) }} - + +
{{ rule.name }} @@ -257,6 +292,112 @@ {{ rule.rule }} + + + + Affected details ({{ rule.affected_details_count.toLocaleString() }}) + + + No details are affected in the current revision. + + + + + + + + {{ detail.project_name ?? detail.project_id }} + + Unattached trace + + · + + {{ detail.version_name ?? detail.version_number ?? detail.version_id }} + + + + + + {{ detail.severity ?? detail.original_severity }} + + + + Hidden + + {{ detail.issue_type }} + + + + {{ detail.jar }} + + + {{ detail.file_path }} + + + + + + View + + + + View + + + + + + + + + {{ expandedAffectedDetails.has(rule.id) ? 'Show less' : 'Show more' }} + + + + + + @@ -266,8 +407,10 @@ import { type Labrinth, SseParser } from '@modrinth/api-client' import { ArrowLeftIcon, + ChevronRightIcon, EditIcon, EyeOffIcon, + ExternalIcon, LoaderCircleIcon, PlayIcon, PlusIcon, @@ -275,6 +418,7 @@ import { } from '@modrinth/assets' import { ButtonStyled, + Avatar, ConfirmModal, EmptyState, injectModrinthClient, @@ -335,13 +479,20 @@ const isLoading = ref(true) const isSaving = ref(false) const isScanning = ref(false) const isTestingRule = ref(false) +const isLoadingRuleSchema = ref(false) const isRuleModalOpen = ref(false) const loadFailed = ref(false) +const ruleSchemaError = ref(null) +const ruleSchema = ref(null) const editingRuleId = ref(null) const ruleToDelete = ref(null) const ruleTestEffects = ref>([]) const ruleTestError = ref(null) const scanProgress = ref(null) +const expandedAffectedDetails = reactive( + new Map(), +) +const loadingAffectedRuleIds = reactive(new Set()) const form = reactive({ name: '', rule: DEFAULT_RULE, @@ -358,6 +509,12 @@ onMounted(async () => { }) const modalTitle = computed(() => (editingRuleId.value === null ? 'Create rule' : 'Edit rule')) +const ruleInputSchemaText = computed(() => + ruleSchema.value ? formatRuleSchema(ruleSchema.value.input, ruleSchema.value.components) : '', +) +const ruleOutputSchemaText = computed(() => + ruleSchema.value ? formatRuleSchema(ruleSchema.value.output, ruleSchema.value.components) : '', +) const previewExamples = computed(() => TEST_TRACES.map((original, index) => { const effect = ruleTestEffects.value[index] ?? null @@ -399,6 +556,92 @@ function getSeverityBadgeColor(severity: Labrinth.TechReview.Internal.DelphiSeve } } +function isSchema(value: unknown): value is Labrinth.TechReview.Internal.DelphiRuleSchema { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function formatRuleSchema( + schema: Labrinth.TechReview.Internal.DelphiRuleSchema, + components: Record, + depth = 0, + visitedReferences = new Set(), +): string { + if (typeof schema.$ref === 'string') { + const name = decodeURIComponent(schema.$ref.split('/').at(-1) ?? '') + if (visitedReferences.has(name)) return name || 'unknown' + + const referencedSchema = components[name] + if (!referencedSchema) return name || 'unknown' + + const visited = new Set(visitedReferences) + visited.add(name) + return formatRuleSchema(referencedSchema, components, depth, visited) + } + + const resolved = schema + const alternatives = [resolved.oneOf, resolved.anyOf].find(Array.isArray) + if (alternatives) { + return alternatives + .filter(isSchema) + .map((alternative) => formatRuleSchema(alternative, components, depth, visitedReferences)) + .join(' | ') + } + + if (Array.isArray(resolved.enum)) { + return resolved.enum.map((value) => JSON.stringify(value)).join(' | ') + } + + const declaredTypes = Array.isArray(resolved.type) + ? resolved.type.filter((type): type is string => typeof type === 'string') + : typeof resolved.type === 'string' + ? [resolved.type] + : [] + const nullable = resolved.nullable === true || declaredTypes.includes('null') + const type = declaredTypes.find((value) => value !== 'null') + let formatted: string + + if (type === 'object' || isSchema(resolved.properties) || resolved.additionalProperties) { + const properties = isSchema(resolved.properties) ? resolved.properties : {} + const required = new Set( + Array.isArray(resolved.required) + ? resolved.required.filter((name): name is string => typeof name === 'string') + : [], + ) + const indentation = ' '.repeat(depth) + const childIndentation = ' '.repeat(depth + 1) + const lines = Object.entries(properties) + .filter((entry): entry is [string, Labrinth.TechReview.Internal.DelphiRuleSchema] => + isSchema(entry[1]), + ) + .map( + ([name, property]) => + `${childIndentation}${JSON.stringify(name)}${required.has(name) ? '' : '?'}: ${formatRuleSchema(property, components, depth + 1, visitedReferences)}`, + ) + + if (isSchema(resolved.additionalProperties)) { + lines.push( + `${childIndentation}[key: string]: ${formatRuleSchema(resolved.additionalProperties, components, depth + 1, visitedReferences)}`, + ) + } else if (resolved.additionalProperties === true) { + lines.push(`${childIndentation}[key: string]: unknown`) + } + + formatted = lines.length === 0 ? '{}' : `{\n${lines.join(',\n')}\n${indentation}}` + } else if (type === 'array') { + formatted = isSchema(resolved.items) + ? `Array<${formatRuleSchema(resolved.items, components, depth, visitedReferences)}>` + : 'unknown[]' + } else if (type === 'integer' || type === 'number') { + formatted = 'number' + } else if (type === 'boolean' || type === 'string' || type === 'null') { + formatted = type + } else { + formatted = 'unknown' + } + + return nullable && formatted !== 'null' ? `${formatted} | null` : formatted +} + function onRuleEditorInit(editor: Ace.Editor) { ruleEditorInstance.value = editor editor.session.setUseWrapMode(true) @@ -460,6 +703,7 @@ async function loadRules() { loadFailed.value = false try { rules.value = await client.labrinth.tech_review_internal.getRules() + expandedAffectedDetails.clear() } catch (error) { console.error('Failed to load Delphi rules', error) loadFailed.value = true @@ -468,6 +712,64 @@ async function loadRules() { } } +async function loadRuleSchema() { + if (ruleSchema.value || isLoadingRuleSchema.value) return + + isLoadingRuleSchema.value = true + ruleSchemaError.value = null + try { + ruleSchema.value = await client.labrinth.tech_review_internal.getRuleSchema() + } catch (error) { + console.error('Failed to load Delphi rule schema', error) + ruleSchemaError.value = 'The rule input and output schema could not be loaded.' + } finally { + isLoadingRuleSchema.value = false + } +} + +function getVisibleRuleDetails( + rule: Labrinth.TechReview.Internal.DelphiRule, +): Labrinth.TechReview.Internal.DelphiRuleAffectedDetail[] { + return expandedAffectedDetails.get(rule.id) ?? rule.affected_details +} + +function getAffectedDetailLink( + detail: Labrinth.TechReview.Internal.DelphiRuleAffectedDetail, +): string { + return `/moderation/technical-review/${detail.project_id}?detail=${encodeURIComponent(detail.detail_id)}` +} + +function getProjectLink(detail: Labrinth.TechReview.Internal.DelphiRuleAffectedDetail): string { + return `/project/${detail.project_id}` +} + +function getVersionLink(detail: Labrinth.TechReview.Internal.DelphiRuleAffectedDetail): string { + return `/project/${detail.project_id}/version/${detail.version_id}` +} + +async function toggleAffectedDetails(rule: Labrinth.TechReview.Internal.DelphiRule) { + if (expandedAffectedDetails.has(rule.id)) { + expandedAffectedDetails.delete(rule.id) + return + } + if (loadingAffectedRuleIds.has(rule.id)) return + + loadingAffectedRuleIds.add(rule.id) + try { + const details = await client.labrinth.tech_review_internal.getRuleAffectedDetails(rule.id) + expandedAffectedDetails.set(rule.id, details) + } catch (error) { + console.error('Failed to load details affected by Delphi rule', error) + addNotification({ + type: 'error', + title: 'Failed to load affected details', + text: 'The complete list of affected details could not be loaded.', + }) + } finally { + loadingAffectedRuleIds.delete(rule.id) + } +} + function openCreateModal() { if (isScanning.value) return editingRuleId.value = null @@ -476,6 +778,7 @@ function openCreateModal() { isRuleModalOpen.value = true ruleModal.value?.show() nextTick(() => ruleEditorInstance.value?.resize(true)) + void loadRuleSchema() void testRule() } @@ -487,6 +790,7 @@ function openEditModal(rule: Labrinth.TechReview.Internal.DelphiRule) { isRuleModalOpen.value = true ruleModal.value?.show() nextTick(() => ruleEditorInstance.value?.resize(true)) + void loadRuleSchema() void testRule() } diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index d54c86c742..6dc3734377 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -117,9 +117,11 @@ pub fn config(cfg: &mut web::ServiceConfig) { moderation::tech_review::global::get_global_issue_detail, moderation::tech_review::rules::get_rules, moderation::tech_review::rules::test_rule, + moderation::tech_review::rules::get_rule_affected_details, moderation::tech_review::rules::create_rule, moderation::tech_review::rules::update_rule, moderation::tech_review::rules::delete_rule, + moderation::tech_review::rules_scan::get_rule_schema, moderation::tech_review::rules_scan::scan_rules, moderation::tech_review::get_project_report, moderation::tech_review::submit_report, diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs index 9a0a55d23c..00c2f4a91a 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs @@ -5,13 +5,23 @@ use chrono::{DateTime, Utc}; use eyre::eyre; use serde::{Deserialize, Serialize}; +use super::rules_scan::{ + RuleArtifact, RuleInput, RuleScan, RuleScope, RuleTrace, +}; use crate::{ auth::check_is_moderator_from_headers, database::{ - PgPool, ReadOnlyPgPool, models::delphi_report_item::DelphiSeverity, + PgPool, ReadOnlyPgPool, + models::{ + DBProjectId, DBVersionId, DelphiReportIssueDetailsId, + DelphiReportIssueId, delphi_report_item::DelphiSeverity, + }, redis::RedisPool, }, - models::pats::Scopes, + models::{ + ids::{ProjectId, VersionId}, + pats::Scopes, + }, queue::session::AuthQueue, routes::ApiError, util::error::Context, @@ -24,6 +34,7 @@ const MAX_RULE_TEST_TRACES: usize = 10; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(get_rules) .service(test_rule) + .service(get_rule_affected_details) .service(create_rule) .service(update_rule) .service(delete_rule); @@ -39,6 +50,27 @@ pub struct DelphiRule { pub updated_at: DateTime, pub created_by: Option, pub updated_by: Option, + pub affected_details_count: i64, + pub affected_details: Vec, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct DelphiRuleAffectedDetail { + pub detail_id: DelphiReportIssueDetailsId, + pub issue_id: DelphiReportIssueId, + pub project_id: Option, + pub project_name: Option, + pub project_icon_url: Option, + pub version_id: Option, + pub version_name: Option, + pub version_number: Option, + pub issue_type: String, + pub key: String, + pub jar: Option, + pub file_path: String, + pub original_severity: DelphiSeverity, + pub severity: Option, + pub hidden: bool, } #[derive(Debug, Deserialize, utoipa::ToSchema)] @@ -77,33 +109,6 @@ pub struct DelphiRuleEffect { pub hidden: bool, } -#[derive(Serialize)] -struct TestRuleInput<'a> { - schema_version: u32, - trace: &'a TestDelphiRuleTrace, - scan: TestRuleScan, - artifact: TestRuleArtifact, - scope: TestRuleScope, -} - -#[derive(Serialize)] -struct TestRuleScan { - delphi_version: i32, -} - -#[derive(Serialize)] -struct TestRuleArtifact { - size: u32, - hashes: BTreeMap, -} - -#[derive(Serialize)] -struct TestRuleScope { - project_id: String, - version_id: String, - file_id: String, -} - struct ValidatedRule { name: String, rule: String, @@ -203,22 +208,29 @@ pub async fn test_rule( Ok(web::Json(TestDelphiRuleResponse { effects })) } -fn test_rule_input(trace: &TestDelphiRuleTrace) -> TestRuleInput<'_> { - TestRuleInput { +fn test_rule_input(trace: &TestDelphiRuleTrace) -> RuleInput { + RuleInput { schema_version: 1, - trace, - scan: TestRuleScan { delphi_version: 17 }, - artifact: TestRuleArtifact { - size: 412_892, + trace: RuleTrace { + key: trace.key.clone(), + issue_type: trace.issue_type.clone(), + severity: trace.severity, + jar: trace.jar.clone(), + file_path: trace.file_path.clone(), + data: trace.data.clone(), + }, + scan: RuleScan { delphi_version: 17 }, + artifact: RuleArtifact { + size: Some(412_892), hashes: BTreeMap::from([ ("sha1".to_string(), "0123456789abcdef".to_string()), ("sha512".to_string(), "fedcba9876543210".to_string()), ]), }, - scope: TestRuleScope { - project_id: "example-project".to_string(), - version_id: "example-version".to_string(), - file_id: "example-file".to_string(), + scope: RuleScope { + project_id: Some("example-project".to_string()), + version_id: Some("example-version".to_string()), + file_id: Some("example-file".to_string()), }, } } @@ -250,27 +262,81 @@ pub async fn get_rules( let rules = sqlx::query!( r#" SELECT - id, - name, - rule, - revision, - created_at, - updated_at, - created_by, - updated_by - FROM delphi_rules - WHERE NOT delete_on_next_revision - ORDER BY id + delphi_rule.id, + delphi_rule.name, + delphi_rule.rule, + delphi_rule.revision, + delphi_rule.created_at, + delphi_rule.updated_at, + delphi_rule.created_by, + delphi_rule.updated_by, + COALESCE(preview.affected_details_count, 0) + AS "affected_details_count!", + preview.detail_id AS "detail_id?: DelphiReportIssueDetailsId", + preview.issue_id AS "issue_id?: DelphiReportIssueId", + preview.project_id AS "project_id?: DBProjectId", + preview.project_name AS "project_name?", + preview.project_icon_url AS "project_icon_url?", + preview.version_id AS "version_id?: DBVersionId", + preview.version_name AS "version_name?", + preview.version_number AS "version_number?", + preview.issue_type AS "issue_type?", + preview.key AS "key?", + preview.jar AS "jar?", + preview.file_path AS "file_path?", + preview.original_severity AS "original_severity?: DelphiSeverity", + preview.severity AS "effect_severity?: DelphiSeverity", + preview.hidden AS "hidden?" + FROM delphi_rules delphi_rule + LEFT JOIN LATERAL ( + SELECT + effect.detail_id, + detail.issue_id, + version.mod_id AS project_id, + project.name AS project_name, + project.icon_url AS project_icon_url, + version.id AS version_id, + version.name AS version_name, + version.version_number, + issue.issue_type, + detail.key, + detail.jar, + detail.file_path, + detail.severity AS original_severity, + effect.severity, + effect.hidden, + COUNT(*) OVER () AS affected_details_count + FROM delphi_rule_effects effect + INNER JOIN delphi_rule_revisions published + ON published.revision = effect.revision + INNER JOIN delphi_report_issue_details detail + ON detail.id = effect.detail_id + INNER JOIN delphi_report_issues issue + ON issue.id = detail.issue_id + INNER JOIN delphi_reports report + ON report.id = issue.report_id + LEFT JOIN files file ON file.id = report.file_id + LEFT JOIN versions version ON version.id = file.version_id + LEFT JOIN mods project ON project.id = version.mod_id + WHERE effect.rule_id = delphi_rule.id + ORDER BY effect.detail_id DESC + LIMIT 3 + ) preview ON TRUE + WHERE NOT delphi_rule.delete_on_next_revision + ORDER BY delphi_rule.id, preview.detail_id DESC "#, ) .fetch_all(&***ro_pool) .await .wrap_internal_err("failed to fetch delphi rules")?; - Ok(web::Json( - rules - .into_iter() - .map(|rule| DelphiRule { + let mut response = Vec::::new(); + for rule in rules { + if response + .last() + .is_none_or(|existing| existing.id != rule.id) + { + response.push(DelphiRule { id: rule.id, name: rule.name, rule: rule.rule, @@ -279,6 +345,137 @@ pub async fn get_rules( updated_at: rule.updated_at, created_by: rule.created_by, updated_by: rule.updated_by, + affected_details_count: rule.affected_details_count, + affected_details: Vec::new(), + }); + } + + if let ( + Some(detail_id), + Some(issue_id), + Some(issue_type), + Some(key), + Some(file_path), + Some(original_severity), + Some(hidden), + ) = ( + rule.detail_id, + rule.issue_id, + rule.issue_type, + rule.key, + rule.file_path, + rule.original_severity, + rule.hidden, + ) { + response + .last_mut() + .expect("a delphi rule was inserted above") + .affected_details + .push(DelphiRuleAffectedDetail { + detail_id, + issue_id, + project_id: rule.project_id.map(ProjectId::from), + project_name: rule.project_name, + project_icon_url: rule.project_icon_url, + version_id: rule.version_id.map(VersionId::from), + version_name: rule.version_name, + version_number: rule.version_number, + issue_type, + key, + jar: rule.jar, + file_path, + original_severity, + severity: rule.effect_severity, + hidden, + }); + } + } + + Ok(web::Json(response)) +} + +/// List all details affected by a Delphi rule in the published revision. +#[utoipa::path( + context_path = "/moderation/tech-review", + tag = "moderation", + security(("bearer_auth" = [])), + responses((status = OK, body = Vec)) +)] +#[get("/rules/{id}/effects")] +pub async fn get_rule_affected_details( + req: HttpRequest, + pool: web::Data, + ro_pool: web::Data, + redis: web::Data, + session_queue: web::Data, + path: web::Path<(i64,)>, +) -> Result>, ApiError> { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ, + ) + .await?; + let (rule_id,) = path.into_inner(); + + let details = sqlx::query!( + r#" + SELECT + effect.detail_id AS "detail_id!: DelphiReportIssueDetailsId", + detail.issue_id AS "issue_id!: DelphiReportIssueId", + version.mod_id AS "project_id?: DBProjectId", + project.name AS "project_name?", + project.icon_url AS "project_icon_url?", + version.id AS "version_id?: DBVersionId", + version.name AS "version_name?", + version.version_number AS "version_number?", + issue.issue_type, + detail.key, + detail.jar, + detail.file_path, + detail.severity AS "original_severity!: DelphiSeverity", + effect.severity AS "effect_severity: DelphiSeverity", + effect.hidden + FROM delphi_rule_effects effect + INNER JOIN delphi_rule_revisions published + ON published.revision = effect.revision + INNER JOIN delphi_report_issue_details detail + ON detail.id = effect.detail_id + INNER JOIN delphi_report_issues issue ON issue.id = detail.issue_id + INNER JOIN delphi_reports report ON report.id = issue.report_id + LEFT JOIN files file ON file.id = report.file_id + LEFT JOIN versions version ON version.id = file.version_id + LEFT JOIN mods project ON project.id = version.mod_id + WHERE effect.rule_id = $1 + ORDER BY effect.detail_id DESC + "#, + rule_id, + ) + .fetch_all(&***ro_pool) + .await + .wrap_internal_err("failed to fetch details affected by delphi rule")?; + + Ok(web::Json( + details + .into_iter() + .map(|detail| DelphiRuleAffectedDetail { + detail_id: detail.detail_id, + issue_id: detail.issue_id, + project_id: detail.project_id.map(ProjectId::from), + project_name: detail.project_name, + project_icon_url: detail.project_icon_url, + version_id: detail.version_id.map(VersionId::from), + version_name: detail.version_name, + version_number: detail.version_number, + issue_type: detail.issue_type, + key: detail.key, + jar: detail.jar, + file_path: detail.file_path, + original_severity: detail.original_severity, + severity: detail.effect_severity, + hidden: detail.hidden, }) .collect(), )) @@ -354,6 +551,8 @@ pub async fn create_rule( updated_at: rule.updated_at, created_by: rule.created_by, updated_by: rule.updated_by, + affected_details_count: 0, + affected_details: Vec::new(), })) } @@ -427,6 +626,8 @@ pub async fn update_rule( updated_at: rule.updated_at, created_by: rule.created_by, updated_by: rule.updated_by, + affected_details_count: 0, + affected_details: Vec::new(), })) } diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs index 4ee7f4c595..e6895558ac 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, HashMap}; -use actix_web::{HttpRequest, HttpResponse, post, web}; +use actix_web::{HttpRequest, HttpResponse, get, post, web}; use ariadne::ids::base62_impl::to_base62; use bytes::Bytes; use eyre::{Context as _, Result, eyre}; @@ -9,6 +9,7 @@ use serde::Serialize; use sqlx::types::Json; use tokio::sync::mpsc; use tokio_stream::wrappers::UnboundedReceiverStream; +use utoipa::{PartialSchema, ToSchema}; use super::rules::DelphiRuleEffect; use crate::{ @@ -26,7 +27,7 @@ const RULE_SCAN_LOCK_ID: i64 = 0x6465_6c70_6869_7275; const PROGRESS_INTERVAL: usize = 50; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { - cfg.service(scan_rules); + cfg.service(get_rule_schema).service(scan_rules); } #[derive(Serialize)] @@ -43,41 +44,41 @@ struct RuleScanErrorEvent<'a> { message: &'a str, } -#[derive(Serialize)] -struct RuleInput { - schema_version: u32, - trace: RuleTrace, - scan: RuleScan, - artifact: RuleArtifact, - scope: RuleScope, +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct RuleInput { + pub(super) schema_version: u32, + pub(super) trace: RuleTrace, + pub(super) scan: RuleScan, + pub(super) artifact: RuleArtifact, + pub(super) scope: RuleScope, } -#[derive(Serialize)] -struct RuleTrace { - key: String, - issue_type: String, - severity: DelphiSeverity, - jar: Option, - file_path: String, - data: HashMap, +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct RuleTrace { + pub(super) key: String, + pub(super) issue_type: String, + pub(super) severity: DelphiSeverity, + pub(super) jar: Option, + pub(super) file_path: String, + pub(super) data: HashMap, } -#[derive(Serialize)] -struct RuleScan { - delphi_version: i32, +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct RuleScan { + pub(super) delphi_version: i32, } -#[derive(Serialize)] -struct RuleArtifact { - size: Option, - hashes: BTreeMap, +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct RuleArtifact { + pub(super) size: Option, + pub(super) hashes: BTreeMap, } -#[derive(Serialize)] -struct RuleScope { - project_id: Option, - version_id: Option, - file_id: Option, +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct RuleScope { + pub(super) project_id: Option, + pub(super) version_id: Option, + pub(super) file_id: Option, } struct CompiledRule { @@ -98,6 +99,62 @@ struct ScanSummary { effects: usize, } +#[derive(Serialize, utoipa::ToSchema)] +pub struct DelphiRuleSchemaResponse { + pub input: serde_json::Value, + pub output: serde_json::Value, + pub components: BTreeMap, +} + +/// Get the schemas for the CEL input and output values. +#[utoipa::path( + context_path = "/moderation/tech-review", + tag = "moderation", + security(("bearer_auth" = [])), + responses((status = OK, body = DelphiRuleSchemaResponse)) +)] +#[get("/rules/schema")] +pub async fn get_rule_schema( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result, ApiError> { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ, + ) + .await?; + + let mut schemas = Vec::new(); + ::schemas(&mut schemas); + as ToSchema>::schemas(&mut schemas); + + Ok(web::Json(DelphiRuleSchemaResponse { + input: schema_to_value(::schema())?, + output: schema_to_value( + as PartialSchema>::schema(), + )?, + components: schemas + .into_iter() + .map(|(name, schema)| Ok((name, schema_to_value(schema)?))) + .collect::>()?, + })) +} + +fn schema_to_value( + schema: T, +) -> Result { + serde_json::to_value(schema).map_err(|error| { + ApiError::Internal( + eyre!(error).wrap_err("failed to serialize Delphi rule schema"), + ) + }) +} + /// Re-evaluate every Delphi issue detail and atomically publish a new rule revision. #[utoipa::path( context_path = "/moderation/tech-review", @@ -316,12 +373,13 @@ async fn run_scan( }; for rule in &rules { - let effect = evaluate_rule(&rule.program, &input).wrap_err_with(|| { - format!( - "failed to evaluate delphi rule {} for detail {detail_id}", - rule.id - ) - })?; + let effect = evaluate_rule(&rule.program, &input) + .wrap_err_with(|| { + format!( + "failed to evaluate delphi rule {} for detail {detail_id}", + rule.id + ) + })?; if let Some(effect) = effect { effects.push(MaterializedEffect { detail_id, diff --git a/packages/api-client/src/modules/labrinth/tech-review/internal.ts b/packages/api-client/src/modules/labrinth/tech-review/internal.ts index c66520baa8..5cf7e278fb 100644 --- a/packages/api-client/src/modules/labrinth/tech-review/internal.ts +++ b/packages/api-client/src/modules/labrinth/tech-review/internal.ts @@ -17,6 +17,30 @@ export class LabrinthTechReviewInternalModule extends AbstractModule { ) } + public async getRuleSchema(): Promise { + return this.client.request( + '/moderation/tech-review/rules/schema', + { + api: 'labrinth', + version: 'internal', + method: 'GET', + }, + ) + } + + public async getRuleAffectedDetails( + id: number, + ): Promise { + return this.client.request( + `/moderation/tech-review/rules/${id}/effects`, + { + api: 'labrinth', + version: 'internal', + method: 'GET', + }, + ) + } + public async testRule( request: Labrinth.TechReview.Internal.TestDelphiRuleRequest, ): Promise { diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 9d97f1ec5a..8f9e55939d 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -2235,6 +2235,26 @@ export namespace Labrinth { updated_at: string created_by: number | null updated_by: number | null + affected_details_count: number + affected_details: DelphiRuleAffectedDetail[] + } + + export type DelphiRuleAffectedDetail = { + detail_id: string + issue_id: string + project_id: string | null + project_name: string | null + project_icon_url: string | null + version_id: string | null + version_name: string | null + version_number: string | null + issue_type: string + key: string + jar: string | null + file_path: string + original_severity: DelphiSeverity + severity: DelphiSeverity | null + hidden: boolean } export type WriteDelphiRule = { @@ -2261,6 +2281,14 @@ export namespace Labrinth { hidden: boolean } + export type DelphiRuleSchema = Record + + export type DelphiRuleSchemaResponse = { + input: DelphiRuleSchema + output: DelphiRuleSchema + components: Record + } + export type TestDelphiRuleResponse = { effects: Array } From 4b07ed720ed92aae253447d75a52546bc97cba34 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:12:37 +0100 Subject: [PATCH 06/21] remove old ui --- .../moderation/technical-review/rules.vue | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/apps/frontend/src/pages/moderation/technical-review/rules.vue b/apps/frontend/src/pages/moderation/technical-review/rules.vue index 6cf23d476a..254415f84d 100644 --- a/apps/frontend/src/pages/moderation/technical-review/rules.vue +++ b/apps/frontend/src/pages/moderation/technical-review/rules.vue @@ -226,28 +226,6 @@ /> - - CEL contract and input - - - Effects are maps such as - { "severity": "low", "hidden": false }. Severity can be low, - medium, high, or severe. - - - Rules run in the order shown below. The first rule that returns a non-null effect wins. - - - The input object contains schema_version, - trace (key, issue_type, severity, - jar, file_path, data), - scan.delphi_version, artifact (size, - hashes), and stable IDs under scope (project_id, - version_id, file_id). - - - - From 8d82c228cf032752a90220b9f6db4a82bcf54fdc Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:49:20 +0100 Subject: [PATCH 07/21] rules affect tech review detail statuses --- .../moderation/GlobalDetailLocalTraceCard.vue | 22 +- .../ui/moderation/GlobalDetailTracesList.vue | 11 +- .../ui/moderation/IssueDetailPath.vue | 74 +++ .../ui/moderation/ModerationTechRevCard.vue | 82 ++- .../pages/moderation/global-traces/[key].vue | 11 +- .../moderation/technical-review/rules.vue | 13 +- ...23120000_delphi_effective_rule_details.sql | 37 ++ .../src/routes/internal/delphi/mod.rs | 11 +- .../src/routes/internal/delphi/rescan.rs | 2 + .../internal/delphi/tech_review_sync.rs | 111 ++-- apps/labrinth/src/routes/internal/mod.rs | 1 + .../src/routes/internal/moderation/mod.rs | 4 + .../routes/internal/moderation/tech_review.rs | 54 +- .../internal/moderation/tech_review/global.rs | 4 + .../moderation/tech_review/rules_scan.rs | 484 ++++++++++++++---- .../modules/labrinth/tech-review/internal.ts | 13 + .../api-client/src/modules/labrinth/types.ts | 24 + 17 files changed, 754 insertions(+), 204 deletions(-) create mode 100644 apps/frontend/src/components/ui/moderation/IssueDetailPath.vue create mode 100644 apps/labrinth/migrations/20260723120000_delphi_effective_rule_details.sql diff --git a/apps/frontend/src/components/ui/moderation/GlobalDetailLocalTraceCard.vue b/apps/frontend/src/components/ui/moderation/GlobalDetailLocalTraceCard.vue index a6756ce443..8329b64b33 100644 --- a/apps/frontend/src/components/ui/moderation/GlobalDetailLocalTraceCard.vue +++ b/apps/frontend/src/components/ui/moderation/GlobalDetailLocalTraceCard.vue @@ -5,14 +5,8 @@ {{ trace.project_name }} - - {{ trace.version_number }} - - {{ decodeTracePath(trace.file_name) }} - - - {{ decodeTracePath(trace.jar) }} - + +