Commit dcda156e authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'filter-merge-requests-by-deployments' into 'master'

Filtering of merge requests by deployments data

See merge request gitlab-org/gitlab!44041
parents 5f2abc4d 46aa4e91
......@@ -108,4 +108,44 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
IssuableTokenKeys.tokenKeys.splice(tokenPosition, 0, ...[approvedBy.token]);
IssuableTokenKeys.tokenKeysWithAlternative.splice(tokenPosition, 0, ...[approvedBy.token]);
IssuableTokenKeys.conditions.push(...approvedBy.condition);
if (gon?.features?.deploymentFilters) {
const environmentToken = {
formattedKey: __('Environment'),
key: 'environment',
type: 'string',
param: '',
symbol: '',
icon: 'cloud-gear',
tag: 'environment',
};
const deployedBeforeToken = {
formattedKey: __('Deployed-before'),
key: 'deployed-before',
type: 'string',
param: '',
symbol: '',
icon: 'clock',
tag: 'deployed_before',
};
const deployedAfterToken = {
formattedKey: __('Deployed-after'),
key: 'deployed-after',
type: 'string',
param: '',
symbol: '',
icon: 'clock',
tag: 'deployed_after',
};
IssuableTokenKeys.tokenKeys.push(environmentToken, deployedBeforeToken, deployedAfterToken);
IssuableTokenKeys.tokenKeysWithAlternative.push(
environmentToken,
deployedBeforeToken,
deployedAfterToken,
);
}
};
......@@ -15,6 +15,7 @@ export default class AvailableDropdownMappings {
labelsEndpoint,
milestonesEndpoint,
releasesEndpoint,
environmentsEndpoint,
groupsOnly,
includeAncestorGroups,
includeDescendantGroups,
......@@ -24,6 +25,7 @@ export default class AvailableDropdownMappings {
this.labelsEndpoint = labelsEndpoint;
this.milestonesEndpoint = milestonesEndpoint;
this.releasesEndpoint = releasesEndpoint;
this.environmentsEndpoint = environmentsEndpoint;
this.groupsOnly = groupsOnly;
this.includeAncestorGroups = includeAncestorGroups;
this.includeDescendantGroups = includeDescendantGroups;
......@@ -149,6 +151,16 @@ export default class AvailableDropdownMappings {
},
element: this.container.querySelector('#js-dropdown-target-branch'),
},
environment: {
reference: null,
gl: DropdownNonUser,
extraArguments: {
endpoint: this.getEnvironmentsEndpoint(),
symbol: '',
preprocessing: data => data.map(env => ({ title: env })),
},
element: this.container.querySelector('#js-dropdown-environment'),
},
};
}
......@@ -194,6 +206,10 @@ export default class AvailableDropdownMappings {
return mergeUrlParams(params, endpoint);
}
getEnvironmentsEndpoint() {
return `${this.environmentsEndpoint}.json`;
}
getGroupId() {
return this.filteredSearchInput.getAttribute('data-group-id') || '';
}
......
......@@ -13,6 +13,7 @@ export default class FilteredSearchDropdownManager {
labelsEndpoint = '',
milestonesEndpoint = '',
releasesEndpoint = '',
environmentsEndpoint = '',
epicsEndpoint = '',
tokenizer,
page,
......@@ -29,6 +30,7 @@ export default class FilteredSearchDropdownManager {
this.milestonesEndpoint = removeTrailingSlash(milestonesEndpoint);
this.releasesEndpoint = removeTrailingSlash(releasesEndpoint);
this.epicsEndpoint = removeTrailingSlash(epicsEndpoint);
this.environmentsEndpoint = removeTrailingSlash(environmentsEndpoint);
this.tokenizer = tokenizer;
this.filteredSearchTokenKeys = filteredSearchTokenKeys || FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
......
......@@ -110,6 +110,7 @@ export default class FilteredSearchManager {
labelsEndpoint = '',
milestonesEndpoint = '',
releasesEndpoint = '',
environmentsEndpoint = '',
epicsEndpoint = '',
} = this.filteredSearchInput.dataset;
......@@ -118,6 +119,7 @@ export default class FilteredSearchManager {
labelsEndpoint,
milestonesEndpoint,
releasesEndpoint,
environmentsEndpoint,
epicsEndpoint,
tokenizer: this.tokenizer,
page: this.page,
......
......@@ -30,6 +30,7 @@ class GroupsController < Groups::ApplicationController
before_action do
push_frontend_feature_flag(:vue_issuables_list, @group)
push_frontend_feature_flag(:deployment_filters)
end
before_action do
......@@ -53,7 +54,7 @@ class GroupsController < Groups::ApplicationController
feature_category :audit_events, [:activity]
feature_category :issue_tracking, [:issues, :issues_calendar, :preview_markdown]
feature_category :code_review, [:merge_requests]
feature_category :code_review, [:merge_requests, :unfoldered_environment_names]
feature_category :projects, [:projects]
feature_category :importers, [:export, :download_export]
......@@ -179,6 +180,16 @@ class GroupsController < Groups::ApplicationController
end
end
def unfoldered_environment_names
return render_404 unless Feature.enabled?(:deployment_filters)
respond_to do |format|
format.json do
render json: EnvironmentNamesFinder.new(@group, current_user).execute
end
end
end
protected
def render_show_html
......
......@@ -47,6 +47,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, @project.group)
push_frontend_feature_flag(:deployment_filters)
end
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions]
......
......@@ -56,6 +56,7 @@ class ProjectsController < Projects::ApplicationController
feature_category :issue_tracking, [:preview_markdown, :new_issuable_address]
feature_category :importers, [:export, :remove_export, :generate_new_export, :download_export]
feature_category :audit_events, [:activity]
feature_category :code_review, [:unfoldered_environment_names]
def index
redirect_to(current_user ? root_path : explore_root_path)
......@@ -315,6 +316,16 @@ class ProjectsController < Projects::ApplicationController
end
end
def unfoldered_environment_names
return render_404 unless Feature.enabled?(:deployment_filters)
respond_to do |format|
format.json do
render json: EnvironmentNamesFinder.new(@project, current_user).execute
end
end
end
private
# Render project landing depending of which features are available
......
# frozen_string_literal: true
# Finder for obtaining the unique environment names of a project or group.
#
# This finder exists so that the merge requests "environments" filter can be
# populated with a unique list of environment names. If we retrieve _just_ the
# environments, duplicates may be present (e.g. multiple projects in a group
# having a "staging" environment).
#
# In addition, this finder only produces unfoldered environments. We do this
# because when searching for environments we want to exclude review app
# environments.
class EnvironmentNamesFinder
attr_reader :project_or_group, :current_user
def initialize(project_or_group, current_user)
@project_or_group = project_or_group
@current_user = current_user
end
def execute
all_environments.unfoldered.order_by_name.pluck_unique_names
end
def all_environments
if project_or_group.is_a?(Namespace)
namespace_environments
else
project_environments
end
end
def namespace_environments
projects =
project_or_group.all_projects.public_or_visible_to_user(current_user)
Environment.for_project(projects)
end
def project_environments
if current_user.can?(:read_environment, project_or_group)
project_or_group.environments
else
Environment.none
end
end
end
......@@ -33,7 +33,17 @@ class MergeRequestsFinder < IssuableFinder
include MergedAtFilter
def self.scalar_params
@scalar_params ||= super + [:wip, :draft, :target_branch, :merged_after, :merged_before, :approved_by_ids]
@scalar_params ||= super + [
:approved_by_ids,
:deployed_after,
:deployed_before,
:draft,
:environment,
:merged_after,
:merged_before,
:target_branch,
:wip
]
end
def self.array_params
......@@ -46,12 +56,13 @@ class MergeRequestsFinder < IssuableFinder
def filter_items(_items)
items = by_commit(super)
items = by_deployment(items)
items = by_source_branch(items)
items = by_draft(items)
items = by_target_branch(items)
items = by_merged_at(items)
items = by_approvals(items)
items = by_deployments(items)
by_source_project_id(items)
end
......@@ -85,17 +96,21 @@ class MergeRequestsFinder < IssuableFinder
items.where(target_branch: target_branch)
end
# rubocop: enable CodeReuse/ActiveRecord
def source_project_id
@source_project_id ||= params[:source_project_id].presence
end
# rubocop: disable CodeReuse/ActiveRecord
def by_source_project_id(items)
return items unless source_project_id
items.where(source_project_id: source_project_id)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def by_draft(items)
draft_param = params[:draft] || params[:wip]
......@@ -107,6 +122,7 @@ class MergeRequestsFinder < IssuableFinder
items
end
end
# rubocop: enable CodeReuse/ActiveRecord
# WIP is deprecated in favor of Draft. Currently both options are supported
def wip_match(table)
......@@ -126,12 +142,14 @@ class MergeRequestsFinder < IssuableFinder
.or(table[:title].matches('(Draft)%'))
end
# rubocop: disable CodeReuse/ActiveRecord
def by_deployment(items)
return items unless deployment_id
items.includes(:deployment_merge_requests)
.where(deployment_merge_requests: { deployment_id: deployment_id })
end
# rubocop: enable CodeReuse/ActiveRecord
def deployment_id
@deployment_id ||= params[:deployment_id].presence
......@@ -149,6 +167,33 @@ class MergeRequestsFinder < IssuableFinder
def items_assigned_to(items, user)
MergeRequest.from_union([super, items.reviewer_assigned_to(user)])
end
def by_deployments(items)
# Until this feature flag is enabled permanently, we retain the old
# filtering behaviour/code.
return by_deployment(items) unless Feature.enabled?(:deployment_filters)
env = params[:environment]
before = params[:deployed_before]
after = params[:deployed_after]
id = params[:deployment_id]
return items if !env && !before && !after && !id
# Each filter depends on the same JOIN+WHERE. To prevent this JOIN+WHERE
# from being duplicated for every filter, we only produce it once. The
# filter methods in turn expect the JOIN+WHERE to already be present.
#
# This approach ensures that query performance doesn't degrade as the number
# of deployment related filters increases.
deploys = DeploymentMergeRequest.join_deployments_for_merge_requests
deploys = deploys.by_deployment_id(id) if id
deploys = deploys.deployed_to(env) if env
deploys = deploys.deployed_before(before) if before
deploys = deploys.deployed_after(after) if after
items.where_exists(deploys)
end
end
MergeRequestsFinder.prepend_if_ee('EE::MergeRequestsFinder')
......@@ -262,11 +262,15 @@ module SearchHelper
opts[:data]['labels-endpoint'] = project_labels_path(@project)
opts[:data]['milestones-endpoint'] = project_milestones_path(@project)
opts[:data]['releases-endpoint'] = project_releases_path(@project)
opts[:data]['environments-endpoint'] =
unfoldered_environment_names_project_path(@project)
elsif @group.present?
opts[:data]['group-id'] = @group.id
opts[:data]['labels-endpoint'] = group_labels_path(@group)
opts[:data]['milestones-endpoint'] = group_milestones_path(@group)
opts[:data]['releases-endpoint'] = group_releases_path(@group)
opts[:data]['environments-endpoint'] =
unfoldered_environment_names_group_path(@group)
else
opts[:data]['labels-endpoint'] = dashboard_labels_path
opts[:data]['milestones-endpoint'] = dashboard_milestones_path
......
......@@ -3,4 +3,25 @@
class DeploymentMergeRequest < ApplicationRecord
belongs_to :deployment, optional: false
belongs_to :merge_request, optional: false
def self.join_deployments_for_merge_requests
joins(deployment: :environment)
.where('deployment_merge_requests.merge_request_id = merge_requests.id')
end
def self.by_deployment_id(id)
where('deployments.id = ?', id)
end
def self.deployed_to(name)
where('environments.name = ?', name)
end
def self.deployed_after(time)
where('deployments.finished_at > ?', time)
end
def self.deployed_before(time)
where('deployments.finished_at < ?', time)
end
end
......@@ -70,6 +70,7 @@ class Environment < ApplicationRecord
scope :order_by_last_deployed_at_desc, -> do
order(Gitlab::Database.nulls_last_order("(#{max_deployment_id_sql})", 'DESC'))
end
scope :order_by_name, -> { order('environments.name ASC') }
scope :in_review_folder, -> { where(environment_type: "review") }
scope :for_name, -> (name) { where(name: name) }
......@@ -122,6 +123,10 @@ class Environment < ApplicationRecord
pluck(:name)
end
def self.pluck_unique_names
pluck('DISTINCT(environments.name)')
end
def self.find_or_create_by_name(name)
find_or_create_by(name: name)
end
......
......@@ -161,6 +161,11 @@
%li.filter-dropdown-item
%button.btn.btn-link.js-data-value.monospace
{{title}}
#js-dropdown-environment.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.btn.btn-link.js-data-value{ type: 'button' }
{{title}}
= render_if_exists 'shared/issuable/filter_weight', type: type
......
---
name: deployment_filters
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44041
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/267561
type: development
group: group::source code
default_enabled: false
......@@ -17,6 +17,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
put :transfer, as: :transfer_group # rubocop:disable Cop/PutGroupRoutesUnderScope
post :export, as: :export_group # rubocop:disable Cop/PutGroupRoutesUnderScope
get :download_export, as: :download_export_group # rubocop:disable Cop/PutGroupRoutesUnderScope
get :unfoldered_environment_names, as: :unfoldered_environment_names_group # rubocop:disable Cop/PutGroupRoutesUnderScope
# TODO: Remove as part of refactor in https://gitlab.com/gitlab-org/gitlab-foss/issues/49693
get 'shared', action: :show, as: :group_shared # rubocop:disable Cop/PutGroupRoutesUnderScope
......
......@@ -578,6 +578,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
get :activity
get :refs
put :new_issuable_address
get :unfoldered_environment_names
end
end
# rubocop: enable Cop/PutProjectRoutesUnderScope
......
......@@ -72,6 +72,9 @@ Parameters:
| `in` | string | no | Modify the scope of the `search` attribute. `title`, `description`, or a string joining them with comma. Default is `title,description` |
| `wip` | string | no | Filter merge requests against their `wip` status. `yes` to return *only* WIP merge requests, `no` to return *non* WIP merge requests |
| `not` | Hash | no | Return merge requests that do not match the parameters supplied. Accepts: `labels`, `milestone`, `author_id`, `author_username`, `assignee_id`, `assignee_username`, `my_reaction_emoji` |
| `environment` | string | no | Returns merge requests deployed to the given environment
| `deployed_before` | datetime | no | Return merge requests deployed before the given date/time
| `deployed_after` | datetime | no | Return merge requests deployed after the given date/time
NOTE: **Note:**
[Starting in GitLab 13.0](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/31890),
......
......@@ -11,6 +11,7 @@ export default class AvailableDropdownMappings {
milestonesEndpoint,
epicsEndpoint,
releasesEndpoint,
environmentsEndpoint,
groupsOnly,
includeAncestorGroups,
includeDescendantGroups,
......@@ -21,6 +22,7 @@ export default class AvailableDropdownMappings {
this.milestonesEndpoint = milestonesEndpoint;
this.epicsEndpoint = epicsEndpoint;
this.releasesEndpoint = releasesEndpoint;
this.environmentsEndpoint = environmentsEndpoint;
this.groupsOnly = groupsOnly;
this.includeAncestorGroups = includeAncestorGroups;
this.includeDescendantGroups = includeDescendantGroups;
......
......@@ -73,6 +73,13 @@ module API
optional :not, type: Hash, desc: 'Parameters to negate' do
use :merge_requests_negatable_params
end
optional :deployed_before,
'Return merge requests deployed before the given date/time'
optional :deployed_after,
'Return merge requests deployed after the given date/time'
optional :environment,
'Returns merge requests deployed to the given environment'
end
params :optional_scope_param do
......
......@@ -8981,6 +8981,12 @@ msgstr ""
msgid "Deployed to"
msgstr ""
msgid "Deployed-after"
msgstr ""
msgid "Deployed-before"
msgstr ""
msgid "Deploying to"
msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Merge Requests > User filters by deployments', :js do
include FilteredSearchHelpers
let!(:project) { create(:project, :public, :repository) }
let!(:user) { project.creator }
let!(:gstg) { create(:environment, project: project, name: 'gstg') }
let!(:gprd) { create(:environment, project: project, name: 'gprd') }
let(:mr1) do
create(
:merge_request,
:simple,
:merged,
author: user,
source_project: project,
target_project: project
)
end
let(:mr2) do
create(
:merge_request,
:simple,
:merged,
author: user,
source_project: project,
target_project: project
)
end
let(:deploy1) do
create(
:deployment,
:success,
deployable: nil,
environment: gstg,
project: project,
sha: mr1.diff_head_sha,
finished_at: Time.utc(2020, 10, 1, 0, 0)
)
end
let(:deploy2) do
create(
:deployment,
:success,
deployable: nil,
environment: gprd,
project: project,
sha: mr2.diff_head_sha,
finished_at: Time.utc(2020, 10, 2, 0, 0)
)
end
before do
deploy1.link_merge_requests(MergeRequest.where(id: mr1.id))
deploy2.link_merge_requests(MergeRequest.where(id: mr2.id))
sign_in(user)
visit(project_merge_requests_path(project, state: :merged))
end
describe 'filtering by deployed-before' do
it 'applies the filter' do
input_filtered_search('deployed-before:=2020-10-02')
expect(page).to have_issuable_counts(open: 0, merged: 1, all: 1)
expect(page).to have_content mr1.title
end
end
describe 'filtering by deployed-after' do
it 'applies the filter' do
input_filtered_search('deployed-after:=2020-10-01')
expect(page).to have_issuable_counts(open: 0, merged: 1, all: 1)
expect(page).to have_content mr2.title
end
end
describe 'filtering by environment' do
it 'applies the filter' do
input_filtered_search('environment:=gstg')
expect(page).to have_issuable_counts(open: 0, merged: 1, all: 1)
expect(page).to have_content mr1.title
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe EnvironmentNamesFinder do
describe '#execute' do
let!(:group) { create(:group) }
let!(:project1) { create(:project, :public, namespace: group) }
let!(:project2) { create(:project, :private, namespace: group) }
let!(:user) { create(:user) }
before do
create(:environment, name: 'gstg', project: project1)
create(:environment, name: 'gprd', project: project1)
create(:environment, name: 'gprd', project: project2)
create(:environment, name: 'gcny', project: project2)
end
context 'using a group and a group member' do
it 'returns environment names for all projects' do
group.add_developer(user)
names = described_class.new(group, user).execute
expect(names).to eq(%w[gcny gprd gstg])
end
end
context 'using a group and a guest' do
it 'returns environment names for all public projects' do
names = described_class.new(group, user).execute
expect(names).to eq(%w[gprd gstg])
end
end
context 'using a public project and a project member' do
it 'returns all the unique environment names' do
project1.team.add_developer(user)
names = described_class.new(project1, user).execute
expect(names).to eq(%w[gprd gstg])
end
end
context 'using a public project and a guest' do
it 'returns all the unique environment names' do
names = described_class.new(project1, user).execute
expect(names).to eq(%w[gprd gstg])
end
end
context 'using a private project and a guest' do
it 'returns all the unique environment names' do
names = described_class.new(project2, user).execute
expect(names).to be_empty
end
end
end
end
......@@ -510,6 +510,83 @@ RSpec.describe MergeRequestsFinder do
expect(merge_requests).to contain_exactly(old_merge_request, new_merge_request)
end
end
context 'filtering by the merge request deployments' do
let(:gstg) { create(:environment, project: project4, name: 'gstg') }
let(:gprd) { create(:environment, project: project4, name: 'gprd') }
let(:mr1) do
create(
:merge_request,
:simple,
:merged,
author: user,
source_project: project4,
target_project: project4
)
end
let(:mr2) do
create(
:merge_request,
:simple,
:merged,
author: user,
source_project: project4,
target_project: project4
)
end
let(:deploy1) do
create(
:deployment,
:success,
deployable: nil,
environment: gstg,
project: project4,
sha: mr1.diff_head_sha,
finished_at: Time.utc(2020, 10, 1, 12, 0)
)
end
let(:deploy2) do
create(
:deployment,
:success,
deployable: nil,
environment: gprd,
project: project4,
sha: mr2.diff_head_sha,
finished_at: Time.utc(2020, 10, 2, 15, 0)
)
end
before do
deploy1.link_merge_requests(MergeRequest.where(id: mr1.id))
deploy2.link_merge_requests(MergeRequest.where(id: mr2.id))
end
it 'filters merge requests deployed to a given environment' do
mrs = described_class.new(user, environment: 'gstg').execute
expect(mrs).to eq([mr1])
end
it 'filters merge requests deployed before a given date' do
mrs =
described_class.new(user, deployed_before: '2020-10-02').execute
expect(mrs).to eq([mr1])
end
it 'filters merge requests deployed after a given date' do
mrs = described_class
.new(user, deployed_after: '2020-10-01 12:00')
.execute
expect(mrs).to eq([mr2])
end
end
end
describe '#row_count', :request_store do
......
......@@ -856,6 +856,55 @@ RSpec.describe API::MergeRequests do
expect(json_response.first['id']).to eq merge_request_closed.id
end
context 'when filtering by deployments' do
let_it_be(:mr) do
create(:merge_request, :merged, source_project: project, target_project: project)
end
before do
env = create(:environment, project: project, name: 'staging')
deploy = create(:deployment, :success, environment: env, deployable: nil)
deploy.link_merge_requests(MergeRequest.where(id: mr.id))
end
it 'supports getting merge requests deployed to an environment' do
get api(endpoint_path, user), params: { environment: 'staging' }
expect(json_response.first['id']).to eq mr.id
end
it 'does not return merge requests for an environment without deployments' do
get api(endpoint_path, user), params: { environment: 'bla' }
expect_empty_array_response
end
it 'supports getting merge requests deployed after a date' do
get api(endpoint_path, user), params: { deployed_after: '1990-01-01' }
expect(json_response.first['id']).to eq mr.id
end
it 'does not return merge requests not deployed after a given date' do
get api(endpoint_path, user), params: { deployed_after: '2100-01-01' }
expect_empty_array_response
end
it 'supports getting merge requests deployed before a date' do
get api(endpoint_path, user), params: { deployed_before: '2100-01-01' }
expect(json_response.first['id']).to eq mr.id
end
it 'does not return merge requests not deployed before a given date' do
get api(endpoint_path, user), params: { deployed_before: '1990-01-01' }
expect_empty_array_response
end
end
context 'a project which enforces all discussions to be resolved' do
let_it_be(:project) { create(:project, :repository, only_allow_merge_if_all_discussions_are_resolved: true) }
......
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