Commit 76c7788b authored by Peter Leitzen's avatar Peter Leitzen

Merge branch '212226-move-vsm-controller-to-group-level' into 'master'

Make VSM controllers available at the Group level

Closes #212226

See merge request gitlab-org/gitlab!28641
parents 33ed8a64 d16700bd
...@@ -89,7 +89,6 @@ export const fetchStageMedianValues = ({ state, dispatch, getters }) => { ...@@ -89,7 +89,6 @@ export const fetchStageMedianValues = ({ state, dispatch, getters }) => {
const { stages } = state; const { stages } = state;
const params = { const params = {
group_id: currentGroupPath,
created_after, created_after,
created_before, created_before,
project_ids, project_ids,
...@@ -191,8 +190,7 @@ export const fetchSummaryData = ({ state, dispatch, getters }) => { ...@@ -191,8 +190,7 @@ export const fetchSummaryData = ({ state, dispatch, getters }) => {
selectedGroup: { fullPath }, selectedGroup: { fullPath },
} = state; } = state;
return Api.cycleAnalyticsSummaryData({ return Api.cycleAnalyticsSummaryData(fullPath, {
group_id: fullPath,
created_after, created_after,
created_before, created_before,
project_ids, project_ids,
...@@ -230,11 +228,10 @@ export const fetchTopRankedGroupLabels = ({ ...@@ -230,11 +228,10 @@ export const fetchTopRankedGroupLabels = ({
dispatch('requestTopRankedGroupLabels'); dispatch('requestTopRankedGroupLabels');
const { subject } = state.tasksByType; const { subject } = state.tasksByType;
return Api.cycleAnalyticsTopLabels({ return Api.cycleAnalyticsTopLabels(currentGroupPath, {
subject, subject,
created_after, created_after,
created_before, created_before,
group_id: currentGroupPath,
}) })
.then(({ data }) => dispatch('receiveTopRankedGroupLabelsSuccess', data)) .then(({ data }) => dispatch('receiveTopRankedGroupLabelsSuccess', data))
.catch(error => .catch(error =>
...@@ -359,7 +356,6 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => { ...@@ -359,7 +356,6 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => {
// dont request if we have no labels selected...for now // dont request if we have no labels selected...for now
if (selectedLabelIds.length) { if (selectedLabelIds.length) {
const params = { const params = {
group_id: currentGroupPath,
created_after, created_after,
created_before, created_before,
project_ids, project_ids,
...@@ -369,7 +365,7 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => { ...@@ -369,7 +365,7 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => {
dispatch('requestTasksByTypeData'); dispatch('requestTasksByTypeData');
return Api.cycleAnalyticsTasksByType(params) return Api.cycleAnalyticsTasksByType(currentGroupPath, params)
.then(({ data }) => dispatch('receiveTasksByTypeDataSuccess', data)) .then(({ data }) => dispatch('receiveTasksByTypeDataSuccess', data))
.catch(error => dispatch('receiveTasksByTypeDataError', error)); .catch(error => dispatch('receiveTasksByTypeDataError', error));
} }
...@@ -472,8 +468,7 @@ export const fetchDurationData = ({ state, dispatch, getters }) => { ...@@ -472,8 +468,7 @@ export const fetchDurationData = ({ state, dispatch, getters }) => {
stages.map(stage => { stages.map(stage => {
const { slug } = stage; const { slug } = stage;
return Api.cycleAnalyticsDurationChart(slug, { return Api.cycleAnalyticsDurationChart(fullPath, slug, {
group_id: fullPath,
created_after, created_after,
created_before, created_before,
project_ids, project_ids,
...@@ -521,8 +516,7 @@ export const fetchDurationMedianData = ({ state, dispatch, getters }) => { ...@@ -521,8 +516,7 @@ export const fetchDurationMedianData = ({ state, dispatch, getters }) => {
stages.map(stage => { stages.map(stage => {
const { slug } = stage; const { slug } = stage;
return Api.cycleAnalyticsDurationChart(slug, { return Api.cycleAnalyticsDurationChart(fullPath, slug, {
group_id: fullPath,
created_after: dateFormat(offsetCreatedAfter, dateFormats.isoDate), created_after: dateFormat(offsetCreatedAfter, dateFormats.isoDate),
created_before: dateFormat(offsetCreatedBefore, dateFormats.isoDate), created_before: dateFormat(offsetCreatedBefore, dateFormats.isoDate),
project_ids, project_ids,
......
...@@ -13,15 +13,17 @@ export default { ...@@ -13,15 +13,17 @@ export default {
groupPackagesPath: '/api/:version/groups/:id/packages', groupPackagesPath: '/api/:version/groups/:id/packages',
projectPackagesPath: '/api/:version/projects/:id/packages', projectPackagesPath: '/api/:version/projects/:id/packages',
projectPackagePath: '/api/:version/projects/:id/packages/:package_id', projectPackagePath: '/api/:version/projects/:id/packages/:package_id',
cycleAnalyticsTasksByTypePath: '/-/analytics/type_of_work/tasks_by_type', cycleAnalyticsTasksByTypePath: '/groups/:id/-/analytics/type_of_work/tasks_by_type',
cycleAnalyticsTopLabelsPath: '/-/analytics/type_of_work/tasks_by_type/top_labels', cycleAnalyticsTopLabelsPath: '/groups/:id/-/analytics/type_of_work/tasks_by_type/top_labels',
cycleAnalyticsSummaryDataPath: '/-/analytics/value_stream_analytics/summary', cycleAnalyticsSummaryDataPath: '/groups/:id/-/analytics/value_stream_analytics/summary',
cycleAnalyticsGroupStagesAndEventsPath: '/-/analytics/value_stream_analytics/stages', cycleAnalyticsGroupStagesAndEventsPath: '/groups/:id/-/analytics/value_stream_analytics/stages',
cycleAnalyticsStageEventsPath: '/-/analytics/value_stream_analytics/stages/:stage_id/records', cycleAnalyticsStageEventsPath:
cycleAnalyticsStageMedianPath: '/-/analytics/value_stream_analytics/stages/:stage_id/median', '/groups/:id/-/analytics/value_stream_analytics/stages/:stage_id/records',
cycleAnalyticsStagePath: '/-/analytics/value_stream_analytics/stages/:stage_id', cycleAnalyticsStageMedianPath:
'/groups/:id/-/analytics/value_stream_analytics/stages/:stage_id/median',
cycleAnalyticsStagePath: '/groups/:id/-/analytics/value_stream_analytics/stages/:stage_id',
cycleAnalyticsDurationChartPath: cycleAnalyticsDurationChartPath:
'/-/analytics/value_stream_analytics/stages/:stage_id/duration_chart', '/groups/:id/-/analytics/value_stream_analytics/stages/:stage_id/duration_chart',
cycleAnalyticsGroupLabelsPath: '/groups/:namespace_path/-/labels.json', cycleAnalyticsGroupLabelsPath: '/groups/:namespace_path/-/labels.json',
codeReviewAnalyticsPath: '/api/:version/analytics/code_review', codeReviewAnalyticsPath: '/api/:version/analytics/code_review',
groupActivityIssuesPath: '/api/:version/analytics/group_activity/issues_count', groupActivityIssuesPath: '/api/:version/analytics/group_activity/issues_count',
...@@ -131,69 +133,74 @@ export default { ...@@ -131,69 +133,74 @@ export default {
return axios.delete(url); return axios.delete(url);
}, },
cycleAnalyticsTasksByType(params = {}) { cycleAnalyticsTasksByType(groupId, params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsTasksByTypePath); const url = Api.buildUrl(this.cycleAnalyticsTasksByTypePath).replace(':id', groupId);
return axios.get(url, { params }); return axios.get(url, { params });
}, },
cycleAnalyticsTopLabels(params = {}) { cycleAnalyticsTopLabels(groupId, params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsTopLabelsPath); const url = Api.buildUrl(this.cycleAnalyticsTopLabelsPath).replace(':id', groupId);
return axios.get(url, { params }); return axios.get(url, { params });
}, },
cycleAnalyticsSummaryData(params = {}) { cycleAnalyticsSummaryData(groupId, params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsSummaryDataPath); const url = Api.buildUrl(this.cycleAnalyticsSummaryDataPath).replace(':id', groupId);
return axios.get(url, { params }); return axios.get(url, { params });
}, },
cycleAnalyticsGroupStagesAndEvents(groupId, params = {}) { cycleAnalyticsGroupStagesAndEvents(groupId, params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsGroupStagesAndEventsPath); const url = Api.buildUrl(this.cycleAnalyticsGroupStagesAndEventsPath).replace(':id', groupId);
return axios.get(url, { return axios.get(url, { params });
params: { group_id: groupId, ...params },
});
}, },
cycleAnalyticsStageEvents(groupId, stageId, params = {}) { cycleAnalyticsStageEvents(groupId, stageId, params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsStageEventsPath).replace(':stage_id', stageId); const url = Api.buildUrl(this.cycleAnalyticsStageEventsPath)
return axios.get(url, { params: { ...params, group_id: groupId } }); .replace(':id', groupId)
.replace(':stage_id', stageId);
return axios.get(url, { params });
}, },
cycleAnalyticsStageMedian(groupId, stageId, params = {}) { cycleAnalyticsStageMedian(groupId, stageId, params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsStageMedianPath).replace(':stage_id', stageId); const url = Api.buildUrl(this.cycleAnalyticsStageMedianPath)
return axios.get(url, { params: { ...params, group_id: groupId } }); .replace(':id', groupId)
.replace(':stage_id', stageId);
return axios.get(url, { params: { ...params } });
}, },
cycleAnalyticsCreateStage(groupId, data) { cycleAnalyticsCreateStage(groupId, data) {
const url = Api.buildUrl(this.cycleAnalyticsGroupStagesAndEventsPath); const url = Api.buildUrl(this.cycleAnalyticsGroupStagesAndEventsPath).replace(':id', groupId);
return axios.post(url, data, { return axios.post(url, data);
params: { group_id: groupId },
});
}, },
cycleAnalyticsStageUrl(stageId) { cycleAnalyticsStageUrl(stageId, groupId) {
return Api.buildUrl(this.cycleAnalyticsStagePath).replace(':stage_id', stageId); return Api.buildUrl(this.cycleAnalyticsStagePath)
.replace(':id', groupId)
.replace(':stage_id', stageId);
}, },
cycleAnalyticsUpdateStage(stageId, groupId, data) { cycleAnalyticsUpdateStage(stageId, groupId, data) {
const url = this.cycleAnalyticsStageUrl(stageId); const url = this.cycleAnalyticsStageUrl(stageId, groupId);
return axios.put(url, data, { return axios.put(url, data);
params: { group_id: groupId },
});
}, },
cycleAnalyticsRemoveStage(stageId, groupId) { cycleAnalyticsRemoveStage(stageId, groupId) {
const url = this.cycleAnalyticsStageUrl(stageId); const url = this.cycleAnalyticsStageUrl(stageId, groupId);
return axios.delete(url, { return axios.delete(url);
params: { group_id: groupId },
});
}, },
cycleAnalyticsDurationChart(stageSlug, params = {}) { cycleAnalyticsDurationChart(groupId, stageSlug, params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsDurationChartPath).replace(':stage_id', stageSlug); const url = Api.buildUrl(this.cycleAnalyticsDurationChartPath)
.replace(':id', groupId)
.replace(':stage_id', stageSlug);
return axios.get(url, { return axios.get(url, {
params, params,
......
...@@ -53,7 +53,7 @@ module Analytics ...@@ -53,7 +53,7 @@ module Analytics
def duration_chart def duration_chart
return render_403 unless can?(current_user, :read_group_stage, @group) return render_403 unless can?(current_user, :read_group_stage, @group)
render json: Analytics::CycleAnalytics::DurationChartItemEntity.represent(data_collector.duration_chart_data) render json: ::Analytics::CycleAnalytics::DurationChartItemEntity.represent(data_collector.duration_chart_data)
end end
private private
...@@ -81,35 +81,35 @@ module Analytics ...@@ -81,35 +81,35 @@ module Analytics
end end
def stage def stage
@stage ||= Analytics::CycleAnalytics::StageFinder.new(parent: @group, stage_id: params[:id]).execute @stage ||= ::Analytics::CycleAnalytics::StageFinder.new(parent: @group, stage_id: params[:id]).execute
end end
def cycle_analytics_configuration(stages) def cycle_analytics_configuration(stages)
stage_presenters = stages.map { |s| StagePresenter.new(s) } stage_presenters = stages.map { |s| StagePresenter.new(s) }
Analytics::CycleAnalytics::ConfigurationEntity.new(stages: stage_presenters) ::Analytics::CycleAnalytics::ConfigurationEntity.new(stages: stage_presenters)
end end
def list_service def list_service
Stages::ListService.new(parent: @group, current_user: current_user) ::Analytics::CycleAnalytics::Stages::ListService.new(parent: @group, current_user: current_user)
end end
def create_service def create_service
Stages::CreateService.new(parent: @group, current_user: current_user, params: create_params) ::Analytics::CycleAnalytics::Stages::CreateService.new(parent: @group, current_user: current_user, params: create_params)
end end
def update_service def update_service
Stages::UpdateService.new(parent: @group, current_user: current_user, params: update_params) ::Analytics::CycleAnalytics::Stages::UpdateService.new(parent: @group, current_user: current_user, params: update_params)
end end
def delete_service def delete_service
Stages::DeleteService.new(parent: @group, current_user: current_user, params: delete_params) ::Analytics::CycleAnalytics::Stages::DeleteService.new(parent: @group, current_user: current_user, params: delete_params)
end end
def render_stage_service_result(result) def render_stage_service_result(result)
if result.success? if result.success?
stage = StagePresenter.new(result.payload[:stage]) stage = StagePresenter.new(result.payload[:stage])
render json: Analytics::CycleAnalytics::StageEntity.new(stage), status: result.http_status render json: ::Analytics::CycleAnalytics::StageEntity.new(stage), status: result.http_status
else else
render json: { message: result.message, errors: result.payload[:errors] }, status: result.http_status render json: { message: result.message, errors: result.payload[:errors] }, status: result.http_status
end end
......
# frozen_string_literal: true
module Groups
module Analytics
module CycleAnalytics
class StagesController < ::Analytics::CycleAnalytics::StagesController
end
end
end
end
# frozen_string_literal: true
module Groups
module Analytics
module CycleAnalytics
class SummaryController < ::Analytics::CycleAnalytics::SummaryController
end
end
end
end
# frozen_string_literal: true
class Groups::Analytics::TasksByTypeController < ::Analytics::TasksByTypeController
end
# frozen_string_literal: true
class Groups::CycleAnalytics::EventsController < Groups::ApplicationController
include ActionView::Helpers::DateHelper
include ActionView::Helpers::TextHelper
include CycleAnalyticsParams
before_action :authorize_group_cycle_analytics!
def issue
render_events(:issue)
end
def plan
render_events(:plan)
end
def code
render_events(:code)
end
def test
render_events(:test)
end
def review
render_events(:review)
end
def staging
render_events(:staging)
end
def production
render_events(:production)
end
private
def render_events(stage)
respond_to do |format|
format.html
format.json { render json: { events: cycle_analytics[stage].events } }
end
end
def cycle_analytics
@cycle_analytics ||= ::CycleAnalytics::GroupLevel.new(group: group, options: options(cycle_analytics_group_params))
end
def authorize_group_cycle_analytics!
unless can?(current_user, :read_group_cycle_analytics, group)
render_403
end
end
end
# frozen_string_literal: true
class Groups::CycleAnalyticsController < Groups::ApplicationController
include ActionView::Helpers::DateHelper
include ActionView::Helpers::TextHelper
include CycleAnalyticsParams
before_action :whitelist_query_limiting, only: [:show]
before_action :authorize_group_cycle_analytics!
def show
respond_to do |format|
format.json { render json: cycle_analytics_json }
end
end
private
def cycle_analytics_json
{
summary: cycle_analytics_stats.summary,
stats: cycle_analytics_stats.stats,
permissions: cycle_analytics_stats.permissions(user: current_user)
}
end
def cycle_analytics_stats
@cycle_analytics_stats ||= ::CycleAnalytics::GroupLevel.new(group: group, options: options(cycle_analytics_group_params))
end
def whitelist_query_limiting
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42671')
end
def authorize_group_cycle_analytics!
unless can?(current_user, :read_group_cycle_analytics, group)
render_403
end
end
end
...@@ -19,21 +19,32 @@ constraints(::Constraints::GroupUrlConstrainer.new) do ...@@ -19,21 +19,32 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
get '/analytics', to: redirect('groups/%{group_id}/-/contribution_analytics') get '/analytics', to: redirect('groups/%{group_id}/-/contribution_analytics')
resource :contribution_analytics, only: [:show] resource :contribution_analytics, only: [:show]
resource :cycle_analytics, only: [:show], path: 'value_stream_analytics'
scope module: :cycle_analytics, as: 'cycle_analytics', path: 'value_stream_analytics' do
scope :events, controller: 'events' do
get :issue
get :plan
get :code
get :test
get :review
get :staging
get :production
end
end
namespace :analytics do namespace :analytics do
resource :productivity_analytics, only: :show, constraints: -> (req) { Gitlab::Analytics.productivity_analytics_enabled? } resource :productivity_analytics, only: :show, constraints: -> (req) { Gitlab::Analytics.productivity_analytics_enabled? }
resource :cycle_analytics, path: 'value_stream_analytics', only: :show, constraints: -> (req) { Gitlab::Analytics.cycle_analytics_enabled? }
constraints(::Constraints::FeatureConstrainer.new(Gitlab::Analytics::CYCLE_ANALYTICS_FEATURE_FLAG, default_enabled: Gitlab::Analytics.feature_enabled_by_default?(Gitlab::Analytics::CYCLE_ANALYTICS_FEATURE_FLAG))) do
resource :cycle_analytics, only: :show, path: 'value_stream_analytics'
scope module: :cycle_analytics, as: 'cycle_analytics', path: 'value_stream_analytics' do
resources :stages, only: [:index, :create, :update, :destroy] do
member do
get :duration_chart
get :median
get :records
end
end
resource :summary, controller: :summary, only: :show
end
get '/cycle_analytics', to: redirect('-/analytics/value_stream_analytics')
end
constraints(::Constraints::FeatureConstrainer.new(Gitlab::Analytics::TASKS_BY_TYPE_CHART_FEATURE_FLAG)) do
scope :type_of_work do
resource :tasks_by_type, controller: :tasks_by_type, only: :show do
get :top_labels
end
end
end
end end
resource :ldap, only: [] do resource :ldap, only: [] do
......
...@@ -95,7 +95,7 @@ describe Analytics::TasksByTypeController do ...@@ -95,7 +95,7 @@ describe Analytics::TasksByTypeController do
it 'succeeds' do it 'succeeds' do
subject subject
expect(response).to be_successful expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('analytics/tasks_by_type', dir: 'ee') expect(response).to match_response_schema('analytics/tasks_by_type', dir: 'ee')
end end
...@@ -128,7 +128,7 @@ describe Analytics::TasksByTypeController do ...@@ -128,7 +128,7 @@ describe Analytics::TasksByTypeController do
it 'succeeds' do it 'succeeds' do
subject subject
expect(response).to be_successful expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('analytics/tasks_by_type_top_labels', dir: 'ee') expect(response).to match_response_schema('analytics/tasks_by_type_top_labels', dir: 'ee')
end end
......
# frozen_string_literal: true
require 'spec_helper'
describe Groups::Analytics::CycleAnalytics::StagesController do
let_it_be(:user) { create(:user) }
let_it_be(:group, refind: true) { create(:group) }
let(:params) { { group_id: group } }
before do
stub_feature_flags(Gitlab::Analytics::CYCLE_ANALYTICS_FEATURE_FLAG => true)
stub_licensed_features(cycle_analytics_for_groups: true)
group.add_reporter(user)
sign_in(user)
end
describe 'GET #index' do
subject { get :index, params: params }
it 'succeeds' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('analytics/cycle_analytics/stages', dir: 'ee')
end
it 'returns correct start events' do
subject
response_start_events = json_response['stages'].map { |s| s['start_event_identifier'] }
start_events = Gitlab::Analytics::CycleAnalytics::DefaultStages.all.map { |s| s['start_event_identifier'] }
expect(response_start_events).to eq(start_events)
end
it 'returns correct event names' do
subject
response_event_names = json_response['events'].map { |s| s['name'] }
event_names = Gitlab::Analytics::CycleAnalytics::StageEvents.events.map(&:name).sort
expect(response_event_names).to eq(event_names)
end
it 'succeeds for subgroups' do
subgroup = create(:group, parent: group)
params[:group_id] = subgroup.full_path
subject
expect(response).to have_gitlab_http_status(:ok)
end
it 'renders `forbidden` based on the response of the service object' do
expect_any_instance_of(Analytics::CycleAnalytics::Stages::ListService).to receive(:can?).and_return(false)
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
include_examples 'group permission check on the controller level'
end
describe 'POST #create' do
subject { post :create, params: params }
include_examples 'group permission check on the controller level'
context 'when valid parameters are given' do
before do
params.merge!({
name: 'my new stage',
start_event_identifier: :merge_request_created,
end_event_identifier: :merge_request_merged
})
end
it 'creates the stage' do
subject
expect(response).to have_gitlab_http_status(:created)
expect(response).to match_response_schema('analytics/cycle_analytics/stage', dir: 'ee')
end
end
include_context 'when invalid stage parameters are given'
end
describe 'PUT #update' do
let(:stage) { create(:cycle_analytics_group_stage, parent: group, relative_position: 15) }
subject { put :update, params: params.merge(id: stage.id) }
include_examples 'group permission check on the controller level'
context 'when valid parameters are given' do
before do
params.merge!({
name: 'my updated stage',
start_event_identifier: :merge_request_created,
end_event_identifier: :merge_request_merged
})
end
it 'succeeds' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('analytics/cycle_analytics/stage', dir: 'ee')
end
it 'updates the name attribute' do
subject
stage.reload
expect(stage.name).to eq(params[:name])
end
context 'hidden attribute' do
before do
params[:hidden] = true
end
it 'updates the hidden attribute' do
subject
stage.reload
expect(stage.hidden).to eq(true)
end
end
context 'when positioning parameter is given' do
before do
params[:move_before_id] = create(:cycle_analytics_group_stage, parent: group, relative_position: 10).id
end
it 'moves the stage before the last place' do
subject
before_last = group.cycle_analytics_stages.ordered[-2]
expect(before_last.id).to eq(stage.id)
end
end
end
include_context 'when invalid stage parameters are given'
end
describe 'DELETE #destroy' do
let(:stage) { create(:cycle_analytics_group_stage, parent: group) }
subject { delete :destroy, params: params }
before do
params[:id] = stage.id
end
include_examples 'group permission check on the controller level'
context 'when persisted stage id is passed' do
it 'succeeds' do
subject
expect(response).to have_gitlab_http_status(:ok)
end
it 'deletes the record' do
subject
expect(group.reload.cycle_analytics_stages.find_by(id: stage.id)).to be_nil
end
end
context 'when default stage id is passed' do
before do
params[:id] = Gitlab::Analytics::CycleAnalytics::DefaultStages.names.first
end
it 'fails with `forbidden` response' do
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
end
end
describe 'data endpoints' do
let(:stage) { create(:cycle_analytics_group_stage, parent: group) }
before do
params[:id] = stage.id
end
describe 'GET #median' do
subject { get :median, params: params }
it 'matches the response schema' do
subject
expect(response).to match_response_schema('analytics/cycle_analytics/median', dir: 'ee')
end
include_examples 'cycle analytics data endpoint examples'
end
describe 'GET #records' do
subject { get :records, params: params }
include_examples 'cycle analytics data endpoint examples'
include_examples 'group permission check on the controller level'
end
describe 'GET #duration_chart' do
subject { get :duration_chart, params: params }
it 'matches the response schema' do
fake_result = [double(MergeRequest, duration_in_seconds: 10, finished_at: Time.now)]
expect_any_instance_of(Gitlab::Analytics::CycleAnalytics::DataForDurationChart).to receive(:load).and_return(fake_result)
subject
expect(response).to match_response_schema('analytics/cycle_analytics/duration_chart', dir: 'ee')
end
include_examples 'cycle analytics data endpoint examples'
include_examples 'group permission check on the controller level'
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Analytics::CycleAnalyticsController do
let(:user) { create(:user) }
before do
sign_in(user)
end
describe 'usage counter' do
it 'increments usage counter' do
expect(Gitlab::UsageDataCounters::CycleAnalyticsCounter).to receive(:count).with(:views)
get(:show)
expect(response).to be_successful
end
end
describe 'GET show' do
it 'renders `show` template' do
stub_feature_flags(Gitlab::Analytics::CYCLE_ANALYTICS_FEATURE_FLAG => true)
get :show
expect(response).to render_template :show
end
it 'renders `404` when feature flag is disabled' do
stub_feature_flags(Gitlab::Analytics::CYCLE_ANALYTICS_FEATURE_FLAG => false)
get :show
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Analytics::TasksByTypeController do
let_it_be(:user) { create(:user) }
let(:group) { create(:group) }
let(:label) { create(:group_label, group: group) }
let!(:issue) { create(:labeled_issue, created_at: 5.days.ago, project: create(:project, group: group), labels: [label]) }
before do
stub_licensed_features(type_of_work_analytics: true)
stub_feature_flags(Gitlab::Analytics::TASKS_BY_TYPE_CHART_FEATURE_FLAG => true)
group.add_reporter(user)
sign_in(user)
end
shared_examples 'expects unprocessable_entity response' do
it 'returns unprocessable_entity as response' do
subject
expect(response).to have_gitlab_http_status(:unprocessable_entity)
end
end
shared_examples 'parameter validation' do
context 'when user access level is lower than reporter' do
before do
group.add_guest(user)
end
it 'returns forbidden as response' do
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
end
context 'when license is missing' do
before do
stub_licensed_features(type_of_work_analytics: false)
end
it 'returns forbidden as response' do
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(Gitlab::Analytics::TASKS_BY_TYPE_CHART_FEATURE_FLAG => false)
end
it 'returns not_found as response' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when `created_after` parameter is invalid' do
before do
params[:created_after] = 'invalid_date'
end
it_behaves_like 'expects unprocessable_entity response'
end
context 'when `created_after` parameter is missing' do
before do
params.delete(:created_after)
end
it_behaves_like 'expects unprocessable_entity response'
end
context 'when `created_after` date is later than `created_before` date' do
before do
params[:created_after] = 1.year.ago.to_date
params[:created_before] = 2.years.ago.to_date
end
it_behaves_like 'expects unprocessable_entity response'
end
end
describe 'GET #show' do
let(:params) { { group_id: group, label_ids: [label.id], created_after: 10.days.ago, subject: 'Issue' } }
subject { get :show, params: params }
context 'when valid parameters are given' do
it 'succeeds' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('analytics/tasks_by_type', dir: 'ee')
end
it 'returns valid count' do
subject
date, count = json_response.first['series'].first
expect(Date.parse(date)).to eq(issue.created_at.to_date)
expect(count).to eq(1)
end
end
context 'when `label_id` is missing' do
before do
params.delete(:label_ids)
end
it_behaves_like 'expects unprocessable_entity response'
end
it_behaves_like 'parameter validation'
end
describe 'GET #top_labels' do
let(:params) { { group_id: group.full_path, created_after: 10.days.ago, subject: 'Issue' } }
subject { get :top_labels, params: params }
context 'when valid parameters are given' do
it 'succeeds' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('analytics/tasks_by_type_top_labels', dir: 'ee')
end
it 'returns valid count' do
subject
label_item = json_response.first
expect(label_item['title']).to eq(label.title)
expect(json_response.count).to eq(1)
end
end
it_behaves_like 'parameter validation'
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec::Matchers.define :nested_hash_including do |path_to_hash, value|
match { |actual| actual.dig(*path_to_hash) == value }
end
describe Groups::CycleAnalytics::EventsController do
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
let(:user) { create(:user) }
let(:group_service) { instance_double(::CycleAnalytics::GroupLevel) }
let(:events_service) { double }
before do
stub_licensed_features(cycle_analytics_for_groups: true)
sign_in(user)
end
describe 'cycle analytics' do
context 'with proper permission' do
before do
group.add_owner(user)
end
it 'calls service' do
expect(events_service).to receive(:events)
expect(group_service).to receive(:[]).and_return(events_service)
expect(::CycleAnalytics::GroupLevel).to receive(:new).and_return(group_service)
get(:issue,
params: {
group_id: group.name
},
format: :json)
expect(response).to be_successful
end
it 'calls service with specific params' do
expect(events_service).to receive(:events)
expect(group_service).to receive(:[]).and_return(events_service)
expect(::CycleAnalytics::GroupLevel).to receive(:new)
.with(nested_hash_including([:options, :projects], [project.id.to_s]))
.and_return(group_service)
get(:issue,
params: {
group_id: group.name,
project_ids: [project.id]
},
format: :json)
expect(response).to be_successful
end
end
context 'as guest' do
before do
group.add_guest(user)
end
it 'returns 403' do
get(:issue,
params: {
group_id: group.name
},
format: :json)
expect(response).to have_gitlab_http_status(:forbidden)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec::Matchers.define :nested_hash_including do |path_to_hash, value|
match { |actual| actual.dig(*path_to_hash) == value }
end
describe Groups::CycleAnalyticsController do
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
let(:user) { create(:user) }
let(:group_service) { instance_double(::CycleAnalytics::GroupLevel) }
before do
stub_licensed_features(cycle_analytics_for_groups: true)
sign_in(user)
end
describe 'cycle analytics' do
context 'with proper permission' do
before do
group.add_owner(user)
end
it 'calls service' do
expect(group_service).to receive(:summary)
expect(group_service).to receive(:stats)
expect(group_service).to receive(:permissions)
expect(::CycleAnalytics::GroupLevel).to receive(:new).and_return(group_service)
get(:show,
params: {
group_id: group.name
},
format: :json)
expect(response).to be_successful
end
it 'calls service with specific params' do
expect(group_service).to receive(:summary)
expect(group_service).to receive(:stats)
expect(group_service).to receive(:permissions)
expect(::CycleAnalytics::GroupLevel).to receive(:new)
.with(nested_hash_including([:options, :projects], [project.id.to_s]))
.and_return(group_service)
get(:show,
params: {
group_id: group.name,
project_ids: [project.id]
},
format: :json)
expect(response).to be_successful
end
end
context 'as guest' do
before do
group.add_guest(user)
end
it 'returns 403' do
get(:show,
params: {
group_id: group.name
},
format: :json)
expect(response).to have_gitlab_http_status(:forbidden)
end
end
end
end
...@@ -33,7 +33,8 @@ const selectedGroup = { fullPath: group.path }; ...@@ -33,7 +33,8 @@ const selectedGroup = { fullPath: group.path };
const [selectedStage] = stages; const [selectedStage] = stages;
const selectedStageSlug = selectedStage.slug; const selectedStageSlug = selectedStage.slug;
const stageEndpoint = ({ stageId }) => `/-/analytics/value_stream_analytics/stages/${stageId}`; const stageEndpoint = ({ stageId }) =>
`/groups/${group.full_path}/-/analytics/value_stream_analytics/stages/${stageId}`;
describe('Cycle analytics actions', () => { describe('Cycle analytics actions', () => {
let state; let state;
......
...@@ -276,8 +276,8 @@ describe('Api', () => { ...@@ -276,8 +276,8 @@ describe('Api', () => {
const createdBefore = '2019-11-18'; const createdBefore = '2019-11-18';
const createdAfter = '2019-08-18'; const createdAfter = '2019-08-18';
const stageId = 'thursday'; const stageId = 'thursday';
const dummyCycleAnalyticsUrlRoot = `${dummyUrlRoot}/groups/${groupId}`;
const defaultParams = { const defaultParams = {
group_id: groupId,
created_after: createdAfter, created_after: createdAfter,
created_before: createdBefore, created_before: createdBefore,
}; };
...@@ -321,7 +321,7 @@ describe('Api', () => { ...@@ -321,7 +321,7 @@ describe('Api', () => {
const expectedUrl = analyticsMockData.endpoints.tasksByTypeData; const expectedUrl = analyticsMockData.endpoints.tasksByTypeData;
mock.onGet(expectedUrl).reply(200, tasksByTypeResponse); mock.onGet(expectedUrl).reply(200, tasksByTypeResponse);
Api.cycleAnalyticsTasksByType(params) Api.cycleAnalyticsTasksByType(groupId, params)
.then(({ data, config: { params: reqParams } }) => { .then(({ data, config: { params: reqParams } }) => {
expect(data).toEqual(tasksByTypeResponse); expect(data).toEqual(tasksByTypeResponse);
expect(reqParams).toEqual(params); expect(reqParams).toEqual(params);
...@@ -345,7 +345,7 @@ describe('Api', () => { ...@@ -345,7 +345,7 @@ describe('Api', () => {
const expectedUrl = analyticsMockData.endpoints.tasksByTypeTopLabelsData; const expectedUrl = analyticsMockData.endpoints.tasksByTypeTopLabelsData;
mock.onGet(expectedUrl).reply(200, response); mock.onGet(expectedUrl).reply(200, response);
Api.cycleAnalyticsTopLabels(params) Api.cycleAnalyticsTopLabels(groupId, params)
.then(({ data, config: { url, params: reqParams } }) => { .then(({ data, config: { url, params: reqParams } }) => {
expect(data).toEqual(response); expect(data).toEqual(response);
expect(url).toMatch(expectedUrl); expect(url).toMatch(expectedUrl);
...@@ -363,10 +363,10 @@ describe('Api', () => { ...@@ -363,10 +363,10 @@ describe('Api', () => {
...defaultParams, ...defaultParams,
}; };
const expectedUrl = `${dummyUrlRoot}/-/analytics/value_stream_analytics/summary`; const expectedUrl = `${dummyCycleAnalyticsUrlRoot}/-/analytics/value_stream_analytics/summary`;
mock.onGet(expectedUrl).reply(200, response); mock.onGet(expectedUrl).reply(200, response);
Api.cycleAnalyticsSummaryData(params) Api.cycleAnalyticsSummaryData(groupId, params)
.then(responseObj => .then(responseObj =>
expectRequestWithCorrectParameters(responseObj, { expectRequestWithCorrectParameters(responseObj, {
response, response,
...@@ -387,7 +387,7 @@ describe('Api', () => { ...@@ -387,7 +387,7 @@ describe('Api', () => {
'cycle_analytics[created_after]': createdAfter, 'cycle_analytics[created_after]': createdAfter,
'cycle_analytics[created_before]': createdBefore, 'cycle_analytics[created_before]': createdBefore,
}; };
const expectedUrl = `${dummyUrlRoot}/-/analytics/value_stream_analytics/stages`; const expectedUrl = `${dummyCycleAnalyticsUrlRoot}/-/analytics/value_stream_analytics/stages`;
mock.onGet(expectedUrl).reply(200, response); mock.onGet(expectedUrl).reply(200, response);
Api.cycleAnalyticsGroupStagesAndEvents(groupId, params) Api.cycleAnalyticsGroupStagesAndEvents(groupId, params)
...@@ -409,7 +409,7 @@ describe('Api', () => { ...@@ -409,7 +409,7 @@ describe('Api', () => {
const params = { const params = {
...defaultParams, ...defaultParams,
}; };
const expectedUrl = `${dummyUrlRoot}/-/analytics/value_stream_analytics/stages/${stageId}/records`; const expectedUrl = `${dummyCycleAnalyticsUrlRoot}/-/analytics/value_stream_analytics/stages/${stageId}/records`;
mock.onGet(expectedUrl).reply(200, response); mock.onGet(expectedUrl).reply(200, response);
Api.cycleAnalyticsStageEvents(groupId, stageId, params) Api.cycleAnalyticsStageEvents(groupId, stageId, params)
...@@ -431,7 +431,7 @@ describe('Api', () => { ...@@ -431,7 +431,7 @@ describe('Api', () => {
const params = { const params = {
...defaultParams, ...defaultParams,
}; };
const expectedUrl = `${dummyUrlRoot}/-/analytics/value_stream_analytics/stages/${stageId}/median`; const expectedUrl = `${dummyCycleAnalyticsUrlRoot}/-/analytics/value_stream_analytics/stages/${stageId}/median`;
mock.onGet(expectedUrl).reply(200, response); mock.onGet(expectedUrl).reply(200, response);
Api.cycleAnalyticsStageMedian(groupId, stageId, params) Api.cycleAnalyticsStageMedian(groupId, stageId, params)
...@@ -457,13 +457,12 @@ describe('Api', () => { ...@@ -457,13 +457,12 @@ describe('Api', () => {
end_event_identifier: 'issue_closed', end_event_identifier: 'issue_closed',
end_event_label_id: null, end_event_label_id: null,
}; };
const expectedUrl = `${dummyUrlRoot}/-/analytics/value_stream_analytics/stages`; const expectedUrl = `${dummyCycleAnalyticsUrlRoot}/-/analytics/value_stream_analytics/stages`;
mock.onPost(expectedUrl).reply(200, response); mock.onPost(expectedUrl).reply(200, response);
Api.cycleAnalyticsCreateStage(groupId, customStage) Api.cycleAnalyticsCreateStage(groupId, customStage)
.then(({ data, config: { params: reqParams, data: reqData, url } }) => { .then(({ data, config: { data: reqData, url } }) => {
expect(data).toEqual(response); expect(data).toEqual(response);
expect(reqParams).toEqual({ group_id: groupId });
expect(JSON.parse(reqData)).toMatchObject(customStage); expect(JSON.parse(reqData)).toMatchObject(customStage);
expect(url).toEqual(expectedUrl); expect(url).toEqual(expectedUrl);
}) })
...@@ -479,13 +478,12 @@ describe('Api', () => { ...@@ -479,13 +478,12 @@ describe('Api', () => {
name: 'nice-stage', name: 'nice-stage',
hidden: true, hidden: true,
}; };
const expectedUrl = `${dummyUrlRoot}/-/analytics/value_stream_analytics/stages/${stageId}`; const expectedUrl = `${dummyCycleAnalyticsUrlRoot}/-/analytics/value_stream_analytics/stages/${stageId}`;
mock.onPut(expectedUrl).reply(200, response); mock.onPut(expectedUrl).reply(200, response);
Api.cycleAnalyticsUpdateStage(stageId, groupId, stageData) Api.cycleAnalyticsUpdateStage(stageId, groupId, stageData)
.then(({ data, config: { params: reqParams, data: reqData, url } }) => { .then(({ data, config: { data: reqData, url } }) => {
expect(data).toEqual(response); expect(data).toEqual(response);
expect(reqParams).toEqual({ group_id: groupId });
expect(JSON.parse(reqData)).toMatchObject(stageData); expect(JSON.parse(reqData)).toMatchObject(stageData);
expect(url).toEqual(expectedUrl); expect(url).toEqual(expectedUrl);
}) })
...@@ -497,13 +495,12 @@ describe('Api', () => { ...@@ -497,13 +495,12 @@ describe('Api', () => {
describe('cycleAnalyticsRemoveStage', () => { describe('cycleAnalyticsRemoveStage', () => {
it('deletes the specified data', done => { it('deletes the specified data', done => {
const response = { id: stageId, hidden: true, custom: true }; const response = { id: stageId, hidden: true, custom: true };
const expectedUrl = `${dummyUrlRoot}/-/analytics/value_stream_analytics/stages/${stageId}`; const expectedUrl = `${dummyCycleAnalyticsUrlRoot}/-/analytics/value_stream_analytics/stages/${stageId}`;
mock.onDelete(expectedUrl).reply(200, response); mock.onDelete(expectedUrl).reply(200, response);
Api.cycleAnalyticsRemoveStage(stageId, groupId) Api.cycleAnalyticsRemoveStage(stageId, groupId)
.then(({ data, config: { params: reqParams, url } }) => { .then(({ data, config: { url } }) => {
expect(data).toEqual(response); expect(data).toEqual(response);
expect(reqParams).toEqual({ group_id: groupId });
expect(url).toEqual(expectedUrl); expect(url).toEqual(expectedUrl);
}) })
...@@ -518,10 +515,10 @@ describe('Api', () => { ...@@ -518,10 +515,10 @@ describe('Api', () => {
const params = { const params = {
...defaultParams, ...defaultParams,
}; };
const expectedUrl = `${dummyUrlRoot}/-/analytics/value_stream_analytics/stages/thursday/duration_chart`; const expectedUrl = `${dummyCycleAnalyticsUrlRoot}/-/analytics/value_stream_analytics/stages/thursday/duration_chart`;
mock.onGet(expectedUrl).reply(200, response); mock.onGet(expectedUrl).reply(200, response);
Api.cycleAnalyticsDurationChart(stageId, params) Api.cycleAnalyticsDurationChart(groupId, stageId, params)
.then(responseObj => .then(responseObj =>
expectRequestWithCorrectParameters(responseObj, { expectRequestWithCorrectParameters(responseObj, {
response, response,
......
...@@ -125,51 +125,6 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do ...@@ -125,51 +125,6 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
clean_frontend_fixtures('cycle_analytics/') clean_frontend_fixtures('cycle_analytics/')
end end
default_stages = %w[issue plan review code test staging production]
describe Groups::CycleAnalytics::EventsController, type: :controller do
render_views
before do
stub_licensed_features(cycle_analytics_for_groups: true)
prepare_cycle_analytics_data
create_deployment
sign_in(user)
end
default_stages.each do |endpoint|
it "value_stream_analytics/events/#{endpoint}.json" do
get endpoint, params: { group_id: group, format: :json }
expect(response).to be_successful
end
end
end
describe Groups::CycleAnalyticsController, type: :controller do
render_views
before do
stub_licensed_features(cycle_analytics_for_groups: true)
prepare_cycle_analytics_data
create_deployment
sign_in(user)
end
it 'value_stream_analytics/mock_data.json' do
get(:show, params: {
group_id: group.name,
cycle_analytics: { start_date: 30 }
}, format: :json)
expect(response).to be_successful
end
end
describe Analytics::CycleAnalytics::StagesController, type: :controller do describe Analytics::CycleAnalytics::StagesController, type: :controller do
render_views render_views
......
# frozen_string_literal: true
require 'spec_helper'
describe 'value stream analytics events' do
let(:user) { create(:user) }
let(:group) { create(:group)}
let(:project) { create(:project, :repository, namespace: group, public_builds: false) }
let(:issue) { create(:issue, project: project, created_at: 2.days.ago) }
describe 'GET /:namespace/-/value_stream_analytics/events/:stage' do
before do
stub_licensed_features(cycle_analytics_for_groups: true)
group.add_developer(user)
project.add_developer(user)
3.times do |count|
Timecop.freeze(Time.now + count.days) do
create_cycle
end
end
deploy_master(user, project)
login_as(user)
end
context 'when date range parameters are given' do
it 'filter by `created_after`' do
params = { created_after: issue.created_at - 5.days }
get group_cycle_analytics_issue_path(group, params: params, format: :json)
expect(json_response['events']).not_to be_empty
end
it 'filters by `created_after` where no events should be found' do
params = { created_after: issue.created_at + 5.days }
get group_cycle_analytics_issue_path(group, params: params, format: :json)
expect(json_response['events']).to be_empty
end
it 'filter by `created_after` and `created_before`' do
params = { created_after: issue.created_at - 5.days, created_before: issue.created_at + 5.days }
get group_cycle_analytics_issue_path(group, params: params, format: :json)
expect(json_response['events']).not_to be_empty
end
it 'raises error when date cannot be parsed' do
params = { created_after: 'invalid' }
expect do
get group_cycle_analytics_issue_path(group, params: params, format: :json)
end.to raise_error(ArgumentError)
end
end
it 'lists the issue events' do
get group_cycle_analytics_issue_path(group, format: :json)
first_issue_iid = project.issues.sort_by_attribute(:created_desc).pluck(:iid).first.to_s
expect(json_response['events']).not_to be_empty
expect(json_response['events'].first['iid']).to eq(first_issue_iid)
end
it 'lists the plan events' do
get group_cycle_analytics_plan_path(group, format: :json)
first_issue_iid = project.issues.sort_by_attribute(:created_desc).pluck(:iid).first.to_s
expect(json_response['events']).not_to be_empty
expect(json_response['events'].first['iid']).to eq(first_issue_iid)
end
it 'lists the code events' do
get group_cycle_analytics_code_path(group, format: :json)
expect(json_response['events']).not_to be_empty
first_mr_iid = project.merge_requests.sort_by_attribute(:created_desc).pluck(:iid).first.to_s
expect(json_response['events'].first['iid']).to eq(first_mr_iid)
end
it 'lists the test events', :sidekiq_might_not_need_inline do
get group_cycle_analytics_test_path(group, format: :json)
expect(json_response['events']).not_to be_empty
expect(json_response['events'].first['date']).not_to be_empty
end
it 'lists the review events' do
get group_cycle_analytics_review_path(group, format: :json)
first_mr_iid = project.merge_requests.sort_by_attribute(:created_desc).pluck(:iid).first.to_s
expect(json_response['events']).not_to be_empty
expect(json_response['events'].first['iid']).to eq(first_mr_iid)
end
it 'lists the staging events', :sidekiq_might_not_need_inline do
get group_cycle_analytics_staging_path(group, format: :json)
expect(json_response['events']).not_to be_empty
expect(json_response['events'].first['date']).not_to be_empty
end
it 'lists the production events', :sidekiq_might_not_need_inline do
get group_cycle_analytics_production_path(group, format: :json)
first_issue_iid = project.issues.sort_by_attribute(:created_desc).pluck(:iid).first.to_s
expect(json_response['events']).not_to be_empty
expect(json_response['events'].first['iid']).to eq(first_issue_iid)
end
context 'specific branch' do
it 'lists the test events', :sidekiq_might_not_need_inline do
branch = project.merge_requests.first.source_branch
get group_cycle_analytics_test_path(group, format: :json, branch: branch)
expect(json_response['events']).not_to be_empty
expect(json_response['events'].first['date']).not_to be_empty
end
end
end
def create_cycle
milestone = create(:milestone, project: project)
issue.update(milestone: milestone)
mr = create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}")
pipeline = create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr)
pipeline.run
create(:ci_build, pipeline: pipeline, status: :success, author: user)
create(:ci_build, pipeline: pipeline, status: :success, author: user)
merge_merge_requests_closing_issue(user, project, issue)
ProcessCommitWorker.new.perform(project.id, user.id, mr.commits.last.to_hash)
end
end
# frozen_string_literal: true # frozen_string_literal: true
RSpec.shared_examples 'group permission check on the controller level' do RSpec.shared_examples 'group permission check on the controller level' do
context 'when `group_id` is not provided' do
before do
params[:group_id] = nil
end
it 'renders `not_found` when group_id is not provided' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when `group_id` is not found' do context 'when `group_id` is not found' do
before do before do
params[:group_id] = 'missing_group' params[:group_id] = 'missing_group'
......
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