Commit 4e20480c authored by Maxime Orefice's avatar Maxime Orefice

Add GraphQL endpoint for code coverage

Allows fetching code coverage data associated
with a project in batch. This contains the aggregation
of all daily code coverage.
parent 8c878467
......@@ -12,13 +12,21 @@ module Ci
validates :data, json_schema: { filename: "daily_build_group_report_result_data" }
scope :with_included_projects, -> { includes(:project) }
scope :by_projects, -> (ids) { where(project_id: ids) }
scope :with_coverage, -> { where("(data->'coverage') IS NOT NULL") }
def self.upsert_reports(data)
upsert_all(data, unique_by: :index_daily_build_group_report_results_unique_columns) if data.any?
end
store_accessor :data, :coverage
class << self
def upsert_reports(data)
upsert_all(data, unique_by: :index_daily_build_group_report_results_unique_columns) if data.any?
end
def self.recent_results(attrs, limit: nil)
where(attrs).order(date: :desc, group_name: :asc).limit(limit)
def recent_results(attrs, limit: nil)
where(attrs).order(date: :desc, group_name: :asc).limit(limit)
end
end
end
end
Ci::DailyBuildGroupReportResult.prepend_if_ee('EE::Ci::DailyBuildGroupReportResult')
......@@ -2515,6 +2515,26 @@ Identifier of Clusters::Cluster
"""
scalar ClustersClusterID
"""
Represents the code coverage summary for a project
"""
type CodeCoverageSummary {
"""
Average percentage of the different code coverage results available for the project.
"""
averageCoverage: Float
"""
Number of different code coverage results available.
"""
coverageCount: Int
"""
Latest date when the code coverage was created for the project.
"""
lastUpdatedAt: Time
}
type Commit {
"""
Author of the commit
......@@ -13776,6 +13796,12 @@ type Project {
last: Int
): ClusterAgentConnection
"""
Code coverages summary associated with the project. Available only when
feature flag `group_coverage_data_report` is enabled
"""
codeCoverageSummary: CodeCoverageSummary
"""
Compliance frameworks associated with the project
"""
......
......@@ -6819,6 +6819,61 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "CodeCoverageSummary",
"description": "Represents the code coverage summary for a project",
"fields": [
{
"name": "averageCoverage",
"description": "Average percentage of the different code coverage results available for the project.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Float",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "coverageCount",
"description": "Number of different code coverage results available.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "lastUpdatedAt",
"description": "Latest date when the code coverage was created for the project.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "Commit",
......@@ -40425,6 +40480,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "codeCoverageSummary",
"description": "Code coverages summary associated with the project. Available only when feature flag `group_coverage_data_report` is enabled",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "CodeCoverageSummary",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "complianceFrameworks",
"description": "Compliance frameworks associated with the project",
......@@ -390,6 +390,16 @@ Autogenerated return type of ClusterAgentTokenDelete.
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
### CodeCoverageSummary
Represents the code coverage summary for a project.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `averageCoverage` | Float | Average percentage of the different code coverage results available for the project. |
| `coverageCount` | Int | Number of different code coverage results available. |
| `lastUpdatedAt` | Time | Latest date when the code coverage was created for the project. |
### Commit
| Field | Type | Description |
......@@ -1975,6 +1985,7 @@ Autogenerated return type of PipelineRetry.
| `avatarUrl` | String | URL to avatar image file of the project |
| `board` | Board | A single board of the project |
| `clusterAgent` | ClusterAgent | Find a single cluster agent by name |
| `codeCoverageSummary` | CodeCoverageSummary | Code coverages summary associated with the project. Available only when feature flag `group_coverage_data_report` is enabled |
| `containerExpirationPolicy` | ContainerExpirationPolicy | The container expiration policy of the project |
| `containerRegistryEnabled` | Boolean | Indicates if the project stores Docker container images in a container registry |
| `createdAt` | Time | Timestamp of the project creation |
......
......@@ -127,6 +127,26 @@ module EE
description: 'Size limit for the repository in bytes',
resolve: -> (obj, _args, _ctx) { obj.actual_size_limit }
field :code_coverage_summary,
::Types::Ci::CodeCoverageSummaryType,
null: true,
description: 'Code coverages summary associated with the project',
feature_flag: :group_coverage_data_report
def code_coverage_summary
BatchLoader::GraphQL.for(project.id).batch do |project_ids, loader|
results = ::Ci::DailyBuildGroupReportResult
.by_projects(project_ids)
.with_coverage
.latest
.summaries_per_project
results.each do |project_id, summary|
loader.call(project_id, summary)
end
end
end
def self.sast_ci_configuration(project)
::Security::CiConfiguration::SastParserService.new(project).configuration
end
......
# frozen_string_literal: true
module Types
module Ci
# rubocop: disable Graphql/AuthorizeTypes
class CodeCoverageSummaryType < BaseObject
graphql_name 'CodeCoverageSummary'
description 'Represents the code coverage summary for a project'
field :average_coverage, GraphQL::FLOAT_TYPE, null: true,
description: 'Average percentage of the different code coverage results available for the project.'
field :coverage_count, GraphQL::INT_TYPE, null: true,
description: 'Number of different code coverage results available.'
field :last_updated_at, Types::TimeType, null: true,
description: 'Latest date when the code coverage was created for the project.'
end
end
end
# frozen_string_literal: true
module EE
module Ci
# Ci::DailyBuildGroupReportResult mixin
#
# This module is intended to encapsulate EE-specific model logic
# and be prepended in the `Ci::DailyBuildGroupReportResult` model
module DailyBuildGroupReportResult
extend ActiveSupport::Concern
prepended do
scope :latest, -> do
with(
latest_by_project: select(:project_id, 'MAX(date) AS date').group(:project_id)
)
.joins(
'JOIN latest_by_project ON ci_daily_build_group_report_results.date = latest_by_project.date
AND ci_daily_build_group_report_results.project_id = latest_by_project.project_id'
)
end
def self.summaries_per_project
group(:project_id, 'latest_by_project.date').pluck(
:project_id,
Arel.sql("AVG(cast(data ->> 'coverage' AS FLOAT))"),
Arel.sql("COUNT(*)"),
Arel.sql("latest_by_project.date")
).each_with_object({}) do |(project_id, average_coverage, coverage_count, date), result|
result[project_id] = {
average_coverage: average_coverage,
coverage_count: coverage_count,
last_updated_at: date
}
end
end
end
end
end
end
---
title: Add GraphQL endpoint for Code Coverage summary for the project
merge_request: 44472
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['CodeCoverageSummary'] do
it { expect(described_class.graphql_name).to eq('CodeCoverageSummary') }
describe 'fields' do
let(:fields) { %i[average_coverage coverage_count last_updated_at] }
it { expect(described_class).to have_graphql_fields(fields) }
end
end
......@@ -18,6 +18,7 @@ RSpec.describe GitlabSchema.types['Project'] do
vulnerabilities sast_ci_configuration vulnerability_scanners requirement_states_count
vulnerability_severities_count packages compliance_frameworks vulnerabilities_count_by_day
security_dashboard_path iterations cluster_agents repository_size_excess actual_repository_size_limit
code_coverage_summary
]
expect(described_class).to include_graphql_fields(*expected_fields)
......@@ -283,4 +284,10 @@ RSpec.describe GitlabSchema.types['Project'] do
expect(cluster_agent.agent_tokens.size).to be(count)
end
end
describe 'code coverage summary field' do
subject { described_class.fields['codeCoverageSummary'] }
it { is_expected.to have_graphql_type(Types::Ci::CodeCoverageSummaryType) }
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Ci::DailyBuildGroupReportResult do
let_it_be(:project) { create(:project) }
let(:recent_build_group_report_result) { create(:ci_daily_build_group_report_result, project: project) }
let(:old_build_group_report_result) do
create(:ci_daily_build_group_report_result, date: 1.week.ago, project: project)
end
describe 'scopes' do
describe '.latest' do
subject { described_class.latest }
it 'returns the most recent records by date and projects' do
expect(subject).to contain_exactly(recent_build_group_report_result)
end
end
describe '.summaries_per_project' do
subject(:summary) { described_class.latest.summaries_per_project }
context 'when projects with coverages' do
let_it_be(:project_2) { create(:project) }
let_it_be(:new_build_group_report_result) do
create(:ci_daily_build_group_report_result, project: project, group_name: 'cobertura', coverage: 66.0)
end
let_it_be(:build_group_report_result_2) do
create(:ci_daily_build_group_report_result, project: project_2, group_name: 'rspec', coverage: 78.0)
end
it 'returns the code coverage summary by project' do
expected_summary = {
project.id => {
average_coverage: 71.5,
coverage_count: 2,
last_updated_at: recent_build_group_report_result.date
},
project_2.id => {
average_coverage: 78.0,
coverage_count: 1,
last_updated_at: build_group_report_result_2.date
}
}
expect(summary).to eq(expected_summary)
end
it 'executes only 1 SQL query' do
query_count = ActiveRecord::QueryRecorder.new { subject }.count
expect(query_count).to eq(1)
end
end
context 'when project does not have coverage' do
it 'returns an empty hash' do
expect(subject).to eq({})
end
it 'executes only 1 SQL query' do
query_count = ActiveRecord::QueryRecorder.new { subject }.count
expect(query_count).to eq(1)
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Getting code coverage summary in a project' do
include GraphqlHelpers
let(:project) { create(:project, :repository, :public) }
let(:current_user) { create(:user) }
let(:code_coverage_summary_graphql_data) { graphql_data['projects']['nodes'].first['codeCoverageSummary'] }
let(:fields) do
<<~QUERY
nodes {
id
name
codeCoverageSummary {
averageCoverage
coverageCount
lastUpdatedAt
}
}
QUERY
end
let(:query) do
graphql_query_for(
'projects',
{ 'ids' => [project.to_global_id.to_s] },
fields
)
end
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: current_user)
end
end
context 'when project has coverage' do
let!(:daily_build_group_report_result) { create(:ci_daily_build_group_report_result, project: project) }
it 'contains code coverage summary data', :aggregates_failures do
post_graphql(query, current_user: current_user)
expect(code_coverage_summary_graphql_data.dig('averageCoverage')).to eq(77.0)
expect(code_coverage_summary_graphql_data.dig('coverageCount')).to eq(1)
expect(code_coverage_summary_graphql_data.dig('lastUpdatedAt')).to eq(daily_build_group_report_result.date.to_s)
end
end
context 'when project does not have coverage' do
it 'returns nil' do
post_graphql(query, current_user: current_user)
expect(code_coverage_summary_graphql_data).to be_nil
end
end
context 'when group_coverage_data_report flag is disabled' do
it 'returns a graphQL error field does not exist' do
stub_feature_flags(group_coverage_data_report: false)
post_graphql(query, current_user: current_user)
expect_graphql_errors_to_include(/Field 'codeCoverageSummary' doesn't exist on type 'Project'/)
end
end
end
......@@ -3,7 +3,7 @@
FactoryBot.define do
factory :ci_daily_build_group_report_result, class: 'Ci::DailyBuildGroupReportResult' do
ref_path { Gitlab::Git::BRANCH_REF_PREFIX + 'master' }
date { Time.zone.now.to_date }
date { Date.current }
project
last_pipeline factory: :ci_pipeline
group_name { 'rspec' }
......
......@@ -81,4 +81,28 @@ RSpec.describe Ci::DailyBuildGroupReportResult do
end
end
end
describe 'scopes' do
let_it_be(:project) { create(:project) }
let(:recent_build_group_report_result) { create(:ci_daily_build_group_report_result, project: project) }
let(:old_build_group_report_result) do
create(:ci_daily_build_group_report_result, date: 1.week.ago, project: project)
end
describe '.by_projects' do
subject { described_class.by_projects([project.id]) }
it 'returns records by projects' do
expect(subject).to contain_exactly(recent_build_group_report_result, old_build_group_report_result)
end
end
describe '.with_coverage' do
subject { described_class.with_coverage }
it 'returns data with coverage' do
expect(subject).to contain_exactly(recent_build_group_report_result, old_build_group_report_result)
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment