Commit 9f0e1228 authored by Mark Chao's avatar Mark Chao

Provide approver suggestion for MR

Move logic to view object MergeRequestApproverPresenter,
handling loading of suggested approvers,

Remove controller @suggested_approvers setup as
this is now in presenter.
parent db65d0dc
......@@ -5,8 +5,6 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
include DiffHelper
include RendersCommits
prepend ::EE::Projects::MergeRequests::CreationsController
skip_before_action :merge_request
before_action :whitelist_query_limiting, only: [:create]
before_action :authorize_create_merge_request_from!
......
......@@ -196,3 +196,13 @@ request from the source branch's project UI, pay attention to the created merge
request itself. It belongs to the target branch's project.
[self-approval]: #allowing-merge-request-authors-to-approve-their-own-merge-requests
## Approver suggestions
Approvers are suggested for merge requests based on the previous authors of the files affected by the merge request.
### CODEOWNERS file
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/7437>) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.4.
If the [CODEOWNERS](../code_owners.md) file is present in the target branch, more precise suggestions are provided based on its rules.
......@@ -6,17 +6,6 @@ module EE
private
# rubocop: disable CodeReuse/ActiveRecord
def set_suggested_approvers
if merge_request.requires_approve?
@suggested_approvers = ::Gitlab::AuthorityAnalyzer.new( # rubocop:disable Gitlab/ModuleWithInstanceVariables
merge_request,
merge_request.author || current_user
).calculate(merge_request.approvals_required)
end
end
# rubocop: enable CodeReuse/ActiveRecord
def merge_request_params
clamp_approvals_before_merge(super)
end
......
module EE
module Projects
module MergeRequests
module CreationsController
extend ActiveSupport::Concern
private
def define_new_vars
super
set_suggested_approvers
end
end
end
end
end
......@@ -51,12 +51,6 @@ module EE
end
# rubocop: enable CodeReuse/ActiveRecord
def define_edit_vars
super
set_suggested_approvers
end
def render_approvals_json
respond_to do |format|
format.json do
......
......@@ -66,6 +66,7 @@ class License < ActiveRecord::Base
system_header_footer
custom_project_templates
packages
code_owner_as_approver_suggestion
].freeze
EEU_FEATURES = EEP_FEATURES + %i[
......
# frozen_string_literal: true
# A view object to ONLY handle approver list display.
# Keeps internal states for performance purpose.
#
# Initialize with following params:
# - skip_user
class MergeRequestApproverPresenter < Gitlab::View::Presenter::Simple
include ActionView::Helpers::TagHelper
include ActionView::Helpers::UrlHelper
include ActionView::Helpers::OutputSafetyHelper
include ActionView::RecordIdentifier
include Gitlab::Utils::StrongMemoize
presents :merge_request
attr_reader :skip_user
def initialize(subject, **attributes)
@skip_user = subject.author || attributes.delete(:skip_user)
super
end
def any?
users.any?
end
def render
safe_join(users.map { |user| render_user(user) }, ', ')
end
def render_user(user)
if eligible_approver?(user)
link_to user.name, '#', id: dom_id(user)
else
content_tag(:span, user.name, title: 'Not an eligible approver', class: 'has-tooltip')
end
end
def show_code_owner_tips?
code_owner_enabled? && code_owner_loader.empty_code_owners?
end
private
def users
return @users if defined?(@users)
load_users
@users
end
def authorized_users
return @authorized_users if defined?(@authorized_users)
load_users
@authorized_users
end
def load_users
set_users_from_code_owners if code_owner_enabled?
set_users_from_git_log_authors if @users.blank?
end
def code_owner_enabled?
strong_memoize(:code_owner_enabled) do
merge_request.project.feature_available?(:code_owner_as_approver_suggestion)
end
end
def eligible_approver?(user)
authorized_users.include?(user)
end
def set_users_from_code_owners
@authorized_users = code_owner_loader.members.to_a
@users = @authorized_users + code_owner_loader.non_members
@users.delete(skip_user)
end
def set_users_from_git_log_authors
@users = ::Gitlab::AuthorityAnalyzer.new(merge_request, skip_user).calculate.first(merge_request.approvals_required)
@authorized_users = @users
end
def related_paths_for_code_owners
diffs = merge_request.diffs
return unless diffs
paths = []
diffs.diff_files.each do |diff|
paths << diff.old_path
paths << diff.new_path
end
paths.compact!
paths.uniq!
paths
end
def code_owner_loader
@code_owner_loader ||= Gitlab::CodeOwners::Loader.new(
merge_request.target_project,
merge_request.target_branch,
related_paths_for_code_owners
)
end
end
......@@ -76,7 +76,13 @@
= form.number_field :approvals_before_merge, class: 'form-control', value: issuable.approvals_required, readonly: !can_update_approvers
- if can_update_approvers
- approver_presenter = MergeRequestApproverPresenter.new(issuable, skip_user: current_user)
.form-text.text-muted.suggested-approvers
- if @suggested_approvers&.any?
- if approver_presenter.any?
Suggested approvers:
= raw @suggested_approvers.map { |approver| link_to sanitize(approver.name), "#", id: dom_id(approver) }.join(", ")
= approver_presenter.render
- if approver_presenter.show_code_owner_tips?
.form-text.text-muted
Tip: add a
= link_to 'CODEOWNERS', help_page_path('user/project/code_owners'), target: '_blank', tabindex: -1
to suggest approvers based on file paths and file types.
---
title: Suggest approvers based on code owners
merge_request: 7437
author:
type: added
......@@ -8,14 +8,12 @@ module Gitlab
@users = Hash.new(0)
end
# rubocop: disable CodeReuse/ActiveRecord
def calculate(number_of_approvers)
def calculate
involved_users
# Picks most active users from hash like: {user1: 2, user2: 6}
@users.sort_by { |user, count| -count }.map(&:first).take(number_of_approvers)
# Sort most active users from hash like: {user1: 2, user2: 6}
@users.sort_by { |user, count| -count }.map(&:first)
end
# rubocop: enable CodeReuse/ActiveRecord
private
......
......@@ -19,27 +19,15 @@ describe Gitlab::AuthorityAnalyzer do
]
end
let(:approvers) { described_class.new(merge_request, author).calculate(number_of_approvers) }
let(:approvers) { described_class.new(merge_request, author).calculate }
before do
merge_request.compare = double(:compare, raw_diffs: files)
allow(merge_request.target_project.repository).to receive(:commits).and_return(commits)
end
context 'when there are fewer contributors than requested' do
let(:number_of_approvers) { 5 }
it 'returns the full number of users' do
expect(approvers.length).to eq(2)
end
end
context 'when there are more contributors than requested' do
let(:number_of_approvers) { 1 }
it 'returns only the top n contributors' do
expect(approvers).to contain_exactly(user_a)
end
it 'returns contributors in order, without skip_user' do
expect(approvers).to contain_exactly(user_a, user_b)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe MergeRequestApproverPresenter do
let(:project) { create(:project, :repository) }
let(:merge_request) { create(:merge_request, target_project: project, source_project: project) }
let(:files) do
[
double(:file, old_path: 'coo', new_path: nil),
double(:file, old_path: 'foo', new_path: 'bar'),
double(:file, old_path: nil, new_path: 'baz')
]
end
let(:approvals_required) { 10 }
let(:enable_code_owner_as_approver_suggestion) { true }
let(:author) { merge_request.author }
let(:owner_a) { build(:user) }
let(:owner_b) { build(:user) }
let(:committer_a) { create(:user) }
let(:committer_b) { create(:user) }
let(:code_owner_loader) { double(:loader) }
subject { described_class.new(merge_request) }
before do
diffs = double(:diffs)
allow(merge_request).to receive(:diffs).and_return(diffs)
allow(diffs).to receive(:diff_files).and_return(files)
allow(merge_request).to receive(:approvals_required).and_return(approvals_required)
stub_licensed_features(code_owner_as_approver_suggestion: enable_code_owner_as_approver_suggestion)
end
def expect_code_owner_loader_init
expect(Gitlab::CodeOwners::Loader).to receive(:new).with(
merge_request.target_project,
merge_request.target_branch,
%w(coo foo bar baz)
).and_return(code_owner_loader)
end
def expect_code_owners_call(*stub_return_users)
expect_code_owner_loader_init
expect(code_owner_loader).to receive(:members).and_return(stub_return_users)
expect(code_owner_loader).to receive(:non_members).and_return([])
end
def expect_git_log_call(*stub_return_users)
analyzer = double(:analyzer)
expect(Gitlab::AuthorityAnalyzer).to receive(:new).with(
merge_request,
merge_request.author
).and_return(analyzer)
expect(analyzer).to receive(:calculate).and_return(stub_return_users)
end
describe '#render' do
context 'when code owner exists' do
it 'renders code owners' do
expect_code_owners_call(owner_a, owner_b)
expect(subject).to receive(:render_user).with(owner_a).and_call_original
expect(subject).to receive(:render_user).with(owner_b).and_call_original
subject.render
end
end
context 'git log lookup' do
context 'when authors are approvers' do
before do
project.add_developer(committer_a)
project.add_developer(committer_b)
end
context 'when the only code owner is skip_user' do
it 'displays git log authors instead' do
expect_code_owners_call(merge_request.author)
expect_git_log_call(committer_a)
expect(subject).to receive(:render_user).with(committer_a).and_call_original
subject.render
end
end
context 'when code owners do not exist' do
it 'displays git log authors' do
expect_code_owners_call
expect_git_log_call(committer_a)
expect(subject).to receive(:render_user).with(committer_a).and_call_original
subject.render
end
end
context 'approvals_required is low' do
let(:approvals_required) { 1 }
it 'returns top n approvers' do
expect_code_owners_call
expect_git_log_call(committer_a, committer_b)
expect(subject).to receive(:render_user).with(committer_a).and_call_original
expect(subject).not_to receive(:render_user).with(committer_b)
subject.render
end
end
end
context 'code_owner_as_approver_suggestion disabled' do
let(:enable_code_owner_as_approver_suggestion) { false }
before do
project.add_developer(committer_a)
end
it 'displays git log authors' do
expect(Gitlab::CodeOwners::Loader).not_to receive(:new)
expect_git_log_call(committer_a)
expect(subject).to receive(:render_user).with(committer_a).and_call_original
subject.render
end
end
end
end
describe '#any?' do
it 'returns true if any user exists' do
expect_code_owners_call(owner_a)
expect(subject.any?).to eq(true)
end
it 'returns false if no user exists' do
expect_code_owners_call
expect_git_log_call
expect(subject.any?).to eq(false)
end
it 'caches loaded users' do
expect(subject).to receive(:load_users).once.and_call_original
subject.any?
subject.any?
end
end
describe '#render_user' do
it 'renders plaintext if user is not an eligible approver' do
expect_code_owner_loader_init
expect(code_owner_loader).to receive(:members).and_return([])
expect(code_owner_loader).to receive(:non_members).and_return([owner_a])
result = subject.render_user(owner_a)
expect(result).to start_with('<span')
expect(result).to include('has-tooltip')
end
context 'user is an eligible approver' do
it 'renders link' do
expect_code_owners_call(committer_a)
result = subject.render_user(committer_a)
expect(result).to start_with('<a')
end
end
end
describe '#show_code_owner_tips?' do
context 'when code_owner feature enabled and code owner is empty' do
before do
expect_code_owner_loader_init
allow(code_owner_loader).to receive(:empty_code_owners?).and_return(true)
end
it 'returns true' do
expect(subject.show_code_owner_tips?).to eq(true)
end
end
context 'when code_owner feature enabled and code owner is not empty' do
before do
expect_code_owner_loader_init
allow(code_owner_loader).to receive(:empty_code_owners?).and_return(false)
end
it 'returns false' do
expect(subject.show_code_owner_tips?).to eq(false)
end
end
context 'when code_owner feature is disabled' do
let(:enable_code_owner_as_approver_suggestion) { false }
it 'returns false' do
expect(subject.show_code_owner_tips?).to eq(false)
end
end
end
end
......@@ -5,6 +5,7 @@ describe 'shared/issuable/_approvals.html.haml' do
let(:project) { build(:project) }
let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
let(:presenter) { merge_request.present(current_user: user) }
let(:approver_presenter) { double(any?: false, show_code_owner_tips?: true) }
let(:form) { double('form') }
before do
......@@ -13,8 +14,8 @@ describe 'shared/issuable/_approvals.html.haml' do
allow(form).to receive(:label)
allow(form).to receive(:number_field)
allow(merge_request).to receive(:requires_approve?).and_return(true)
allow(MergeRequestApproverPresenter).to receive(:new).and_return(approver_presenter)
assign(:project, project)
assign(:suggested_approvers, [])
end
context 'has no approvers' do
......
......@@ -30,10 +30,6 @@ shared_examples 'update invalid issuable' do |klass|
expect(response).to render_template(:edit)
expect(assigns[:conflict]).to be_truthy
if klass == MergeRequest && issuable.requires_approve?
expect(assigns[:suggested_approvers]).to be_an(Array)
end
end
it 'renders json error message when format is json' do
......
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