Commit 25641627 authored by Maxime Orefice's avatar Maxime Orefice Committed by Vitali Tatarintev

Add GraphQL endpoint for code coverage group activity

This commit allows to fetch associated code coverage
activities for a given group.
parent 0177b2c0
......@@ -4,6 +4,7 @@ module Ci
class DailyBuildGroupReportResult < ApplicationRecord
extend Gitlab::Ci::Model
REPORT_WINDOW = 90.days
PARAM_TYPES = %w[coverage].freeze
belongs_to :last_pipeline, class_name: 'Ci::Pipeline', foreign_key: :last_pipeline_id
......@@ -15,6 +16,7 @@ module Ci
scope :by_projects, -> (ids) { where(project_id: ids) }
scope :with_coverage, -> { where("(data->'coverage') IS NOT NULL") }
scope :with_default_branch, -> { where(default_branch: true) }
scope :by_date, -> (start_date) { where(date: report_window(start_date)..Date.current) }
store_accessor :data, :coverage
......@@ -26,6 +28,13 @@ module Ci
def recent_results(attrs, limit: nil)
where(attrs).order(date: :desc, group_name: :asc).limit(limit)
end
def report_window(start_date)
default_date = REPORT_WINDOW.ago.to_date
date = Date.parse(start_date) rescue default_date
[date, default_date].max
end
end
end
end
......
......@@ -2682,6 +2682,66 @@ Identifier of Clusters::Cluster
"""
scalar ClustersClusterID
"""
Represents the code coverage activity for a group
"""
type CodeCoverageActivity {
"""
Average percentage of the different code coverage results available for the group.
"""
averageCoverage: Float
"""
Number of different code coverage results available for the group.
"""
coverageCount: Int
"""
Date when the code coverage was created.
"""
date: Date!
"""
Number of projects with code coverage results for the group.
"""
projectCount: Int
}
"""
The connection type for CodeCoverageActivity.
"""
type CodeCoverageActivityConnection {
"""
A list of edges.
"""
edges: [CodeCoverageActivityEdge]
"""
A list of nodes.
"""
nodes: [CodeCoverageActivity]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type CodeCoverageActivityEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: CodeCoverageActivity
}
"""
Represents the code coverage summary for a project
"""
......@@ -7965,6 +8025,37 @@ type Group {
last: Int
): BoardConnection
"""
Represents the code coverage activity for this group. Available only when
feature flag `group_coverage_data_report` is enabled
"""
codeCoverageActivities(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
First day for which to fetch code coverage activity (maximum time window is set to 90 days)
"""
startDate: Date!
): CodeCoverageActivityConnection
"""
Container repositories of the project
"""
......
......@@ -7328,6 +7328,191 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "CodeCoverageActivity",
"description": "Represents the code coverage activity for a group",
"fields": [
{
"name": "averageCoverage",
"description": "Average percentage of the different code coverage results available for the group.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Float",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "coverageCount",
"description": "Number of different code coverage results available for the group.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "date",
"description": "Date when the code coverage was created.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Date",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "projectCount",
"description": "Number of projects with code coverage results for the group.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "CodeCoverageActivityConnection",
"description": "The connection type for CodeCoverageActivity.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "CodeCoverageActivityEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "CodeCoverageActivity",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "CodeCoverageActivityEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "CodeCoverageActivity",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "CodeCoverageSummary",
......@@ -22008,6 +22193,73 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "codeCoverageActivities",
"description": "Represents the code coverage activity for this group. Available only when feature flag `group_coverage_data_report` is enabled",
"args": [
{
"name": "startDate",
"description": "First day for which to fetch code coverage activity (maximum time window is set to 90 days)",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Date",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "CodeCoverageActivityConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "containerRepositories",
"description": "Container repositories of the project",
......@@ -418,6 +418,17 @@ 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. |
### CodeCoverageActivity
Represents the code coverage activity for a group.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `averageCoverage` | Float | Average percentage of the different code coverage results available for the group. |
| `coverageCount` | Int | Number of different code coverage results available for the group. |
| `date` | Date! | Date when the code coverage was created. |
| `projectCount` | Int | Number of projects with code coverage results for the group. |
### CodeCoverageSummary
Represents the code coverage summary for a project.
......
......@@ -66,6 +66,13 @@ module EE
null: false,
description: 'Represents vulnerable project counts for each grade',
resolver: ::Resolvers::VulnerabilitiesGradeResolver
field :code_coverage_activities,
::Types::Ci::CodeCoverageActivityType.connection_type,
null: true,
description: 'Represents the code coverage activity for this group',
resolver: ::Resolvers::Ci::CodeCoverageActivitiesResolver,
feature_flag: :group_coverage_data_report
end
end
end
......
# frozen_string_literal: true
module Resolvers
module Ci
class CodeCoverageActivitiesResolver < BaseResolver
type ::Types::Ci::CodeCoverageActivityType, null: true
argument :start_date, Types::DateType,
required: true,
description: 'First day for which to fetch code coverage activity (maximum time window is set to 90 days)'
alias_method :group, :object
def resolve(**args)
project_ids = group.projects.select(:id)
start_date = args[:start_date].to_s
::Ci::DailyBuildGroupReportResult
.with_included_projects
.by_projects(project_ids)
.with_coverage
.with_default_branch
.by_date(start_date)
.activity_per_group
end
end
end
end
# frozen_string_literal: true
module Types
module Ci
# rubocop: disable Graphql/AuthorizeTypes
class CodeCoverageActivityType < BaseObject
graphql_name 'CodeCoverageActivity'
description 'Represents the code coverage activity for a group'
field :average_coverage, GraphQL::FLOAT_TYPE, null: true,
description: 'Average percentage of the different code coverage results available for the group.'
field :coverage_count, GraphQL::INT_TYPE, null: true,
description: 'Number of different code coverage results available for the group.'
field :project_count, GraphQL::INT_TYPE, null: true,
description: 'Number of projects with code coverage results for the group.'
field :date, Types::DateType, null: false,
description: 'Date when the code coverage was created.'
end
end
end
......@@ -34,6 +34,23 @@ module EE
}
end
end
def self.activity_per_group
group(:date).pluck(
Arel.sql("AVG(cast(data ->> 'coverage' AS FLOAT))"),
Arel.sql("COUNT(*)"),
Arel.sql("COUNT(DISTINCT ci_daily_build_group_report_results.project_id)"),
Arel.sql("date")
)
.each_with_object([]) do |(average_coverage, coverage_count, project_count, date), result|
result << {
average_coverage: average_coverage,
coverage_count: coverage_count,
project_count: project_count,
date: date
}
end
end
end
end
end
......
---
title: Add GraphQL endpoint for Code Coverage Activity for the group
merge_request: 45831
author:
type: added
......@@ -16,6 +16,7 @@ RSpec.describe GitlabSchema.types['Group'] do
it { expect(described_class).to have_graphql_field(:vulnerability_scanners) }
it { expect(described_class).to have_graphql_field(:vulnerabilities_count_by_day_and_severity) }
it { expect(described_class).to have_graphql_field(:vulnerability_grades) }
it { expect(described_class).to have_graphql_field(:code_coverage_activities) }
describe 'timelogs field' do
subject { described_class.fields['timelogs'] }
......@@ -69,4 +70,35 @@ RSpec.describe GitlabSchema.types['Group'] do
expect(vulnerabilities.first['severity']).to eq('CRITICAL')
end
end
describe 'codeCoverageActivities' do
let(:group) { create(:group) }
let(:user) { create(:user) }
let(:start_date) { 1.day.ago.to_date.to_s }
let(:query) do
%(
query {
group(fullPath: "#{group.full_path}") {
codeCoverageActivities(startDate: "#{start_date}") {
nodes {
averageCoverage
}
}
}
}
)
end
context 'when group_coverage_data_report flag is disabled' do
subject { GitlabSchema.execute(query, context: { current_user: user }).as_json }
it 'returns a graphQL error field does not exist' do
stub_feature_flags(group_coverage_data_report: false)
expected_message = "Field 'codeCoverageActivities' doesn't exist on type 'Group'"
expect(subject.dig('errors').first.dig('message')).to eq(expected_message)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Ci::CodeCoverageActivitiesResolver do
include GraphqlHelpers
it { expect(described_class.type).to eq(Types::Ci::CodeCoverageActivityType) }
it { expect(described_class.null).to be_truthy }
describe '#resolve' do
let_it_be(:current_user) { create(:user) }
let_it_be(:project_1) { create(:project) }
let_it_be(:project_2) { create(:project) }
let_it_be(:group) { create(:group, projects: [project_1, project_2]) }
let(:start_date) { 1.day.ago.to_date.to_s }
context 'when group has projects with coverage' do
let!(:coverage_1) { create(:ci_daily_build_group_report_result, project: project_1) }
let!(:coverage_2) { create(:ci_daily_build_group_report_result, project: project_2) }
it 'returns coverage activity for the group' do
expected_results = expected_activities(
average_coverage: 77.0,
coverage_count: 2,
date: Date.current,
project_count: 2
)
results = resolve_coverages(start_date: start_date)
expect(results).to contain_exactly(expected_results)
end
end
context 'when group has projects without coverage' do
it 'returns an empty collection' do
results = resolve_coverages(start_date: start_date)
expect(results).to be_empty
end
end
context 'when coverage is included within start date' do
let!(:coverage_1) { create(:ci_daily_build_group_report_result, project: project_1, date: 1.week.ago) }
let!(:coverage_2) { create(:ci_daily_build_group_report_result, project: project_1, date: 1.week.ago, group_name: 'karma') }
let(:start_date) { 1.week.ago.to_date.to_s }
it 'returns coverage from the start_date' do
expected_results = expected_activities(
average_coverage: 77.0,
coverage_count: 2,
date: 1.week.ago,
project_count: 1
)
results = resolve_coverages(start_date: start_date)
expect(results).to contain_exactly(expected_results)
end
end
context 'when coverage is not included within start date' do
let!(:coverage_1) { create(:ci_daily_build_group_report_result, project: project_1, date: 1.week.ago) }
let!(:coverage_2) { create(:ci_daily_build_group_report_result, project: project_1, date: 2.weeks.ago) }
it 'returns an empty collection' do
results = resolve_coverages(start_date: start_date)
expect(results).to be_empty
end
end
end
def resolve_coverages(args = {}, context = { current_user: current_user })
resolve(described_class, obj: group, args: args, ctx: context)
end
def expected_activities(args = {})
{
average_coverage: args[:average_coverage],
coverage_count: args[:coverage_count],
date: args[:date].to_date,
project_count: args[:project_count]
}
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['CodeCoverageActivity'] do
let(:fields) { %i[average_coverage coverage_count project_count date] }
it { expect(described_class.graphql_name).to eq('CodeCoverageActivity') }
it { expect(described_class).to have_graphql_fields(fields) }
end
......@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe Ci::DailyBuildGroupReportResult do
let_it_be(:project) { create(:project) }
let_it_be(:group) { create(:group, projects: [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)
......@@ -66,5 +67,58 @@ RSpec.describe Ci::DailyBuildGroupReportResult do
end
end
end
describe '.activity_per_group' do
subject(:activity) { described_class.activity_per_group }
context 'when group has project with several coverage' do
let!(:coverage_1) { create(:ci_daily_build_group_report_result, project: project) }
let!(:coverage_2) { create(:ci_daily_build_group_report_result, project: project, group_name: 'karma', coverage: 88.8) }
it 'returns coverage activity for the group' do
expected_results = expected_activities(
average_coverage: 82.9,
coverage_count: 2,
date: Date.current,
project_count: 1
)
expect(activity).to contain_exactly(expected_results)
end
end
context 'when group has projects with several coverage' do
let!(:project_2) { create(:project) }
let!(:group) { create(:group, projects: [project, project_2]) }
let!(:coverage_1) { create(:ci_daily_build_group_report_result, project: project) }
let!(:coverage_2) { create(:ci_daily_build_group_report_result, project: project_2, group_name: 'karma') }
it 'returns coverage activity for the group' do
expected_results = expected_activities(
average_coverage: 77.0,
coverage_count: 2,
date: Date.current,
project_count: 2
)
expect(subject).to contain_exactly(expected_results)
end
end
context 'when group has projects without coverage' do
it 'returns an empty collection' do
expect(activity).to be_empty
end
end
end
end
def expected_activities(args = {})
{
average_coverage: args[:average_coverage],
coverage_count: args[:coverage_count],
date: args[:date].to_date,
project_count: args[:project_count]
}
end
end
......@@ -234,4 +234,45 @@ RSpec.describe 'getting group information' do
)
end
end
describe 'pagination' do
let_it_be(:current_user) { create(:user) }
let_it_be(:project_1) { create(:project) }
let_it_be(:project_2) { create(:project) }
let_it_be(:group) { create(:group, projects: [project_1, project_2]) }
let(:data_path) { [:group, :codeCoverageActivities] }
def pagination_query(params, page_info)
graphql_query_for(
'group',
{ 'fullPath' => group.full_path },
<<~QUERY
codeCoverageActivities(startDate: "#{start_date}" #{params}) {
#{page_info} edges {
node {
averageCoverage
}
}
}
QUERY
)
end
def pagination_results_data(data)
data.map { |coverage| coverage.dig('node', 'averageCoverage') }
end
context 'when default sorting' do
let!(:coverage_1) { create(:ci_daily_build_group_report_result, project: project_1) }
let!(:coverage_2) { create(:ci_daily_build_group_report_result, project: project_2, coverage: 88.8, date: 1.week.ago) }
let(:start_date) { 1.week.ago.to_date.to_s }
it_behaves_like 'sorted paginated query' do
let(:sort_param) { }
let(:first_param) { 2 }
let(:expected_results) { [88.8, 77.0] }
end
end
end
end
......@@ -123,5 +123,39 @@ RSpec.describe Ci::DailyBuildGroupReportResult do
end
end
end
describe '.by_date' do
subject(:coverages) { described_class.by_date(start_date) }
let!(:coverage_1) { create(:ci_daily_build_group_report_result, date: 1.week.ago) }
context 'when project has several coverage' do
let!(:coverage_2) { create(:ci_daily_build_group_report_result, date: 2.weeks.ago) }
let(:start_date) { 1.week.ago.to_date.to_s }
it 'returns the coverage from the start_date' do
expect(coverages).to contain_exactly(coverage_1)
end
end
context 'when start_date is over 90 days' do
let!(:coverage_2) { create(:ci_daily_build_group_report_result, date: 90.days.ago) }
let!(:coverage_3) { create(:ci_daily_build_group_report_result, date: 91.days.ago) }
let(:start_date) { 1.year.ago.to_date.to_s }
it 'returns the coverage in the last 90 days' do
expect(coverages).to contain_exactly(coverage_1, coverage_2)
end
end
context 'when start_date is not a string' do
let!(:coverage_2) { create(:ci_daily_build_group_report_result, date: 90.days.ago) }
let(:start_date) { 1.week.ago }
it 'returns the coverage in the last 90 days' do
expect(coverages).to contain_exactly(coverage_1, coverage_2)
end
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