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:
- Backend (this MR) - Database, service logic, REST API, GraphQL, blockage reason
- Frontend - Settings UI + MR widget feedback (!224368 (closed))
- 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
- #6284
- #15765 (closed) (related: Coverage MR Approval Rule, closed)
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_thresholdisnil, behavior is unchanged. - Database migration is safe: single
add_columnwithnull: true, no default, no index needed. - No N+1 queries introduced.
coverage_minimum_threshold_forusespick(single value query). - Feature is behind existing
coverage_check_approval_rulelicensed 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