Commit 177c3f16 authored by Adam Hegyi's avatar Adam Hegyi

Add latest snapshot to segments

- Expose the latest snapshot for segments
- Move all segment specific code to EE
- Use array for the groups association
parent f20bdc4f
---
title: Add analytics_devops_adoption_snapshots table
merge_request: 47388
author:
type: other
# frozen_string_literal: true
class CreateAnalyticsDevopsAdoptionSnapshots < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
create_table :analytics_devops_adoption_snapshots do |t|
t.references :segment, index: false, null: false, foreign_key: { to_table: :analytics_devops_adoption_segments, on_delete: :cascade }
t.datetime_with_timezone :recorded_at, null: false
t.boolean :issue_opened, null: false
t.boolean :merge_request_opened, null: false
t.boolean :merge_request_approved, null: false
t.boolean :runner_configured, null: false
t.boolean :pipeline_succeeded, null: false
t.boolean :deploy_succeeded, null: false
t.boolean :security_scan_succeeded, null: false
t.index [:segment_id, :recorded_at], name: 'index_on_snapshots_segment_id_recorded_at'
end
end
end
7a905f8e636be21e328a622d9871018903982989836e6e0def09fd2c2826691f
\ No newline at end of file
......@@ -8990,6 +8990,28 @@ CREATE SEQUENCE analytics_devops_adoption_segments_id_seq
ALTER SEQUENCE analytics_devops_adoption_segments_id_seq OWNED BY analytics_devops_adoption_segments.id;
CREATE TABLE analytics_devops_adoption_snapshots (
id bigint NOT NULL,
segment_id bigint NOT NULL,
recorded_at timestamp with time zone NOT NULL,
issue_opened boolean NOT NULL,
merge_request_opened boolean NOT NULL,
merge_request_approved boolean NOT NULL,
runner_configured boolean NOT NULL,
pipeline_succeeded boolean NOT NULL,
deploy_succeeded boolean NOT NULL,
security_scan_succeeded boolean NOT NULL
);
CREATE SEQUENCE analytics_devops_adoption_snapshots_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE analytics_devops_adoption_snapshots_id_seq OWNED BY analytics_devops_adoption_snapshots.id;
CREATE TABLE analytics_instance_statistics_measurements (
id bigint NOT NULL,
count bigint NOT NULL,
......@@ -17773,6 +17795,8 @@ ALTER TABLE ONLY analytics_devops_adoption_segment_selections ALTER COLUMN id SE
ALTER TABLE ONLY analytics_devops_adoption_segments ALTER COLUMN id SET DEFAULT nextval('analytics_devops_adoption_segments_id_seq'::regclass);
ALTER TABLE ONLY analytics_devops_adoption_snapshots ALTER COLUMN id SET DEFAULT nextval('analytics_devops_adoption_snapshots_id_seq'::regclass);
ALTER TABLE ONLY analytics_instance_statistics_measurements ALTER COLUMN id SET DEFAULT nextval('analytics_instance_statistics_measurements_id_seq'::regclass);
ALTER TABLE ONLY appearances ALTER COLUMN id SET DEFAULT nextval('appearances_id_seq'::regclass);
......@@ -18754,6 +18778,9 @@ ALTER TABLE ONLY analytics_devops_adoption_segment_selections
ALTER TABLE ONLY analytics_devops_adoption_segments
ADD CONSTRAINT analytics_devops_adoption_segments_pkey PRIMARY KEY (id);
ALTER TABLE ONLY analytics_devops_adoption_snapshots
ADD CONSTRAINT analytics_devops_adoption_snapshots_pkey PRIMARY KEY (id);
ALTER TABLE ONLY analytics_instance_statistics_measurements
ADD CONSTRAINT analytics_instance_statistics_measurements_pkey PRIMARY KEY (id);
......@@ -21632,6 +21659,8 @@ CREATE UNIQUE INDEX index_on_segment_selections_project_id_segment_id ON analyti
CREATE INDEX index_on_segment_selections_segment_id ON analytics_devops_adoption_segment_selections USING btree (segment_id);
CREATE INDEX index_on_snapshots_segment_id_recorded_at ON analytics_devops_adoption_snapshots USING btree (segment_id, recorded_at);
CREATE INDEX index_on_users_lower_email ON users USING btree (lower((email)::text));
CREATE INDEX index_on_users_lower_username ON users USING btree (lower((username)::text));
......@@ -23742,6 +23771,9 @@ ALTER TABLE ONLY saml_group_links
ALTER TABLE ONLY group_custom_attributes
ADD CONSTRAINT fk_rails_246e0db83a FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE;
ALTER TABLE ONLY analytics_devops_adoption_snapshots
ADD CONSTRAINT fk_rails_25da9a92c0 FOREIGN KEY (segment_id) REFERENCES analytics_devops_adoption_segments(id) ON DELETE CASCADE;
ALTER TABLE ONLY cluster_agents
ADD CONSTRAINT fk_rails_25e9fc2d5d FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
......
......@@ -6539,33 +6539,18 @@ type DevopsAdoptionSegment {
"""
Assigned groups
"""
groups(
"""
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
): GroupConnection
groups: [Group!]
"""
ID of the segment
"""
id: ID!
"""
The latest adoption metrics for the segment
"""
latestSnapshot: DevopsAdoptionSnapshot
"""
Name of the segment
"""
......@@ -6607,6 +6592,61 @@ type DevopsAdoptionSegmentEdge {
node: DevopsAdoptionSegment
}
"""
Snapshot
"""
type DevopsAdoptionSnapshot {
"""
At least one deployment succeeded
"""
deploySucceeded: Boolean!
"""
The end time for the snapshot where the data points were collected
"""
endTime: Time!
"""
At least one issue was opened
"""
issueOpened: Boolean!
"""
At least one merge request was approved
"""
mergeRequestApproved: Boolean!
"""
At least one merge request was opened
"""
mergeRequestOpened: Boolean!
"""
At least one pipeline succeeded
"""
pipelineSucceeded: Boolean!
"""
The time the snapshot was recorded
"""
recordedAt: Time!
"""
At least one runner was used
"""
runnerConfigured: Boolean!
"""
At least one security scan succeeded
"""
securityScanSucceeded: Boolean!
"""
The start time for the snapshot where the data points were collected
"""
startTime: Time!
}
input DiffImagePositionInput {
"""
Merge base of the branch the comment was made on
......@@ -9872,41 +9912,6 @@ type Group {
webUrl: String!
}
"""
The connection type for Group.
"""
type GroupConnection {
"""
A list of edges.
"""
edges: [GroupEdge]
"""
A list of nodes.
"""
nodes: [Group]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type GroupEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: Group
}
"""
Identifier of Group
"""
......
......@@ -1104,10 +1104,28 @@ Segment.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `groups` | GroupConnection | Assigned groups |
| `groups` | Group! => Array | Assigned groups |
| `id` | ID! | ID of the segment |
| `latestSnapshot` | DevopsAdoptionSnapshot | The latest adoption metrics for the segment |
| `name` | String! | Name of the segment |
### DevopsAdoptionSnapshot
Snapshot.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `deploySucceeded` | Boolean! | At least one deployment succeeded |
| `endTime` | Time! | The end time for the snapshot where the data points were collected |
| `issueOpened` | Boolean! | At least one issue was opened |
| `mergeRequestApproved` | Boolean! | At least one merge request was approved |
| `mergeRequestOpened` | Boolean! | At least one merge request was opened |
| `pipelineSucceeded` | Boolean! | At least one pipeline succeeded |
| `recordedAt` | Time! | The time the snapshot was recorded |
| `runnerConfigured` | Boolean! | At least one runner was used |
| `securityScanSucceeded` | Boolean! | At least one security scan succeeded |
| `startTime` | Time! | The start time for the snapshot where the data points were collected |
### DiffPosition
| Field | Type | Description |
......
......@@ -15,8 +15,23 @@ module Types
field :name, GraphQL::STRING_TYPE, null: false,
description: 'Name of the segment'
field :groups, Types::GroupType.connection_type, null: true,
field :groups, [Types::GroupType], null: true,
description: 'Assigned groups'
field :latest_snapshot, SnapshotType, null: true,
description: 'The latest adoption metrics for the segment'
def latest_snapshot
BatchLoader::GraphQL.for(object.id).batch(key: :devops_adoption_latest_snapshots) do |ids, loader, args|
snapshots = ::Analytics::DevopsAdoption::Snapshot
.latest_snapshot_for_segment_ids(ids)
.index_by(&:segment_id)
ids.each do |id|
loader.call(id, snapshots[id])
end
end
end
end
end
end
......
# frozen_string_literal: true
# rubocop:disable Graphql/AuthorizeTypes
module Types
module Admin
module Analytics
module DevopsAdoption
class SnapshotType < BaseObject
graphql_name 'DevopsAdoptionSnapshot'
description 'Snapshot'
field :issue_opened, GraphQL::BOOLEAN_TYPE, null: false,
description: 'At least one issue was opened'
field :merge_request_opened, GraphQL::BOOLEAN_TYPE, null: false,
description: 'At least one merge request was opened'
field :merge_request_approved, GraphQL::BOOLEAN_TYPE, null: false,
description: 'At least one merge request was approved'
field :runner_configured, GraphQL::BOOLEAN_TYPE, null: false,
description: 'At least one runner was used'
field :pipeline_succeeded, GraphQL::BOOLEAN_TYPE, null: false,
description: 'At least one pipeline succeeded'
field :deploy_succeeded, GraphQL::BOOLEAN_TYPE, null: false,
description: 'At least one deployment succeeded'
field :security_scan_succeeded, GraphQL::BOOLEAN_TYPE, null: false,
description: 'At least one security scan succeeded'
field :recorded_at, Types::TimeType, null: false,
description: 'The time the snapshot was recorded'
field :start_time, Types::TimeType, null: false,
description: 'The start time for the snapshot where the data points were collected'
field :end_time, Types::TimeType, null: false,
description: 'The end time for the snapshot where the data points were collected'
end
end
end
end
end
......@@ -5,6 +5,8 @@ class Analytics::DevopsAdoption::Segment < ApplicationRecord
has_many :segment_selections
has_many :groups, through: :segment_selections
has_many :snapshots, inverse_of: :segment
has_one :latest_snapshot, -> { order(recorded_at: :desc) }, inverse_of: :segment, class_name: 'Snapshot'
validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
validate :validate_segment_count, on: :create
......
# frozen_string_literal: true
class Analytics::DevopsAdoption::Snapshot < ApplicationRecord
SNAPSHOT_TIME_PERIOD = 1.month
belongs_to :segment, inverse_of: :snapshots
validates :segment, presence: true
validates :recorded_at, presence: true
validates :issue_opened, inclusion: { in: [true, false] }
validates :merge_request_opened, inclusion: { in: [true, false] }
validates :merge_request_approved, inclusion: { in: [true, false] }
validates :runner_configured, inclusion: { in: [true, false] }
validates :pipeline_succeeded, inclusion: { in: [true, false] }
validates :deploy_succeeded, inclusion: { in: [true, false] }
validates :security_scan_succeeded, inclusion: { in: [true, false] }
scope :latest_snapshot_for_segment_ids, -> (ids) do
inner_select = model
.default_scoped
.distinct
.select("FIRST_VALUE(id) OVER (PARTITION BY segment_id ORDER BY recorded_at DESC) as id")
.where(segment_id: ids)
joins("INNER JOIN (#{inner_select.to_sql}) latest_snapshots ON latest_snapshots.id = analytics_devops_adoption_snapshots.id")
end
def start_time
(recorded_at - SNAPSHOT_TIME_PERIOD).at_beginning_of_day
end
def end_time
recorded_at
end
end
---
title: Expose latest snapshot for devops adoptions in GraphQL
merge_request: 47388
author:
type: changed
# frozen_string_literal: true
Gitlab::Seeder.quiet do
admin = User.where(admin: true).first
if admin.nil?
puts "No admin user present"
next
end
groups = Group.take(5)
next if groups.empty?
segment_groups_1 = groups.sample(2)
segment_groups_2 = groups.sample(3)
ActiveRecord::Base.transaction do
segment_1 = Analytics::DevopsAdoption::Segments::CreateService.new(params: { name: 'Segment 1', groups: segment_groups_1 }, current_user: admin).execute
segment_2 = Analytics::DevopsAdoption::Segments::CreateService.new(params: { name: 'Segment 2', groups: segment_groups_2 }, current_user: admin).execute
segments = [segment_1.payload[:segment], segment_2.payload[:segment]]
if segments.any?(&:invalid?)
puts "Error creating segments"
puts "#{segments.map(&:errors)}"
next
end
booleans = [true, false]
# create snapshots for the last 5 months
5.downto(1).each do |index|
recorded_at = index.months.ago.at_end_of_month
segments.each do |segment|
Analytics::DevopsAdoption::Snapshot.create!(
segment: segment,
issue_opened: booleans.sample,
merge_request_opened: booleans.sample,
merge_request_approved: booleans.sample,
runner_configured: booleans.sample,
pipeline_succeeded: booleans.sample,
deploy_succeeded: booleans.sample,
security_scan_succeeded: booleans.sample,
recorded_at: recorded_at
)
segment.update!(last_recorded_at: recorded_at)
end
end
end
print '.'
end
# frozen_string_literal: true
FactoryBot.define do
factory :devops_adoption_snapshot, class: 'Analytics::DevopsAdoption::Snapshot' do
association :segment, factory: :devops_adoption_segment
recorded_at { Time.zone.now }
issue_opened { true }
merge_request_opened { false }
merge_request_approved { false }
runner_configured { true }
pipeline_succeeded { false }
deploy_succeeded { true }
security_scan_succeeded { false }
end
end
......@@ -3,58 +3,70 @@
require 'spec_helper'
RSpec.describe Analytics::DevopsAdoption::Segment, type: :model do
subject { build(:devops_adoption_segment) }
describe 'associations' do
subject { build(:devops_adoption_segment) }
it { is_expected.to have_many(:segment_selections) }
it { is_expected.to have_many(:groups) }
it { is_expected.to have_many(:snapshots) }
end
describe 'validation' do
subject { build(:devops_adoption_segment) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_uniqueness_of(:name) }
it { is_expected.to validate_length_of(:name).is_at_most(255) }
context 'limit the number of segments' do
subject { build(:devops_adoption_segment) }
subject(:segment) { build(:devops_adoption_segment) }
before do
it 'shows validation error' do
create_list(:devops_adoption_segment, 2)
stub_const("#{described_class}::ALLOWED_SEGMENT_COUNT", 2)
end
it 'shows validation error' do
subject.validate
segment.validate
expect(subject.errors[:name]).to eq([s_('DevopsAdoptionSegment|The maximum number of segments has been reached')])
expect(segment.errors[:name]).to eq([s_('DevopsAdoptionSegment|The maximum number of segments has been reached')])
end
end
end
describe '.ordered_by_name' do
let(:segment_1) { create(:devops_adoption_segment, name: 'bbb') }
let(:segment_2) { create(:devops_adoption_segment, name: 'aaa') }
subject { described_class.ordered_by_name }
subject(:segments) { described_class.ordered_by_name }
it 'orders segments by name' do
expect(subject).to eq([segment_2, segment_1])
segment_1 = create(:devops_adoption_segment, name: 'bbb')
segment_2 = create(:devops_adoption_segment, name: 'aaa')
expect(segments).to eq([segment_2, segment_1])
end
end
describe 'length validation on accepts_nested_attributes_for for segment_selections' do
let(:group_1) { create(:group) }
let(:group_2) { create(:group) }
subject { described_class.create!(name: 'test', segment_selections_attributes: [{ group: group_1 }]) }
describe '.latest_snapshot' do
it 'loads the latest snapshot' do
segment = create(:devops_adoption_segment, name: 'test_segment')
latest_snapshot = create(:devops_adoption_snapshot, segment: segment, recorded_at: 2.days.ago)
create(:devops_adoption_snapshot, segment: segment, recorded_at: 5.days.ago)
create(:devops_adoption_snapshot, segment: create(:devops_adoption_segment), recorded_at: 1.hour.ago)
before do
stub_const("Analytics::DevopsAdoption::SegmentSelection::ALLOWED_SELECTIONS_PER_SEGMENT", 1)
expect(segment.latest_snapshot).to eq(latest_snapshot)
end
end
describe 'length validation on accepts_nested_attributes_for for segment_selections' do
it 'validates the number of segment_selections' do
stub_const("Analytics::DevopsAdoption::SegmentSelection::ALLOWED_SELECTIONS_PER_SEGMENT", 1)
group_1 = create(:group)
group_2 = create(:group)
segment = create(:devops_adoption_segment, segment_selections_attributes: [{ group: group_1 }])
selections = [{ group: group_1, _destroy: 1 }, { group: group_2 }]
subject.assign_attributes(segment_selections_attributes: selections)
segment.assign_attributes(segment_selections_attributes: selections)
expect(subject).to be_invalid
expect(subject.errors[:"segment_selections.segment"]).to eq([s_('DevopsAdoptionSegmentSelection|The maximum number of selections has been reached')])
expect(segment).to be_invalid
expect(segment.errors[:"segment_selections.segment"]).to eq([s_('DevopsAdoptionSegmentSelection|The maximum number of selections has been reached')])
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Analytics::DevopsAdoption::Snapshot, type: :model do
it { is_expected.to belong_to(:segment) }
it { is_expected.to validate_presence_of(:segment) }
it { is_expected.to validate_presence_of(:recorded_at) }
describe '.latest_snapshot_for_segment_ids' do
it 'returns the latest snapshot for the given segment ids' do
segment_1 = create(:devops_adoption_segment)
segment_1_latest_snapshot = create(:devops_adoption_snapshot, segment: segment_1, recorded_at: 1.week.ago)
create(:devops_adoption_snapshot, segment: segment_1, recorded_at: 2.weeks.ago)
segment_2 = create(:devops_adoption_segment)
segment_2_latest_snapshot = create(:devops_adoption_snapshot, segment: segment_2, recorded_at: 1.year.ago)
create(:devops_adoption_snapshot, segment: segment_2, recorded_at: 2.years.ago)
latest_snapshot_for_segments = described_class.latest_snapshot_for_segment_ids([segment_1.id, segment_2.id])
expect(latest_snapshot_for_segments).to match_array([segment_1_latest_snapshot, segment_2_latest_snapshot])
end
end
describe '#end_time' do
subject(:segment) { described_class.new(recorded_at: 5.days.ago) }
it 'equals to recorded_at' do
expect(segment.end_time).to eq(segment.recorded_at)
end
end
describe '#start_time' do
subject(:segment) { described_class.new(recorded_at: 3.days.ago) }
it 'calcualtes a one-month period from end_time' do
expected_end_time = (segment.end_time - 1.month).at_beginning_of_day
expect(segment.start_time).to eq(expected_end_time)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'DevopsAdoptionSegments' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user, :admin) }
let_it_be(:group) { create(:group, name: 'my-group') }
let_it_be(:segment) do
create(:devops_adoption_segment, name: 'segment').tap do |segment|
create(:devops_adoption_segment_selection, :group, segment: segment, group: group)
create(:devops_adoption_snapshot, segment: segment, issue_opened: true, merge_request_opened: false)
end
end
let_it_be(:empty_segment) { create(:devops_adoption_segment, name: 'empty segment') }
let(:query) do
graphql_query_for(:devopsAdoptionSegments, {}, %(
nodes {
id
name
groups {
name
}
latestSnapshot {
issueOpened
mergeRequestOpened
}
}
))
end
before do
post_graphql(query, current_user: current_user)
end
it 'returns measurement objects' do
expect(graphql_data['devopsAdoptionSegments']['nodes']).to eq([
{
'id' => empty_segment.to_gid.to_s,
'name' => empty_segment.name,
'groups' => [],
'latestSnapshot' => nil
},
{
'id' => segment.to_gid.to_s,
'name' => segment.name,
'groups' => [{ 'name' => group.name }],
'latestSnapshot' => {
'mergeRequestOpened' => false,
'issueOpened' => true
}
}
])
end
end
......@@ -20,10 +20,8 @@ RSpec.describe Mutations::Admin::Analytics::DevopsAdoption::Segments::Create do
id
name
groups {
nodes {
id
name
}
id
name
}
}
QL
......@@ -48,7 +46,7 @@ RSpec.describe Mutations::Admin::Analytics::DevopsAdoption::Segments::Create do
segment = mutation_response['segment']
expect(segment['name']).to eq('my segment')
group_names = segment['groups']['nodes'].map { |node| node['name'] }
group_names = segment['groups'].map { |node| node['name'] }
expect(group_names).to match_array(%w[aaaa bbbb cccc])
end
......@@ -60,7 +58,7 @@ RSpec.describe Mutations::Admin::Analytics::DevopsAdoption::Segments::Create do
end
it 'creates an empty segment' do
expect(mutation_response['segment']['groups']['nodes']).to be_empty
expect(mutation_response['segment']['groups']).to be_empty
end
end
end
......@@ -22,10 +22,8 @@ RSpec.describe Mutations::Admin::Analytics::DevopsAdoption::Segments::Update do
id
name
groups {
nodes {
id
name
}
id
name
}
}
QL
......@@ -52,7 +50,7 @@ RSpec.describe Mutations::Admin::Analytics::DevopsAdoption::Segments::Update do
segment = mutation_response['segment']
expect(segment['name']).to eq('new name')
group_names = segment['groups']['nodes'].map { |node| node['name'] }
group_names = segment['groups'].map { |node| node['name'] }
expect(group_names).to match_array(%w[aaaa bbbb cccc])
end
......@@ -74,7 +72,7 @@ RSpec.describe Mutations::Admin::Analytics::DevopsAdoption::Segments::Update do
end
it 'removes all selections' do
expect(mutation_response['segment']['groups']['nodes']).to be_empty
expect(mutation_response['segment']['groups']).to be_empty
end
end
end
......@@ -9525,6 +9525,9 @@ msgstr ""
msgid "DevopsAdoptionSegment|The maximum number of segments has been reached"
msgstr ""
msgid "DevopsAdoptionSegment|The maximum number of selections has been reached"
msgstr ""
msgid "DevopsAdoption|%{selectedCount} group selected (20 max)"
msgstr ""
......
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