Commit e261c79f authored by Vitali Tatarintev's avatar Vitali Tatarintev

Merge branch 'mo-code-coverage-group-graphql' into 'master'

Add GraphQL endpoint for code coverage group activity

See merge request gitlab-org/gitlab!45831
parents d90dd0de 25641627
......@@ -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