Commit 6cab23c2 authored by Lin Jen-Shin's avatar Lin Jen-Shin

Merge branch '9867-insights-controller-action-for-filtered-issues-by-label-category' into 'master'

Introduce the Insights controllers

Closes #10421 and #9867

See merge request gitlab-org/gitlab-ee!9776
parents d2fa62d8 7da5fed4
# frozen_string_literal: true
module InsightsActions
extend ActiveSupport::Concern
included do
before_action :check_insights_available!
before_action :validate_params, only: [:query]
end
def show
respond_to do |format|
# FIXME: This is temporary until we have the frontend
format.html do
insights_config = config_data
if insights_config
first_chart_hash = insights_config.first.last
params.merge!(
chart_type: first_chart_hash[:type],
query: first_chart_hash[:query])
@insights_json = insights_json # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
end
format.json do
render json: config_data
end
end
end
def query
respond_to do |format|
format.json do
render json: insights_json
end
end
end
private
def check_insights_available!
render_404 unless insights_entity.insights_available?
end
def validate_params
Gitlab::Insights::Validators::ParamsValidator.new(params).validate!
end
def insights_json
issuables = find_issuables(params[:query])
insights = reduce(issuables, params[:chart_type], params[:query])
serializer(params[:chart_type]).present(insights)
end
def find_issuables(query)
Gitlab::Insights::Finders::IssuableFinder
.new(insights_entity, current_user, query).find
end
def reduce(issuables, chart_type, query)
case chart_type
when 'stacked-bar', 'line'
Gitlab::Insights::Reducers::LabelCountPerPeriodReducer.reduce(issuables, period: query[:group_by], labels: query[:collection_labels])
when 'bar'
Gitlab::Insights::Reducers::CountPerPeriodReducer.reduce(issuables, period: query[:group_by])
end
end
def serializer(chart_type)
case chart_type
when 'stacked-bar'
Gitlab::Insights::Serializers::Chartjs::MultiSeriesSerializer
when 'bar'
Gitlab::Insights::Serializers::Chartjs::BarSerializer
when 'line'
Gitlab::Insights::Serializers::Chartjs::LineSerializer
end
end
def config_data
insights_entity.insights_config
end
end
# frozen_string_literal: true
class Groups::InsightsController < Groups::ApplicationController
include InsightsActions
before_action :authorize_read_group!
private
def authorize_read_group!
render_404 unless can?(current_user, :read_group, group)
end
def insights_entity
group
end
end
# frozen_string_literal: true
class Projects::InsightsController < Projects::ApplicationController
include InsightsActions
before_action :authorize_read_project!
private
def authorize_read_project!
render_404 unless can?(current_user, :read_project, project)
end
def insights_entity
project
end
end
......@@ -2,14 +2,27 @@
module InsightsFeature
extend ActiveSupport::Concern
include ::Gitlab::Utils::StrongMemoize
# This allows to:
# 1. Disable the :insights by default even if the license allows it
# 1. Enable the Insights feature for an arbitrary group/project
# Once we're ready to release the feature, we could just replace
# `{group,project}.insights_available?` with
# `{group,project}.feature_available?(:insights)` and remove this module.
def insights_available?
::Feature.enabled?(:insights, self) && feature_available?(:insights)
beta_feature_available?(:insights)
end
def insights_config
case self
when Group
insight&.project&.insights_config
when Project
return if repository.empty?
insights_config_yml = repository.insights_config_for(repository.root_ref)
return unless insights_config_yml
strong_memoize(:insights_config) do
::Gitlab::Config::Loader::Yaml.new(insights_config_yml).load!
rescue Gitlab::Config::Loader::FormatError
nil
end
end
end
end
......@@ -81,6 +81,20 @@ module EE
project.full_path.sub(/\A#{Regexp.escape(full_path)}/, full_path_was)
end
# This makes the feature disabled by default, in contrary to how
# `#feature_available?` makes a feature enabled by default.
#
# This allows to:
# - Enable the feature flag for a given group, regardless of the license.
# This is useful for early testing a feature in production on a given group.
# - Enable the feature flag globally and still check that the license allows
# it. This is the case when we're ready to enable a feature for anyone
# with the correct license.
def beta_feature_available?(feature)
::Feature.enabled?(feature, self) ||
(::Feature.enabled?(feature) && feature_available?(feature))
end
# Checks features (i.e. https://about.gitlab.com/pricing/) availabily
# for a given Namespace plan. This method should consider ancestor groups
# being licensed.
......
......@@ -214,6 +214,20 @@ module EE
shared_runners_limit_namespace.shared_runners_minutes_limit_enabled?
end
# This makes the feature disabled by default, in contrary to how
# `#feature_available?` makes a feature enabled by default.
#
# This allows to:
# - Enable the feature flag for a given project, regardless of the license.
# This is useful for early testing a feature in production on a given project.
# - Enable the feature flag globally and still check that the license allows
# it. This is the case when we're ready to enable a feature for anyone
# with the correct license.
def beta_feature_available?(feature)
::Feature.enabled?(feature, self) ||
(::Feature.enabled?(feature) && feature_available?(feature))
end
def feature_available?(feature, user = nil)
if ::ProjectFeature::FEATURES.include?(feature)
super
......
......@@ -103,5 +103,9 @@ module EE
possible_code_owner_blobs = ::Gitlab::CodeOwners::FILE_PATHS.map { |path| [ref, path] }
blobs_at(possible_code_owner_blobs).compact.first
end
def insights_config_for(sha)
blob_data_at(sha, ::Gitlab::Insights::CONFIG_FILE_PATH)
end
end
end
- return unless @group.insights_available?
%section.settings.gs-advanced.no-animate#js-templates{ class: ('expanded' if expanded) }
%section.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
= _('Insights')
......@@ -9,7 +9,7 @@
%p
= _('Select a project to read Insights configuration file')
.settings-content
= form_for @group, url: group_path, html: { class: 'fieldset-form' } do |form|
= form_for @group, html: { class: 'fieldset-form' } do |form|
= form_errors(@group)
%fieldset
......
- return unless @group.feature_available?(:custom_file_templates_for_namespace)
%section.settings.gs-advanced.no-animate#js-templates{ class: ('expanded' if expanded) }
%section.settings.no-animate#js-templates{ class: ('expanded' if expanded) }
.settings-header
%h4
= _('Templates')
......
- @no_container = true
= render('shared/insights', endpoint: group_insights_path(@group), query_endpoint: query_group_insights_path(@group))
- return unless group_sidebar_link?(:group_insights)
= nav_link(path: 'groups/insights#show') do
= link_to group_insights_path(@group), title: _('Insights'), class: 'shortcuts-group-insights' do
%span= _('Insights')
- return unless project_nav_tab?(:project_insights)
= nav_link(path: 'projects/insights#show') do
= link_to project_insights_path(@project), title: _('Insights'), class: 'shortcuts-project-insights' do
%span= _('Insights')
- @no_container = true
= render('shared/insights', endpoint: namespace_project_insights_path(@project.namespace, @project), query_endpoint: query_namespace_project_insights_path(@project.namespace, @project))
- page_title _('Insights')
%h2 Insights chart data
%pre
%code= @insights_json
%div{ class: container_class }
#js-insights-pane{ data: { endpoint: endpoint, query_endpoint: query_endpoint } }
......@@ -26,6 +26,12 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
resource :issues_analytics, only: [:show]
resource :insights, only: [:show] do
collection do
post :query
end
end
resource :notification_setting, only: [:update]
resources :ldap_group_links, only: [:index, :create, :destroy]
......
......@@ -34,6 +34,12 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
end
resource :insights, only: [:show] do
collection do
post :query
end
end
scope '-' do
resources :packages, only: [:index, :show, :destroy], module: :packages
resources :package_files, only: [], module: :packages do
......
......@@ -2,6 +2,7 @@
module Gitlab
module Insights
CONFIG_FILE_PATH = '.gitlab/insights.yml'
COLOR_SCHEME = {
red: '#e6194b',
green: '#3cb44b',
......
# frozen_string_literal: true
module Gitlab
module Insights
module Validators
class ParamsValidator
ParamsValidatorError = Class.new(StandardError)
InvalidChartTypeError = Class.new(ParamsValidatorError)
SUPPORTER_CHART_TYPES = %w[bar line stacked-bar pie].freeze
def initialize(params)
@params = params
end
def validate!
unless SUPPORTER_CHART_TYPES.include?(params[:chart_type])
raise InvalidChartTypeError, "Invalid `:chart_type`: `#{params[:chart_type]}`. Allowed values are #{SUPPORTER_CHART_TYPES}!"
end
end
private
attr_reader :params
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'Group Insights' do
it_behaves_like 'Insights page' do
set(:entity) { create(:group) }
let(:route) { url_for([entity, :insights]) }
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'Project Insights' do
it_behaves_like 'Insights page' do
set(:entity) { create(:project) }
let(:route) { url_for([entity.namespace, entity, :insights]) }
end
end
# frozen_string_literal: true
require 'spec_helper'
describe '[EE] Internal Group access' do
include AccessMatchers
set(:group) { create(:group, :internal) }
set(:project) { create(:project, :internal, group: group) }
set(:project_guest) do
create(:user) do |user|
project.add_guest(user)
end
end
describe 'GET /groups/:path/-/insights' do
before do
stub_licensed_features(insights: true)
end
subject { group_insights_path(group) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:auditor) }
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_allowed_for(:maintainer).of(group) }
it { is_expected.to be_allowed_for(:developer).of(group) }
it { is_expected.to be_allowed_for(:reporter).of(group) }
it { is_expected.to be_allowed_for(:guest).of(group) }
it { is_expected.to be_allowed_for(project_guest) }
it { is_expected.to be_allowed_for(:user) }
it { is_expected.to be_denied_for(:external) }
it { is_expected.to be_denied_for(:visitor) }
end
end
# frozen_string_literal: true
require 'spec_helper'
describe '[EE] Private Group access' do
include AccessMatchers
set(:group) { create(:group, :private) }
set(:project) { create(:project, :private, group: group) }
set(:project_guest) do
create(:user) do |user|
project.add_guest(user)
end
end
describe 'GET /groups/:path/-/insights' do
before do
stub_licensed_features(insights: true)
end
subject { group_insights_path(group) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:auditor) }
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_allowed_for(:maintainer).of(group) }
it { is_expected.to be_allowed_for(:developer).of(group) }
it { is_expected.to be_allowed_for(:reporter).of(group) }
it { is_expected.to be_allowed_for(:guest).of(group) }
it { is_expected.to be_allowed_for(project_guest) }
it { is_expected.to be_denied_for(:user) }
it { is_expected.to be_denied_for(:external) }
it { is_expected.to be_denied_for(:visitor) }
end
end
# frozen_string_literal: true
require 'spec_helper'
describe '[EE] Public Group access' do
include AccessMatchers
set(:group) { create(:group, :public) }
set(:project) { create(:project, :public, group: group) }
set(:project_guest) do
create(:user) do |user|
project.add_guest(user)
end
end
describe 'GET /groups/:path/-/insights' do
before do
stub_licensed_features(insights: true)
end
subject { group_insights_path(group) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:auditor) }
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_allowed_for(:maintainer).of(group) }
it { is_expected.to be_allowed_for(:developer).of(group) }
it { is_expected.to be_allowed_for(:reporter).of(group) }
it { is_expected.to be_allowed_for(:guest).of(group) }
it { is_expected.to be_allowed_for(project_guest) }
it { is_expected.to be_allowed_for(:user) }
it { is_expected.to be_allowed_for(:external) }
it { is_expected.to be_allowed_for(:visitor) }
end
end
# frozen_string_literal: true
require 'spec_helper'
describe '[EE] Internal Project Access' do
include AccessMatchers
set(:project) { create(:project, :internal, :repository) }
describe 'GET /:project_path/insights' do
before do
stub_licensed_features(insights: true)
end
subject { project_insights_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:auditor) }
it { is_expected.to be_allowed_for(:owner).of(project) }
it { is_expected.to be_allowed_for(:maintainer).of(project) }
it { is_expected.to be_allowed_for(:developer).of(project) }
it { is_expected.to be_allowed_for(:reporter).of(project) }
it { is_expected.to be_allowed_for(:guest).of(project) }
it { is_expected.to be_allowed_for(:user) }
it { is_expected.to be_denied_for(:external) }
it { is_expected.to be_denied_for(:visitor) }
end
end
# frozen_string_literal: true
require 'spec_helper'
describe '[EE] Private Project Access' do
include AccessMatchers
set(:project) { create(:project, :private, :repository) }
describe 'GET/:project_path/insights' do
before do
stub_licensed_features(insights: true)
end
subject { project_insights_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:auditor) }
it { is_expected.to be_allowed_for(:owner).of(project) }
it { is_expected.to be_allowed_for(:maintainer).of(project) }
it { is_expected.to be_allowed_for(:developer).of(project) }
it { is_expected.to be_allowed_for(:reporter).of(project) }
it { is_expected.to be_allowed_for(:guest).of(project) }
it { is_expected.to be_denied_for(:user) }
it { is_expected.to be_denied_for(:external) }
it { is_expected.to be_denied_for(:visitor) }
end
end
# frozen_string_literal: true
require 'spec_helper'
describe '[EE] Public Project Access' do
include AccessMatchers
set(:project) { create(:project, :public) }
describe 'GET /:project_path/insights' do
before do
stub_licensed_features(insights: true)
end
subject { project_insights_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:auditor) }
it { is_expected.to be_allowed_for(:owner).of(project) }
it { is_expected.to be_allowed_for(:maintainer).of(project) }
it { is_expected.to be_allowed_for(:developer).of(project) }
it { is_expected.to be_allowed_for(:reporter).of(project) }
it { is_expected.to be_allowed_for(:guest).of(project) }
it { is_expected.to be_allowed_for(:user) }
it { is_expected.to be_allowed_for(:external) }
it { is_expected.to be_allowed_for(:visitor) }
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Insights::Validators::ParamsValidator do
subject { described_class.new(params).validate! }
describe ':chart_type' do
described_class::SUPPORTER_CHART_TYPES.each do |chart_type|
context "with chart_type: '#{chart_type}'" do
let(:params) do
{ chart_type: chart_type }
end
it 'does not raise an error' do
expect { subject }.not_to raise_error
end
end
end
context 'with an invalid :chart_type' do
let(:params) do
{ chart_type: 'unknown' }
end
it 'raises an error' do
expect { subject }.to raise_error(described_class::InvalidChartTypeError, "Invalid `:chart_type`: `unknown`. Allowed values are #{described_class::SUPPORTER_CHART_TYPES}!")
end
end
end
end
......@@ -395,9 +395,53 @@ describe Group do
end
end
describe '#insights_available?' do
it_behaves_like 'an entity with the Insights feature' do
describe '#beta_feature_available?' do
it_behaves_like 'an entity with beta feature support' do
let(:entity) { group }
end
end
describe "#insights_config" do
context 'when group has no Insights project configured' do
it 'returns nil' do
expect(group.insights_config).to be_nil
end
end
context 'when group has an Insights project configured without a config file' do
before do
project = create(:project, group: group)
group.create_insight!(project: project)
end
it 'returns nil' do
expect(group.insights_config).to be_nil
end
end
context 'when group has an Insights project configured' do
before do
project = create(:project, :custom_repo, group: group, files: { ::Gitlab::Insights::CONFIG_FILE_PATH => insights_file_content })
group.create_insight!(project: project)
end
context 'with a valid config file' do
let(:insights_file_content) { 'key: monthlyBugsCreated' }
it 'returns the insights config data' do
insights_config = group.insights_config
expect(insights_config).to eq(key: 'monthlyBugsCreated')
end
end
context 'with an invalid config file' do
let(:insights_file_content) { ': foo bar' }
it 'returns the insights config data' do
expect(group.insights_config).to be_nil
end
end
end
end
end
......@@ -325,6 +325,12 @@ describe Project do
end
end
describe '#beta_feature_available?' do
it_behaves_like 'an entity with beta feature support' do
let(:entity) { create(:project) }
end
end
describe '#feature_available?' do
let(:namespace) { create(:namespace) }
let(:plan_license) { nil }
......@@ -1729,9 +1735,35 @@ describe Project do
end
end
describe '#insights_available?' do
it_behaves_like 'an entity with the Insights feature' do
let(:entity) { create(:project) }
describe "#insights_config" do
context 'when project has no Insights config file' do
it 'returns nil' do
expect(create(:project).insights_config).to be_nil
end
end
context 'when project has an Insights config file' do
let(:project) do
create(:project, :custom_repo, files: { ::Gitlab::Insights::CONFIG_FILE_PATH => insights_file_content })
end
context 'with a valid config file' do
let(:insights_file_content) { 'key: monthlyBugsCreated' }
it 'returns the insights config data' do
insights_config = project.insights_config
expect(insights_config).to eq(key: 'monthlyBugsCreated')
end
end
context 'with an invalid config file' do
let(:insights_file_content) { ': foo bar' }
it 'returns the insights config data' do
expect(project.insights_config).to be_nil
end
end
end
end
......
......@@ -187,4 +187,24 @@ describe Repository do
.not_to change { ::Geo::RepositoryUpdatedEvent.count }
end
end
describe "#insights_config_for" do
context 'when no config file exists' do
it 'returns nil if does not exist' do
expect(repository.insights_config_for(repository.root_ref)).to be_nil
end
end
it 'returns nil for an empty repository' do
allow(repository).to receive(:empty?).and_return(true)
expect(repository.insights_config_for(repository.root_ref)).to be_nil
end
it 'returns a valid Insights config file' do
project = create(:project, :custom_repo, files: { Gitlab::Insights::CONFIG_FILE_PATH => "monthlyBugsCreated:\n title: My chart" })
expect(project.repository.insights_config_for(project.repository.root_ref)).to eq("monthlyBugsCreated:\n title: My chart")
end
end
end
# frozen_string_literal: true
RSpec.shared_examples 'Insights page' do
set(:user) { create(:user) }
context 'as a permitted user' do
before(:context) do
entity.add_maintainer(user)
end
before do
sign_in(user)
end
context 'with correct license' do
before do
stub_licensed_features(insights: true)
end
it 'has correct title' do
visit route
expect(page).to have_gitlab_http_status(200)
expect(page).to have_content('Insights')
end
context 'when the feature flag is disabled globally' do
before do
stub_feature_flags(insights: false)
end
it 'returns 404' do
visit route
expect(page).to have_gitlab_http_status(404)
end
end
end
context 'without correct license' do
before do
stub_feature_flags(insights: { enabled: false, thing: entity })
stub_licensed_features(insights: false)
end
it 'returns 404' do
visit route
expect(page).to have_gitlab_http_status(404)
end
end
end
end
# This needs an `entity` object: Project or Group.
RSpec.shared_examples 'an entity with the Insights feature' do
RSpec.shared_examples 'an entity with beta feature support' do
context 'when license does not allow it' do
before do
# This is needed because all feature flags are enabled by default in tests
allow(Feature).to receive(:enabled?)
.with(:insights, entity)
.and_return(false)
stub_licensed_features(insights: false)
end
context 'when license does not allow it' do
context 'when the feature flag is disabled globally' do
before do
stub_licensed_features(insights: false)
stub_feature_flags(insights: false)
end
it { expect(entity).not_to be_insights_available }
it { expect(entity.beta_feature_available?(:insights)).to be_falsy }
end
context 'when the feature flag is enabled globally' do
before do
stub_feature_flags(insights: true)
end
it { expect(entity).not_to be_insights_available }
it { expect(entity.beta_feature_available?(:insights)).to be_truthy }
end
context 'when the feature flag is enabled for the entity' do
......@@ -27,7 +26,7 @@ RSpec.shared_examples 'an entity with the Insights feature' do
stub_feature_flags(insights: { enabled: true, thing: entity })
end
it { expect(entity).not_to be_insights_available }
it { expect(entity.beta_feature_available?(:insights)).to be_truthy }
end
end
......@@ -36,14 +35,12 @@ RSpec.shared_examples 'an entity with the Insights feature' do
stub_licensed_features(insights: true)
end
it { expect(entity).not_to be_insights_available }
context 'when the feature flag is disabled globally' do
before do
stub_feature_flags(insights: false)
end
it { expect(entity).not_to be_insights_available }
it { expect(entity.beta_feature_available?(:insights)).to be_falsy }
end
context 'when the feature flag is enabled globally' do
......@@ -51,7 +48,7 @@ RSpec.shared_examples 'an entity with the Insights feature' do
stub_feature_flags(insights: true)
end
it { expect(entity).to be_insights_available }
it { expect(entity.beta_feature_available?(:insights)).to be_truthy }
end
context 'when the feature flag is enabled for the entity' do
......@@ -59,7 +56,7 @@ RSpec.shared_examples 'an entity with the Insights feature' do
stub_feature_flags(insights: { enabled: true, thing: entity })
end
it { expect(entity).to be_insights_available }
it { expect(entity.beta_feature_available?(:insights)).to be_truthy }
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