Commit 46aa4e91 authored by Yorick Peterse's avatar Yorick Peterse

Filtering of merge requests by deployments data

This adds support for filtering merge requests by the environment name
and deployment times, in addition to the existing support of filtering
merge requests by a deployment ID. This means you can now get merge
requests that match an environment name, deployment ID, or are deployed
before/after a given date and time. These filters can be combined as
well.

Filtering merge requests by deployments data makes it easy to see what
has been deployed, and when. In addition, you can apply all the other
merge requests filters such as a list of labels to filter by.

This new feature is hidden behind the "deployment_filters" feature flag,
which is disabled by default. This feature flag is a global flag. Merge
requests can be retrieved for both projects and groups, making it
difficult to determine what to scope the feature flag to. So we take the
easy way: it's either enabled for all, or disabled for all. For
something as simple as a few additional filters this should not pose any
problems.

See https://gitlab.com/gitlab-com/gl-infra/delivery/-/issues/1246 for
more information.
parent eb1b194f
......@@ -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
......@@ -68,6 +68,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) }
......@@ -120,6 +121,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
......
......@@ -8972,6 +8972,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