Add minimum coverage threshold to Coverage-Check approval rule (Backend)

What does this MR do and why?

This is MR 1 of 3 implementing the minimum coverage threshold feature for #6284:

  1. Backend (this MR) - Database, service logic, REST API, GraphQL, blockage reason
  2. Frontend - Settings UI + MR widget feedback (!224368 (closed))
  3. Documentation + Changelog (!224375 (closed))

Problem

The existing Coverage-Check approval rule only requires approval when coverage decreases relative to the base pipeline. There is no way to enforce an absolute minimum coverage threshold (e.g. "block merge if coverage below 80%"). Additionally, when the rule blocks a merge, users cannot see why it was blocked.

Solution

Extends the Coverage-Check approval rule with an optional coverage_minimum_threshold field (0-100). When set, approval is required if coverage falls below the threshold. The existing relative decrease check continues to work alongside it. When nil, behavior is unchanged.

Adds a blockage_reason method to ApprovalWrappedRule that returns a human-readable explanation of why the rule is blocking (e.g. "Coverage 70.0% is below the minimum threshold of 80.0%" or "Coverage decreased from 90.0% to 85.0%"). This is exposed via GraphQL on ApprovalRuleType so the frontend can display it in the MR widget.

Changes

Area Details
Database Add coverage_minimum_threshold (float, nullable) to approval_project_rules and approval_merge_request_rules
Model Validation (0-100, code_coverage only), propagation via REPORT_APPROVER_ATTRIBUTES
Service Ci::SyncReportsToApprovalRulesService checks absolute threshold before relative comparison
REST API coverage_minimum_threshold parameter on create/update approval rules
GraphQL Argument on approvalProjectRuleUpdate and branchRuleApprovalProjectRuleCreate, field on ApprovalProjectRuleType
Blockage Reason ApprovalWrappedRule#blockage_reason computes why a rule blocks, exposed as blockageReason on ApprovalRuleType

References

How to set up and validate locally

1. Rails Console

# === Part A: coverage_minimum_threshold ===
project = Project.first
rule = ApprovalProjectRule.create!(
  project: project,
  name: 'Coverage-Check',
  rule_type: :report_approver,
  report_type: :code_coverage,
  approvals_required: 1,
  coverage_minimum_threshold: 80.0
)

puts "✓ Rule created with threshold: #{rule.coverage_minimum_threshold}"

# Test 1: Validation - value too high
rule.coverage_minimum_threshold = 150.0
if rule.valid?
  puts "✗ FAILED: Validation should have rejected value"
else
  puts "✓ Validation correct: #{rule.errors.full_messages.first}"
end

# Test 2: Validation - value too low
rule.reload
rule.coverage_minimum_threshold = -10.0
if rule.valid?
  puts "✗ FAILED: Validation should have rejected value"
else
  puts "✓ Validation correct: #{rule.errors.full_messages.first}"
end

# Test 3: Valid update
rule.reload
rule.update!(coverage_minimum_threshold: 90.0)
puts "✓ Update successful: #{rule.coverage_minimum_threshold}"

# Test 4: Set threshold to nil (should be allowed)
rule.update!(coverage_minimum_threshold: nil)
puts "✓ Threshold set to nil: #{rule.coverage_minimum_threshold.inspect}"

rule.destroy!

# === Part B: blockage_reason ===
puts "\n=== blockage_reason Tests ==="

mr = project.merge_requests.opened.last
sha = project.repository.commit('HEAD').id
partition_id = Ci::Partition.current.id

# Create pipelines with coverage
base_pipeline = Ci::Pipeline.new(
  project: project, ref: mr.target_branch, sha: sha,
  source: :push, status: :success, partition_id: partition_id
)
base_pipeline.save!(validate: false)
Ci::Build.new(
  project: project, pipeline: base_pipeline, name: 'test',
  status: :success, coverage: 90.0, partition_id: partition_id,
  scheduling_type: :stage
).save!(validate: false)

head_pipeline = Ci::Pipeline.new(
  project: project, ref: mr.source_branch, sha: sha,
  source: :merge_request_event, status: :success,
  merge_request: mr, partition_id: partition_id
)
head_pipeline.save!(validate: false)
Ci::Build.new(
  project: project, pipeline: head_pipeline, name: 'test',
  status: :success, coverage: 70.0, partition_id: partition_id,
  scheduling_type: :stage
).save!(validate: false)
mr.update_column(:head_pipeline_id, head_pipeline.id)

# Create MR-level approval rule with approver (not the MR author)
approver = User.where.not(id: mr.author_id).first
mr_rule = ApprovalMergeRequestRule.create!(
  merge_request: mr, name: 'Coverage-Check',
  rule_type: :report_approver, report_type: :code_coverage,
  approvals_required: 1, coverage_minimum_threshold: 80.0
)
mr_rule.users << approver

# Test 5: Coverage below threshold
wrapped = ApprovalWrappedRule.new(mr.reload, mr_rule)
reason = wrapped.blockage_reason
if reason&.include?("below the minimum threshold")
  puts "✓ blockage_reason correct: #{reason}"
else
  puts "✗ FAILED: expected threshold message, got: #{reason.inspect}"
end

# Test 6: Coverage above threshold
head_pipeline.builds.last.update_column(:coverage, 95.0)
wrapped = ApprovalWrappedRule.new(mr.reload, mr_rule)
reason = wrapped.blockage_reason
if reason.nil?
  puts "✓ blockage_reason nil when coverage above threshold"
else
  puts "✗ FAILED: expected nil, got: #{reason.inspect}"
end

