Commit 7335c312 authored by Mayra Cabrera's avatar Mayra Cabrera

Merge branch 'ajk-add-user-mr-interaction' into 'master'

Add user-merge-request interaction type [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!54588
parents ab8cdbe8 774db6a8
......@@ -27,6 +27,15 @@ module Types
end
end
# Helper to define an enum member for each element of a Rails AR enum
def from_rails_enum(enum, description:)
enum.each_key do |name|
value name.to_s.upcase,
value: name,
description: format(description, name: name)
end
end
def value(*args, **kwargs, &block)
enum[args[0].downcase] = kwargs[:value] || args[0]
gitlab_deprecation(kwargs)
......
# frozen_string_literal: true
module FindClosest
# Find the closest node of a given type above this node, and return the domain object
def closest_parent(type, parent)
parent = parent.try(:parent) while parent && parent.object.class != type
return unless parent
parent.object.object
end
end
......@@ -23,7 +23,7 @@ module Types
A global identifier.
A global identifier represents an object uniquely across the application.
An example of such an identifier is "gid://gitlab/User/1".
An example of such an identifier is `"gid://gitlab/User/1"`.
Global identifiers are encoded as strings.
DESC
......
# frozen_string_literal: true
module Types
class MergeRequestReviewStateEnum < BaseEnum
graphql_name 'MergeRequestReviewState'
description 'State of a review of a GitLab merge request.'
from_rails_enum(::MergeRequestReviewer.states,
description: "The merge request is %{name}.")
end
end
......@@ -132,7 +132,10 @@ module Types
description: 'The milestone of the merge request.'
field :assignees, Types::UserType.connection_type, null: true, complexity: 5,
description: 'Assignees of the merge request.'
field :reviewers, Types::UserType.connection_type, null: true, complexity: 5,
field :reviewers,
type: Types::MergeRequests::ReviewerType.connection_type,
null: true,
complexity: 5,
description: 'Users from whom a review has been requested.'
field :author, Types::UserType, null: true,
description: 'User who created this merge request.'
......
# frozen_string_literal: true
module Types
module MergeRequests
class ReviewerType < ::Types::UserType
include FindClosest
graphql_name 'MergeRequestReviewer'
description 'A user from whom a merge request review has been requested.'
authorize :read_user
field :merge_request_interaction,
type: ::Types::UserMergeRequestInteractionType,
null: true,
extras: [:parent],
description: "Details of this user's interactions with the merge request."
def merge_request_interaction(parent:)
merge_request = closest_parent(::Types::MergeRequestType, parent)
return unless merge_request
Users::MergeRequestInteraction.new(user: object, merge_request: merge_request)
end
end
end
end
# frozen_string_literal: true
module Types
class UserMergeRequestInteractionType < BaseObject
graphql_name 'UserMergeRequestInteraction'
description <<~MD
Information about a merge request given a specific user.
This object has two parts to its state: a `User` and a `MergeRequest`. All
fields relate to interactions between the two entities.
MD
authorize :read_merge_request
field :can_merge,
type: ::GraphQL::BOOLEAN_TYPE,
null: false,
calls_gitaly: true,
method: :can_merge?,
description: 'Whether this user can merge this merge request.'
field :can_update,
type: ::GraphQL::BOOLEAN_TYPE,
null: false,
method: :can_update?,
description: 'Whether this user can update this merge request.'
field :review_state,
::Types::MergeRequestReviewStateEnum,
null: true,
description: 'The state of the review by this user.'
field :reviewed,
type: ::GraphQL::BOOLEAN_TYPE,
null: false,
method: :reviewed?,
description: 'Whether this user has provided a review for this merge request.'
field :approved,
type: ::GraphQL::BOOLEAN_TYPE,
null: false,
method: :approved?,
description: 'Whether this user has approved this merge request.'
end
end
::Types::UserMergeRequestInteractionType.prepend_if_ee('EE::Types::UserMergeRequestInteractionType')
# frozen_string_literal: true
module Users
class MergeRequestInteraction
def initialize(user:, merge_request:)
@user = user
@merge_request = merge_request
end
def declarative_policy_subject
merge_request
end
def can_merge?
merge_request.can_be_merged_by?(user)
end
def can_update?
user.can?(:update_merge_request, merge_request)
end
def review_state
reviewer&.state
end
def reviewed?
reviewer&.reviewed? == true
end
def approved?
merge_request.approvals.any? { |app| app.user_id == user.id }
end
private
def reviewer
@reviewer ||= merge_request.merge_request_reviewers.find { |r| r.user_id == user.id }
end
attr_reader :user, :merge_request
end
end
::Users::MergeRequestInteraction.prepend_if_ee('EE::Users::MergeRequestInteraction')
......@@ -13,7 +13,11 @@ class UserPresenter < Gitlab::View::Presenter::Delegated
private
def can?(*args)
user.can?(*args)
end
def should_be_private?
!can?(current_user, :read_user_profile, user)
!Ability.allowed?(current_user, :read_user_profile, user)
end
end
---
title: Add user-merge request interaction type
merge_request: 54588
author:
type: added
......@@ -692,6 +692,16 @@ An API Fuzzing scan profile.
| `name` | [`String`](#string) | The unique name of the profile. |
| `yaml` | [`String`](#string) | A syntax highlit HTML representation of the YAML. |
### `ApprovalRule`
Describes a rule for who can approve merge requests.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | [`GlobalID!`](#globalid) | ID of the rule. |
| `name` | [`String`](#string) | Name of the rule. |
| `type` | [`ApprovalRuleType`](#approvalruletype) | Type of the rule. |
### `AwardEmoji`
An emoji awarded by a user.
......@@ -3916,7 +3926,7 @@ An edge in a connection.
| `rebaseCommitSha` | [`String`](#string) | Rebase commit SHA of the merge request. |
| `rebaseInProgress` | [`Boolean!`](#boolean) | Indicates if there is a rebase currently in progress for the merge request. |
| `reference` | [`String!`](#string) | Internal reference of the merge request. Returned in shortened format by default. |
| `reviewers` | [`UserConnection`](#userconnection) | Users from whom a review has been requested. |
| `reviewers` | [`MergeRequestReviewerConnection`](#mergerequestreviewerconnection) | Users from whom a review has been requested. |
| `securityAutoFix` | [`Boolean`](#boolean) | Indicates if the merge request is created by @GitLab-Security-Bot. |
| `securityReportsUpToDateOnTargetBranch` | [`Boolean!`](#boolean) | Indicates if the target branch security reports are out of date. |
| `shouldBeRebased` | [`Boolean!`](#boolean) | Indicates if the merge request will be rebased. |
......@@ -4038,6 +4048,56 @@ Check permissions for the current user on a merge request.
| `revertOnCurrentMergeRequest` | [`Boolean!`](#boolean) | Indicates the user can perform `revert_on_current_merge_request` on this resource. |
| `updateMergeRequest` | [`Boolean!`](#boolean) | Indicates the user can perform `update_merge_request` on this resource. |
### `MergeRequestReviewer`
A user from whom a merge request review has been requested.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `assignedMergeRequests` | [`MergeRequestConnection`](#mergerequestconnection) | Merge requests assigned to the user. |
| `authoredMergeRequests` | [`MergeRequestConnection`](#mergerequestconnection) | Merge requests authored by the user. |
| `avatarUrl` | [`String`](#string) | URL of the user's avatar. |
| `bot` | [`Boolean!`](#boolean) | Indicates if the user is a bot. |
| `callouts` | [`UserCalloutConnection`](#usercalloutconnection) | User callouts that belong to the user. |
| `email` **{warning-solid}** | [`String`](#string) | **Deprecated** in 13.7. This was renamed. Use: `User.publicEmail`. |
| `groupCount` | [`Int`](#int) | Group count for the user. Available only when feature flag `user_group_counts` is enabled. |
| `groupMemberships` | [`GroupMemberConnection`](#groupmemberconnection) | Group memberships of the user. |
| `id` | [`ID!`](#id) | ID of the user. |
| `location` | [`String`](#string) | The location of the user. |
| `mergeRequestInteraction` | [`UserMergeRequestInteraction`](#usermergerequestinteraction) | Details of this user's interactions with the merge request. |
| `name` | [`String!`](#string) | Human-readable name of the user. |
| `projectMemberships` | [`ProjectMemberConnection`](#projectmemberconnection) | Project memberships of the user. |
| `publicEmail` | [`String`](#string) | User's public email. |
| `reviewRequestedMergeRequests` | [`MergeRequestConnection`](#mergerequestconnection) | Merge requests assigned to the user for review. |
| `snippets` | [`SnippetConnection`](#snippetconnection) | Snippets authored by the user. |
| `starredProjects` | [`ProjectConnection`](#projectconnection) | Projects starred by the user. |
| `state` | [`UserState!`](#userstate) | State of the user. |
| `status` | [`UserStatus`](#userstatus) | User status. |
| `todos` | [`TodoConnection`](#todoconnection) | To-do items of the user. |
| `userPermissions` | [`UserPermissions!`](#userpermissions) | Permissions for the current user on the resource. |
| `username` | [`String!`](#string) | Username of the user. Unique within this instance of GitLab. |
| `webPath` | [`String!`](#string) | Web path of the user. |
| `webUrl` | [`String!`](#string) | Web URL of the user. |
### `MergeRequestReviewerConnection`
The connection type for MergeRequestReviewer.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `edges` | [`[MergeRequestReviewerEdge]`](#mergerequestrevieweredge) | A list of edges. |
| `nodes` | [`[MergeRequestReviewer]`](#mergerequestreviewer) | A list of nodes. |
| `pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
### `MergeRequestReviewerEdge`
An edge in a connection.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `cursor` | [`String!`](#string) | A cursor for use in pagination. |
| `node` | [`MergeRequestReviewer`](#mergerequestreviewer) | The item at the end of the edge. |
### `MergeRequestReviewerRereviewPayload`
Autogenerated return type of MergeRequestReviewerRereview.
......@@ -6623,6 +6683,22 @@ An edge in a connection.
| `cursor` | [`String!`](#string) | A cursor for use in pagination. |
| `node` | [`User`](#user) | The item at the end of the edge. |
### `UserMergeRequestInteraction`
Information about a merge request given a specific user.
This object has two parts to its state: a `User` and a `MergeRequest`. All
fields relate to interactions between the two entities.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `applicableApprovalRules` | [`[ApprovalRule!]`](#approvalrule) | Approval rules that apply to this user for this merge request. |
| `approved` | [`Boolean!`](#boolean) | Whether this user has approved this merge request. |
| `canMerge` | [`Boolean!`](#boolean) | Whether this user can merge this merge request. |
| `canUpdate` | [`Boolean!`](#boolean) | Whether this user can update this merge request. |
| `reviewState` | [`MergeRequestReviewState`](#mergerequestreviewstate) | The state of the review by this user. |
| `reviewed` | [`Boolean!`](#boolean) | Whether this user has provided a review for this merge request. |
### `UserPermissions`
| Field | Type | Description |
......@@ -7320,6 +7396,17 @@ All possible ways to specify the API surface for an API fuzzing scan.
| `OPENAPI` | The API surface is specified by a OPENAPI file. |
| `POSTMAN` | The API surface is specified by a POSTMAN file. |
### `ApprovalRuleType`
The kind of an approval rule.
| Value | Description |
| ----- | ----------- |
| `ANY_APPROVER` | A `any_approver` approval rule. |
| `CODE_OWNER` | A `code_owner` approval rule. |
| `REGULAR` | A `regular` approval rule. |
| `REPORT_APPROVER` | A `report_approver` approval rule. |
### `AvailabilityEnum`
User availability status.
......@@ -7802,6 +7889,15 @@ New state to apply to a merge request.
| `CLOSED` | Close the merge request if it is open. |
| `OPEN` | Open the merge request if it is closed. |
### `MergeRequestReviewState`
State of a review of a GitLab merge request.
| Value | Description |
| ----- | ----------- |
| `REVIEWED` | The merge request is reviewed. |
| `UNREVIEWED` | The merge request is unreviewed. |
### `MergeRequestSort`
Values for sorting merge requests.
......@@ -8513,6 +8609,15 @@ A `GitlabErrorTrackingDetailedErrorID` is a global ID. It is encoded as a string
An example `GitlabErrorTrackingDetailedErrorID` is: `"gid://gitlab/Gitlab::ErrorTracking::DetailedError/1"`.
### `GlobalID`
A global identifier.
A global identifier represents an object uniquely across the application.
An example of such an identifier is `"gid://gitlab/User/1"`.
Global identifiers are encoded as strings.
### `GroupID`
A `GroupID` is a global ID. It is encoded as a string.
......
# frozen_string_literal: true
module EE
module Types
module UserMergeRequestInteractionType
extend ActiveSupport::Concern
prepended do
field :applicable_approval_rules,
[::Types::ApprovalRuleType],
null: true,
description: 'Approval rules that apply to this user for this merge request.'
end
end
end
end
# frozen_string_literal: true
module Types
class ApprovalRuleType < BaseObject
graphql_name 'ApprovalRule'
description 'Describes a rule for who can approve merge requests.'
authorize :read_approval_rule
field :id,
type: ::Types::GlobalIDType,
null: false,
description: 'ID of the rule.'
field :name,
type: GraphQL::STRING_TYPE,
null: true,
description: 'Name of the rule.'
field :type,
type: ::Types::ApprovalRuleTypeEnum,
null: true,
method: :rule_type,
description: 'Type of the rule.'
end
end
# frozen_string_literal: true
module Types
class ApprovalRuleTypeEnum < BaseEnum
# See: ApprovalMergeRequestRule, and ApprovalProjectRule
graphql_name 'ApprovalRuleType'
description 'The kind of an approval rule.'
from_rails_enum(
ApprovalProjectRule.rule_types.merge(ApprovalMergeRequestRule.rule_types),
description: 'A `%{name}` approval rule.'
)
end
end
......@@ -24,7 +24,7 @@ class ApprovalWrappedRule
def_delegators(:@approval_rule,
:regular?, :any_approver?, :code_owner?, :report_approver?,
:overridden?, :id, :name, :users, :groups, :code_owner,
:source_rule, :rule_type, :approvals_required, :section)
:source_rule, :rule_type, :approvals_required, :section, :to_global_id)
def self.wrap(merge_request, rule)
if rule.any_approver?
......@@ -51,6 +51,10 @@ class ApprovalWrappedRule
end
end
def declarative_policy_delegate
@approval_rule
end
# @return [Array<User>] of users who have approved the merge request
#
# This is dynamically calculated unless it is persisted as `approved_approvers`.
......
# frozen_string_literal: true
module EE
module Users
module MergeRequestInteraction
def applicable_approval_rules
return [] unless merge_request.project.licensed_feature_available?(:merge_request_approvers)
merge_request.applicable_approval_rules_for_user(user.id)
end
end
end
end
......@@ -8,4 +8,6 @@ class ApprovalMergeRequestRulePolicy < BasePolicy
end
rule { editable }.enable :edit_approval_rule
rule { can?(:read_merge_request) }.enable :read_approval_rule
end
......@@ -8,4 +8,6 @@ class ApprovalProjectRulePolicy < BasePolicy
end
rule { editable }.enable :edit_approval_rule
rule { can?(:read_project) }.enable :read_approval_rule
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['UserMergeRequestInteraction'] do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project, :public, :repository) }
let_it_be(:merge_request) { create(:merge_request, source_project: project) }
let(:interaction) { ::Users::MergeRequestInteraction.new(user: user, merge_request: merge_request.reset) }
it 'has the expected fields' do
expected_fields = %w[
applicable_approval_rules
]
expect(described_class).to have_graphql_fields(*expected_fields).at_least
end
def resolve(field_name)
resolve_field(field_name, interaction, current_user: current_user)
end
describe '#applicable_approval_rules' do
subject { resolve(:applicable_approval_rules) }
before do
merge_request.clear_memoization(:approval_state)
end
context 'when there are no approval rules' do
it { is_expected.to be_empty }
end
context 'when there are approval rules' do
before do
create(:approval_merge_request_rule, merge_request: merge_request)
create(:code_owner_rule, merge_request: merge_request)
create(:any_approver_rule, merge_request: merge_request)
end
context 'when the feature is not available' do
it { is_expected.to be_empty }
end
context 'when the feature is available' do
before do
stub_licensed_features(merge_request_approvers: true)
end
it { is_expected.to be_empty }
context 'when the user is associated with a rule' do
let(:rule) { create(:code_owner_rule, merge_request: merge_request) }
before do
rule.users << user
end
it { is_expected.to contain_exactly(have_attributes(approval_rule: rule)) }
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['ApprovalRuleType'] do
it 'has the correct members' do
expect(described_class.values).to match(
'REGULAR' => have_attributes(
description: 'A `regular` approval rule.',
value: 'regular'
),
'CODE_OWNER' => have_attributes(
description: 'A `code_owner` approval rule.',
value: 'code_owner'
),
'REPORT_APPROVER' => have_attributes(
description: 'A `report_approver` approval rule.',
value: 'report_approver'
),
'ANY_APPROVER' => have_attributes(
description: 'A `any_approver` approval rule.',
value: 'any_approver'
)
)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['ApprovalRule'] do
let(:fields) { %i[id name type] }
it { expect(described_class).to have_graphql_fields(fields) }
it { expect(described_class).to require_graphql_authorizations(:read_approval_rule) }
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::Users::MergeRequestInteraction do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public, :repository) }
let_it_be(:merge_request) { create(:merge_request, source_project: project) }
subject { described_class.new(user: user, merge_request: merge_request) }
describe '#applicable_approval_rules' do
before do
merge_request.reset
merge_request.clear_memoization(:approval_state)
end
context 'when there are no approval rules' do
it { is_expected.to have_attributes(applicable_approval_rules: be_empty) }
end
context 'when there are approval rules' do
before do
create(:approval_merge_request_rule, merge_request: merge_request)
create(:code_owner_rule, merge_request: merge_request)
create(:any_approver_rule, merge_request: merge_request)
end
context 'when the feature is not available' do
it { is_expected.to have_attributes(applicable_approval_rules: be_empty) }
end
context 'when the feature is available' do
before do
stub_licensed_features(merge_request_approvers: true)
end
it { is_expected.to have_attributes(applicable_approval_rules: be_empty) }
context 'when the user is associated with a rule' do
let(:rule) { create(:code_owner_rule, merge_request: merge_request) }
before do
create(:code_owner_rule, merge_request: merge_request) # irrelevant rule
rule.users << user
end
specify do
is_expected.to have_attributes(
applicable_approval_rules: contain_exactly(
have_attributes(approval_rule: rule)
)
)
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'MergeRequestReviewer' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
context 'when requesting information about MR interactions' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public, :repository) }
let_it_be(:merge_request) { create(:merge_request, source_project: project) }
let(:query) do
graphql_query_for(
:project,
{ full_path: project.full_path },
query_graphql_field(
:merge_request,
{ iid: merge_request.iid.to_s },
query_nodes(
:reviewers,
"mergeRequestInteraction { #{all_graphql_fields_for('UserMergeRequestInteraction')} }"
)
)
)
end
let(:interaction) do
graphql_data_at(:project,
:merge_request,
:reviewers,
:nodes, 0,
:merge_request_interaction)
end
before do
merge_request.reviewers << user
end
context 'when the user does not have any applicable rules' do
it 'returns null data' do
post_graphql(query)
expect(interaction).to include(
'applicableApprovalRules' => []
)
end
end
context 'when the user has interacted' do
let(:rule) { create(:code_owner_rule, merge_request: merge_request) }
before do
stub_licensed_features(merge_request_approvers: true)
rule.users << user
end
it 'returns appropriate data' do
the_rule = eq(
'id' => global_id_of(rule),
'name' => rule.name,
'type' => 'CODE_OWNER'
)
post_graphql(query)
expect(interaction['applicableApprovalRules']).to contain_exactly(the_rule)
end
end
end
end
......@@ -21,8 +21,9 @@ module Gitlab
def ok?(object, current_user)
return true if none?
subject = object.try(:declarative_policy_subject) || object
abilities.all? do |ability|
Ability.allowed?(current_user, ability, object)
Ability.allowed?(current_user, ability, subject)
end
end
end
......
......@@ -3,6 +3,38 @@
require 'spec_helper'
RSpec.describe Types::BaseEnum do
describe '.from_rails_enum' do
let(:enum_type) { Class.new(described_class) }
let(:template) { "The name is '%{name}', James %{name}." }
let(:enum) do
{
'foo' => 1,
'bar' => 2,
'baz' => 100
}
end
it 'contructs the correct values' do
enum_type.from_rails_enum(enum, description: template)
expect(enum_type.values).to match(
'FOO' => have_attributes(
description: "The name is 'foo', James foo.",
value: 'foo'
),
'BAR' => have_attributes(
description: "The name is 'bar', James bar.",
value: 'bar'
),
'BAZ' => have_attributes(
description: "The name is 'baz', James baz.",
value: 'baz'
)
)
end
end
describe '.declarative_enum' do
let(:use_name) { true }
let(:use_description) { true }
......@@ -26,12 +58,15 @@ RSpec.describe Types::BaseEnum do
end
end
subject(:set_declarative_enum) { enum_type.declarative_enum(enum_module, use_name: use_name, use_description: use_description) }
subject(:set_declarative_enum) do
enum_type.declarative_enum(enum_module, use_name: use_name, use_description: use_description)
end
describe '#graphql_name' do
context 'when the use_name is `true`' do
it 'changes the graphql_name' do
expect { set_declarative_enum }.to change { enum_type.graphql_name }.from('OriginalName').to('Name')
expect { set_declarative_enum }
.to change(enum_type, :graphql_name).from('OriginalName').to('Name')
end
end
......@@ -39,7 +74,8 @@ RSpec.describe Types::BaseEnum do
let(:use_name) { false }
it 'does not change the graphql_name' do
expect { set_declarative_enum }.not_to change { enum_type.graphql_name }.from('OriginalName')
expect { set_declarative_enum }
.not_to change(enum_type, :graphql_name).from('OriginalName')
end
end
end
......@@ -47,7 +83,8 @@ RSpec.describe Types::BaseEnum do
describe '#description' do
context 'when the use_description is `true`' do
it 'changes the description' do
expect { set_declarative_enum }.to change { enum_type.description }.from('Original description').to('Description')
expect { set_declarative_enum }
.to change(enum_type, :description).from('Original description').to('Description')
end
end
......@@ -55,7 +92,8 @@ RSpec.describe Types::BaseEnum do
let(:use_description) { false }
it 'does not change the description' do
expect { set_declarative_enum }.not_to change { enum_type.description }.from('Original description')
expect { set_declarative_enum }
.not_to change(enum_type, :description).from('Original description')
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['MergeRequestReviewState'] do
it 'the correct enum members' do
expect(described_class.values).to match(
'REVIEWED' => have_attributes(
description: 'The merge request is reviewed.',
value: 'reviewed'
),
'UNREVIEWED' => have_attributes(
description: 'The merge request is unreviewed.',
value: 'unreviewed'
)
)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['MergeRequestReviewer'] do
specify { expect(described_class).to require_graphql_authorizations(:read_user) }
it 'has the expected fields' do
expected_fields = %w[
id
bot
user_permissions
snippets
name
username
email
publicEmail
avatarUrl
webUrl
webPath
todos
state
status
location
authoredMergeRequests
assignedMergeRequests
reviewRequestedMergeRequests
groupMemberships
groupCount
projectMemberships
starredProjects
callouts
merge_request_interaction
]
expect(described_class).to have_graphql_fields(*expected_fields)
end
describe '#merge_request_interaction' do
subject { described_class.fields['mergeRequestInteraction'] }
it 'returns the correct type' do
is_expected.to have_graphql_type(Types::UserMergeRequestInteractionType)
end
it 'has the correct arguments' do
is_expected.to have_attributes(arguments: be_empty)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['UserMergeRequestInteraction'] do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project, :public, :repository) }
let_it_be(:merge_request) { create(:merge_request, source_project: project) }
let(:interaction) { ::Users::MergeRequestInteraction.new(user: user, merge_request: merge_request.reset) }
specify { expect(described_class).to require_graphql_authorizations(:read_merge_request) }
it 'has the expected fields' do
expected_fields = %w[
can_merge
can_update
review_state
reviewed
approved
]
expect(described_class).to have_graphql_fields(*expected_fields).at_least
end
def resolve(field_name)
resolve_field(field_name, interaction, current_user: current_user)
end
describe '#can_merge' do
subject { resolve(:can_merge) }
context 'when the user cannot merge' do
it { is_expected.to be false }
end
context 'when the user can merge' do
before do
project.add_maintainer(user)
end
it { is_expected.to be true }
end
end
describe '#can_update' do
subject { resolve(:can_update) }
context 'when the user cannot update the MR' do
it { is_expected.to be false }
end
context 'when the user can update the MR' do
before do
project.add_developer(user)
end
it { is_expected.to be true }
end
end
describe '#review_state' do
subject { resolve(:review_state) }
context 'when the user has not been asked to review the MR' do
it { is_expected.to be_nil }
it 'implies not reviewed' do
expect(resolve(:reviewed)).to be false
end
end
context 'when the user has been asked to review the MR' do
before do
merge_request.reviewers << user
end
it { is_expected.to eq(Types::MergeRequestReviewStateEnum.values['UNREVIEWED'].value) }
it 'implies not reviewed' do
expect(resolve(:reviewed)).to be false
end
end
context 'when the user has provided a review' do
before do
merge_request.merge_request_reviewers.create!(reviewer: user, state: MergeRequestReviewer.states['reviewed'])
end
it { is_expected.to eq(Types::MergeRequestReviewStateEnum.values['REVIEWED'].value) }
it 'implies reviewed' do
expect(resolve(:reviewed)).to be true
end
end
end
describe '#approved' do
subject { resolve(:approved) }
context 'when the user has not approved the MR' do
it { is_expected.to be false }
end
context 'when the user has approved the MR' do
before do
merge_request.approved_by_users << user
end
it { is_expected.to be true }
end
end
end
# frozen_string_literal: true
require 'fast_spec_helper'
RSpec.describe ::Gitlab::Graphql::Authorize::ObjectAuthorization do
describe '#ok?' do
subject { described_class.new(%i[go_fast go_slow]) }
let(:user) { double(:User, id: 10001) }
let(:policy) do
Class.new(::DeclarativePolicy::Base) do
condition(:fast, scope: :subject) { @subject.x >= 10 }
condition(:slow, scope: :subject) { @subject.y >= 10 }
rule { fast }.policy do
enable :go_fast
end
rule { slow }.policy do
enable :go_slow
end
end
end
before do
stub_const('Foo', Struct.new(:x, :y))
stub_const('FooPolicy', policy)
end
context 'when there are no abilities' do
subject { described_class.new([]) }
it { is_expected.to be_ok(double, double) }
end
context 'when no ability should be allowed' do
let(:object) { Foo.new(0, 0) }
it { is_expected.not_to be_ok(object, user) }
end
context 'when go_fast should be allowed' do
let(:object) { Foo.new(100, 0) }
it { is_expected.not_to be_ok(object, user) }
end
context 'when go_fast and go_slow should be allowed' do
let(:object) { Foo.new(100, 100) }
it { is_expected.to be_ok(object, user) }
end
context 'when the object delegates to another subject' do
def proxy(foo)
double(:Proxy, declarative_policy_subject: foo)
end
it { is_expected.to be_ok(proxy(Foo.new(100, 100)), user) }
it { is_expected.not_to be_ok(proxy(Foo.new(0, 100)), user) }
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::Users::MergeRequestInteraction do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public, :repository) }
let_it_be(:merge_request) { create(:merge_request, source_project: project) }
subject(:interaction) do
::Users::MergeRequestInteraction.new(user: user, merge_request: merge_request.reset)
end
describe 'declarative policy delegation' do
it 'delegates to the merge request' do
expect(subject.declarative_policy_subject).to eq(merge_request)
end
end
describe '#can_merge?' do
context 'when the user cannot merge' do
it { is_expected.not_to be_can_merge }
end
context 'when the user can merge' do
before do
project.add_maintainer(user)
end
it { is_expected.to be_can_merge }
end
end
describe '#can_update?' do
context 'when the user cannot update the MR' do
it { is_expected.not_to be_can_update }
end
context 'when the user can update the MR' do
before do
project.add_developer(user)
end
it { is_expected.to be_can_update }
end
end
describe '#review_state' do
subject { interaction.review_state }
context 'when the user has not been asked to review the MR' do
it { is_expected.to be_nil }
it 'implies not reviewed' do
expect(interaction).not_to be_reviewed
end
end
context 'when the user has been asked to review the MR' do
before do
merge_request.reviewers << user
end
it { is_expected.to eq(Types::MergeRequestReviewStateEnum.values['UNREVIEWED'].value) }
it 'implies not reviewed' do
expect(interaction).not_to be_reviewed
end
end
context 'when the user has provided a review' do
before do
merge_request.merge_request_reviewers.create!(reviewer: user, state: MergeRequestReviewer.states['reviewed'])
end
it { is_expected.to eq(Types::MergeRequestReviewStateEnum.values['REVIEWED'].value) }
it 'implies reviewed' do
expect(interaction).to be_reviewed
end
end
end
describe '#approved?' do
context 'when the user has not approved the MR' do
it { is_expected.not_to be_approved }
end
context 'when the user has approved the MR' do
before do
merge_request.approved_by_users << user
end
it { is_expected.to be_approved }
end
end
end
......@@ -5,21 +5,25 @@ require 'spec_helper'
RSpec.describe 'getting merge request information nested in a project' do
include GraphqlHelpers
let(:project) { create(:project, :repository, :public) }
let(:current_user) { create(:user) }
let(:merge_request_graphql_data) { graphql_data['project']['mergeRequest'] }
let!(:merge_request) { create(:merge_request, source_project: project) }
let(:mr_fields) { all_graphql_fields_for('MergeRequest', excluded: %w(pipeline jobs)) }
let_it_be(:project) { create(:project, :repository, :public) }
let_it_be(:current_user) { create(:user) }
let_it_be_with_reload(:merge_request) { create(:merge_request, source_project: project) }
let(:merge_request_graphql_data) { graphql_data_at(:project, :merge_request) }
let(:mr_fields) { all_graphql_fields_for('MergeRequest', max_depth: 1) }
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('mergeRequest', { iid: merge_request.iid.to_s }, mr_fields)
:project,
{ full_path: project.full_path },
query_graphql_field(:merge_request, { iid: merge_request.iid.to_s }, mr_fields)
)
end
it_behaves_like 'a working graphql query' do
# we exclude Project.pipeline because it needs arguments
let(:mr_fields) { all_graphql_fields_for('MergeRequest', excluded: %w[jobs pipeline]) }
before do
post_graphql(query, current_user: current_user)
end
......@@ -38,13 +42,17 @@ RSpec.describe 'getting merge request information nested in a project' do
expect(merge_request_graphql_data['webUrl']).to be_present
end
it 'includes author' do
post_graphql(query, current_user: current_user)
context 'when selecting author' do
let(:mr_fields) { 'author { username }' }
expect(merge_request_graphql_data['author']['username']).to eq(merge_request.author.username)
it 'includes author' do
post_graphql(query, current_user: current_user)
expect(merge_request_graphql_data['author']['username']).to eq(merge_request.author.username)
end
end
context 'the merge_request has reviewers' do
context 'when the merge_request has reviewers' do
let(:mr_fields) do
<<~SELECT
reviewers { nodes { id username } }
......@@ -68,63 +76,76 @@ RSpec.describe 'getting merge request information nested in a project' do
end
end
it 'includes diff stats' do
be_natural = an_instance_of(Integer).and(be >= 0)
post_graphql(query, current_user: current_user)
sums = merge_request_graphql_data['diffStats'].reduce([0, 0, 0]) do |(a, d, c), node|
a_, d_ = node.values_at('additions', 'deletions')
[a + a_, d + d_, c + a_ + d_]
describe 'diffStats' do
let(:mr_fields) do
<<~FIELDS
diffStats { #{all_graphql_fields_for('DiffStats')} }
diffStatsSummary { #{all_graphql_fields_for('DiffStatsSummary')} }
FIELDS
end
expect(merge_request_graphql_data).to include(
'diffStats' => all(a_hash_including('path' => String, 'additions' => be_natural, 'deletions' => be_natural)),
'diffStatsSummary' => a_hash_including(
'fileCount' => merge_request.diff_stats.count,
'additions' => be_natural,
'deletions' => be_natural,
'changes' => be_natural
)
)
it 'includes diff stats' do
be_natural = an_instance_of(Integer).and(be >= 0)
# diff_stats is consistent with summary
expect(merge_request_graphql_data['diffStatsSummary']
.values_at('additions', 'deletions', 'changes')).to eq(sums)
# diff_stats_summary is internally consistent
expect(merge_request_graphql_data['diffStatsSummary']
.values_at('additions', 'deletions').sum)
.to eq(merge_request_graphql_data.dig('diffStatsSummary', 'changes'))
.and be_positive
end
post_graphql(query, current_user: current_user)
context 'requesting a specific diff stat' do
let(:diff_stat) { merge_request.diff_stats.first }
sums = merge_request_graphql_data['diffStats'].reduce([0, 0, 0]) do |(a, d, c), node|
a_, d_ = node.values_at('additions', 'deletions')
[a + a_, d + d_, c + a_ + d_]
end
let(:query) do
graphql_query_for(:project, { full_path: project.full_path },
query_graphql_field(:merge_request, { iid: merge_request.iid.to_s }, [
query_graphql_field(:diff_stats, { path: diff_stat.path }, all_graphql_fields_for('DiffStats'))
])
expect(merge_request_graphql_data).to include(
'diffStats' => all(a_hash_including('path' => String, 'additions' => be_natural, 'deletions' => be_natural)),
'diffStatsSummary' => a_hash_including(
'fileCount' => merge_request.diff_stats.count,
'additions' => be_natural,
'deletions' => be_natural,
'changes' => be_natural
)
)
# diff_stats is consistent with summary
expect(merge_request_graphql_data['diffStatsSummary']
.values_at('additions', 'deletions', 'changes')).to eq(sums)
# diff_stats_summary is internally consistent
expect(merge_request_graphql_data['diffStatsSummary']
.values_at('additions', 'deletions').sum)
.to eq(merge_request_graphql_data.dig('diffStatsSummary', 'changes'))
.and be_positive
end
it 'includes only the requested stats' do
post_graphql(query, current_user: current_user)
context 'when requesting a specific diff stat' do
let(:diff_stat) { merge_request.diff_stats.first }
expect(merge_request_graphql_data).to include(
'diffStats' => contain_exactly(
a_hash_including('path' => diff_stat.path, 'additions' => diff_stat.additions, 'deletions' => diff_stat.deletions)
let(:mr_fields) do
query_graphql_field(
:diff_stats,
{ path: diff_stat.path },
all_graphql_fields_for('DiffStats')
)
)
end
it 'includes only the requested stats' do
post_graphql(query, current_user: current_user)
expect(merge_request_graphql_data).to include(
'diffStats' => contain_exactly(
a_hash_including(
'path' => diff_stat.path,
'additions' => diff_stat.additions,
'deletions' => diff_stat.deletions
)
)
)
end
end
end
it 'includes correct mergedAt value when merged' do
time = 1.week.ago
merge_request.mark_as_merged
merge_request.metrics.update_columns(merged_at: time)
merge_request.metrics.update!(merged_at: time)
post_graphql(query, current_user: current_user)
retrieved = merge_request_graphql_data['mergedAt']
......@@ -139,7 +160,11 @@ RSpec.describe 'getting merge request information nested in a project' do
expect(retrieved).to be_nil
end
context 'permissions on the merge request' do
describe 'permissions on the merge request' do
let(:mr_fields) do
"userPermissions { #{all_graphql_fields_for('MergeRequestPermissions')} }"
end
it 'includes the permissions for the current user on a public project' do
expected_permissions = {
'readMergeRequest' => true,
......@@ -162,8 +187,6 @@ RSpec.describe 'getting merge request information nested in a project' do
end
context 'when the user does not have access to the merge request' do
let(:project) { create(:project, :public, :repository) }
it 'returns nil' do
project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE)
......@@ -174,13 +197,23 @@ RSpec.describe 'getting merge request information nested in a project' do
end
context 'when there are pipelines' do
before do
let_it_be(:pipeline) do
create(
:ci_pipeline,
project: merge_request.source_project,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha
)
end
let(:mr_fields) do
<<~FIELDS
headPipeline { id }
pipelines { nodes { id } }
FIELDS
end
before do
merge_request.update_head_pipeline
end
......@@ -193,20 +226,12 @@ RSpec.describe 'getting merge request information nested in a project' do
it 'has pipeline connections' do
post_graphql(query, current_user: current_user)
expect(merge_request_graphql_data['pipelines']['edges'].size).to eq(1)
expect(merge_request_graphql_data['pipelines']['nodes']).to be_one
end
end
context 'when limiting the number of results' do
let(:merge_requests_graphql_data) { graphql_data['project']['mergeRequests']['edges'] }
let!(:merge_requests) do
[
create(:merge_request, source_project: project, source_branch: 'branch-1'),
create(:merge_request, source_project: project, source_branch: 'branch-2'),
create(:merge_request, source_project: project, source_branch: 'branch-3')
]
end
let(:merge_requests_graphql_data) { graphql_data_at(:project, :merge_requests, :edges) }
let(:fields) do
<<~QUERY
......@@ -228,6 +253,10 @@ RSpec.describe 'getting merge request information nested in a project' do
end
it 'returns the correct number of results' do
create(:merge_request, source_project: project, source_branch: 'branch-1')
create(:merge_request, source_project: project, source_branch: 'branch-2')
create(:merge_request, source_project: project, source_branch: 'branch-3')
post_graphql(query, current_user: current_user)
expect(merge_requests_graphql_data.size).to eq 2
......@@ -281,4 +310,129 @@ RSpec.describe 'getting merge request information nested in a project' do
)
end
end
context 'when requesting information about MR interactions' do
let_it_be(:user) { create(:user) }
let(:selected_fields) { all_graphql_fields_for('UserMergeRequestInteraction') }
let(:mr_fields) do
query_nodes(
:reviewers,
query_graphql_field(:merge_request_interaction, nil, selected_fields)
)
end
def interaction_data
graphql_data_at(:project, :merge_request, :reviewers, :nodes, :merge_request_interaction)
end
context 'when the user does not have interactions' do
it 'returns null data' do
post_graphql(query)
expect(interaction_data).to be_empty
end
end
context 'when the user is a reviewer, but has not reviewed' do
before do
project.add_guest(user)
merge_request.merge_request_reviewers.create!(reviewer: user)
end
it 'returns falsey values' do
post_graphql(query)
expect(interaction_data).to contain_exactly a_hash_including(
'canMerge' => false,
'canUpdate' => false,
'reviewState' => 'UNREVIEWED',
'reviewed' => false,
'approved' => false
)
end
end
context 'when the user has interacted' do
before do
project.add_maintainer(user)
merge_request.merge_request_reviewers.create!(reviewer: user, state: 'reviewed')
merge_request.approved_by_users << user
end
it 'returns appropriate data' do
post_graphql(query)
enum = ::Types::MergeRequestReviewStateEnum.values['REVIEWED']
expect(interaction_data).to contain_exactly a_hash_including(
'canMerge' => true,
'canUpdate' => true,
'reviewState' => enum.graphql_name,
'reviewed' => true,
'approved' => true
)
end
end
describe 'scalability' do
let_it_be(:other_users) { create_list(:user, 3) }
let(:unreviewed) do
{ 'reviewState' => 'UNREVIEWED' }
end
let(:reviewed) do
{ 'reviewState' => 'REVIEWED' }
end
shared_examples 'scalable query for interaction fields' do
before do
([user] + other_users).each { project.add_guest(_1) }
end
it 'does not suffer from N+1' do
merge_request.merge_request_reviewers.create!(reviewer: user, state: 'reviewed')
baseline = ActiveRecord::QueryRecorder.new do
post_graphql(query)
end
expect(interaction_data).to contain_exactly(include(reviewed))
other_users.each do |user|
merge_request.merge_request_reviewers.create!(reviewer: user)
end
expect { post_graphql(query) }.not_to exceed_query_limit(baseline)
expect(interaction_data).to contain_exactly(
include(unreviewed),
include(unreviewed),
include(unreviewed),
include(reviewed)
)
end
end
context 'when selecting only known scalable fields' do
let(:not_scalable) { %w[canUpdate canMerge] }
let(:selected_fields) do
all_graphql_fields_for('UserMergeRequestInteraction', excluded: not_scalable)
end
it_behaves_like 'scalable query for interaction fields'
end
context 'when selecting all fields' do
before do
pending "See: https://gitlab.com/gitlab-org/gitlab/-/issues/322549"
end
let(:selected_fields) { all_graphql_fields_for('UserMergeRequestInteraction') }
it_behaves_like 'scalable query for interaction fields'
end
end
end
end
......@@ -42,7 +42,13 @@ RSpec.describe 'User' do
end
context 'when username and id parameter are used' do
let_it_be(:query) { graphql_query_for(:user, { id: current_user.to_global_id.to_s, username: current_user.username }, 'id') }
let_it_be(:query) do
graphql_query_for(
:user,
{ id: current_user.to_global_id.to_s, username: current_user.username },
'id'
)
end
it 'displays an error' do
post_graphql(query)
......
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