# Cleanup
mr_rule.destroy!
head_pipeline.builds.destroy_all
head_pipeline.destroy!
base_pipeline.builds.destroy_all
base_pipeline.destroy!

puts "\n=== All Rails console tests passed ==="

2. REST API

#!/bin/bash
TOKEN="<your-personal-access-token>"
PROJECT_ID=1

echo "=== API End-to-End Test: Coverage Minimum Threshold ==="
echo ""

# Test 1: Create rule with threshold
echo "Test 1: Create rule with 75% threshold..."
RESPONSE=$(curl -s -X POST "http://gdk.local:3000/api/v4/projects/$PROJECT_ID/approval_rules" \
  -H "PRIVATE-TOKEN: $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Coverage-Check", "approvals_required": 1, "report_type": "code_coverage", "coverage_minimum_threshold": 75.0}')

RULE_ID=$(echo $RESPONSE | jq -r '.id')
THRESHOLD=$(echo $RESPONSE | jq -r '.coverage_minimum_threshold')

if [ "$THRESHOLD" = "75.0" ]; then
  echo "✓ Rule created with ID $RULE_ID and threshold $THRESHOLD"
else
  echo "✗ FAILED: $RESPONSE"
  exit 1
fi

# Test 2: Update threshold
echo ""
echo "Test 2: Update threshold to 85%..."
RESPONSE=$(curl -s -X PUT "http://gdk.local:3000/api/v4/projects/$PROJECT_ID/approval_rules/$RULE_ID" \
  -H "PRIVATE-TOKEN: $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"coverage_minimum_threshold": 85.0}')

THRESHOLD=$(echo $RESPONSE | jq -r '.coverage_minimum_threshold')

if [ "$THRESHOLD" = "85.0" ]; then
  echo "✓ Threshold updated to $THRESHOLD"
else
  echo "✗ FAILED: $RESPONSE"
  exit 1
fi

# Test 3: Delete rule (cleanup)
echo ""
echo "Test 3: Delete rule (cleanup)..."
curl -s -X DELETE "http://gdk.local:3000/api/v4/projects/$PROJECT_ID/approval_rules/$RULE_ID" \
  -H "PRIVATE-TOKEN: $TOKEN" > /dev/null
echo "✓ Rule deleted"

echo ""
echo "=== All API tests passed ==="
echo ""
echo "NOTE: blockage_reason is exposed via GraphQL (ApprovalRuleType), not via REST API."
echo "See GraphQL test for blockageReason verification."

3. GraphQL

#!/bin/bash
TOKEN="<your-personal-access-token>"
PROJECT_ID=1
PROJECT_PATH="<your-project-path>"

echo "=== GraphQL End-to-End Test: Coverage Minimum Threshold + blockageReason ==="
echo ""

# Setup: Create rule via REST API
echo "Setup: Create rule with 80% threshold..."
RESPONSE=$(curl -s -X POST "http://gdk.local:3000/api/v4/projects/$PROJECT_ID/approval_rules" \
  -H "PRIVATE-TOKEN: $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Coverage-Check", "approvals_required": 1, "report_type": "code_coverage", "coverage_minimum_threshold": 80.0}')

RULE_ID=$(echo $RESPONSE | jq -r '.id')
echo "✓ Rule created with ID $RULE_ID"

# Test 1: Query coverageMinimumThreshold via GraphQL (project-level rule)
echo ""
echo "Test 1: Query coverageMinimumThreshold via GraphQL..."
RESPONSE=$(curl -s -X POST "http://gdk.local:3000/api/graphql" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "query { project(fullPath: \"'"$PROJECT_PATH"'\") { branchRules { nodes { approvalRules { nodes { id name coverageMinimumThreshold } } } } } }"
  }')

echo "$RESPONSE" | jq .

# Test 2: Query blockageReason on MR approval rules
echo ""
echo "Test 2: Query blockageReason on MR approval rules..."

MR_IID=$(curl -s "http://gdk.local:3000/api/v4/projects/$PROJECT_ID/merge_requests?state=opened&per_page=1" \
  -H "PRIVATE-TOKEN: $TOKEN" | jq -r '.[0].iid')
echo "Using MR !$MR_IID"

RESPONSE=$(curl -s -X POST "http://gdk.local:3000/api/graphql" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "query { project(fullPath: \"'"$PROJECT_PATH"'\") { mergeRequest(iid: \"'"$MR_IID"'\") { approvalState { rules { id name type approved blockageReason } } } } }"
  }')

echo "$RESPONSE" | jq '.data.project.mergeRequest.approvalState.rules'

# Cleanup
echo ""
echo "Cleanup: Delete rule..."
curl -s -X DELETE "http://gdk.local:3000/api/v4/projects/$PROJECT_ID/approval_rules/$RULE_ID" \
  -H "PRIVATE-TOKEN: $TOKEN" > /dev/null
echo "✓ Rule deleted"

echo ""
echo "=== GraphQL test completed ==="

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

  • The change is backwards compatible. When coverage_minimum_threshold is nil, behavior is unchanged.
  • Database migration is safe: single add_column with null: true, no default, no index needed.
  • No N+1 queries introduced. coverage_minimum_threshold_for uses pick (single value query).
  • Feature is behind existing coverage_check_approval_rule licensed feature, no new feature flag needed.
  • API changes are additive (new optional parameter), no breaking changes.
  • GraphQL changes follow style guide (descriptions, no "this" demonstrative).
  • RSpec coverage: model validation, service logic, threshold propagation, API integration, blockage_reason (380 tests passing).
  • Manually tested via Rails console, REST API, and GraphQL.

Related to #6284

Edited by Norman Debald

Merge request reports

Loading