Commit 3cc8cfea authored by Sean McGivern's avatar Sean McGivern

Merge branch 'project_member_list' into 'master'

Fix listing of project members

See merge request gitlab-org/gitlab!39444
parents 9f729d7c 3aa02468
...@@ -2,19 +2,23 @@ ...@@ -2,19 +2,23 @@
module Resolvers module Resolvers
class ProjectMembersResolver < BaseResolver class ProjectMembersResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
argument :search, GraphQL::STRING_TYPE, argument :search, GraphQL::STRING_TYPE,
required: false, required: false,
description: 'Search query' description: 'Search query'
type Types::ProjectMemberType, null: true type Types::MemberInterface, null: true
authorize :read_project_member
alias_method :project, :object alias_method :project, :object
def resolve(**args) def resolve(**args)
return Member.none unless project.present? authorize!(project)
MembersFinder MembersFinder
.new(project, context[:current_user], params: args) .new(project, current_user, params: args)
.execute .execute
end end
end end
......
...@@ -8,7 +8,7 @@ module Types ...@@ -8,7 +8,7 @@ module Types
implements MemberInterface implements MemberInterface
graphql_name 'GroupMember' graphql_name 'GroupMember'
description 'Represents a Group Member' description 'Represents a Group Membership'
field :group, Types::GroupType, null: true, field :group, Types::GroupType, null: true,
description: 'Group that a User is a member of', description: 'Group that a User is a member of',
......
...@@ -4,6 +4,9 @@ module Types ...@@ -4,6 +4,9 @@ module Types
module MemberInterface module MemberInterface
include BaseInterface include BaseInterface
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the member'
field :access_level, Types::AccessLevelType, null: true, field :access_level, Types::AccessLevelType, null: true,
description: 'GitLab::Access level' description: 'GitLab::Access level'
...@@ -18,5 +21,22 @@ module Types ...@@ -18,5 +21,22 @@ module Types
field :expires_at, Types::TimeType, null: true, field :expires_at, Types::TimeType, null: true,
description: 'Date and time the membership expires' description: 'Date and time the membership expires'
field :user, Types::UserType, null: false,
description: 'User that is associated with the member object',
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.user_id).find }
definition_methods do
def resolve_type(object, context)
case object
when GroupMember
Types::GroupMemberType
when ProjectMember
Types::ProjectMemberType
else
raise ::Gitlab::Graphql::Errors::BaseError, "Unknown member type #{object.class.name}"
end
end
end
end end
end end
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
module Types module Types
class ProjectMemberType < BaseObject class ProjectMemberType < BaseObject
graphql_name 'ProjectMember' graphql_name 'ProjectMember'
description 'Represents a Project Member' description 'Represents a Project Membership'
expose_permissions Types::PermissionTypes::Project expose_permissions Types::PermissionTypes::Project
...@@ -11,13 +11,6 @@ module Types ...@@ -11,13 +11,6 @@ module Types
authorize :read_project authorize :read_project
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the member'
field :user, Types::UserType, null: false,
description: 'User that is associated with the member object',
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.user_id).find }
field :project, Types::ProjectType, null: true, field :project, Types::ProjectType, null: true,
description: 'Project that User is a member of', description: 'Project that User is a member of',
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, obj.source_id).find } resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, obj.source_id).find }
......
...@@ -159,7 +159,7 @@ module Types ...@@ -159,7 +159,7 @@ module Types
resolver: Resolvers::ProjectMilestonesResolver resolver: Resolvers::ProjectMilestonesResolver
field :project_members, field :project_members,
Types::ProjectMemberType.connection_type, Types::MemberInterface.connection_type,
description: 'Members of the project', description: 'Members of the project',
resolver: Resolvers::ProjectMembersResolver resolver: Resolvers::ProjectMembersResolver
......
---
title: Include also inherited project members in GraphQL API
merge_request: 39444
author:
type: fixed
...@@ -6658,7 +6658,7 @@ type Group { ...@@ -6658,7 +6658,7 @@ type Group {
} }
""" """
Represents a Group Member Represents a Group Membership
""" """
type GroupMember implements MemberInterface { type GroupMember implements MemberInterface {
""" """
...@@ -6686,11 +6686,21 @@ type GroupMember implements MemberInterface { ...@@ -6686,11 +6686,21 @@ type GroupMember implements MemberInterface {
""" """
group: Group group: Group
"""
ID of the member
"""
id: ID!
""" """
Date and time the membership was last updated Date and time the membership was last updated
""" """
updatedAt: Time updatedAt: Time
"""
User that is associated with the member object
"""
user: User!
""" """
Permissions for the current user on the resource Permissions for the current user on the resource
""" """
...@@ -8340,10 +8350,55 @@ interface MemberInterface { ...@@ -8340,10 +8350,55 @@ interface MemberInterface {
""" """
expiresAt: Time expiresAt: Time
"""
ID of the member
"""
id: ID!
""" """
Date and time the membership was last updated Date and time the membership was last updated
""" """
updatedAt: Time updatedAt: Time
"""
User that is associated with the member object
"""
user: User!
}
"""
The connection type for MemberInterface.
"""
type MemberInterfaceConnection {
"""
A list of edges.
"""
edges: [MemberInterfaceEdge]
"""
A list of nodes.
"""
nodes: [MemberInterface]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type MemberInterfaceEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: MemberInterface
} }
type MergeRequest implements Noteable { type MergeRequest implements Noteable {
...@@ -11567,7 +11622,7 @@ type Project { ...@@ -11567,7 +11622,7 @@ type Project {
Search query Search query
""" """
search: String search: String
): ProjectMemberConnection ): MemberInterfaceConnection
""" """
Indicates if there is public access to pipelines and job details of the project, including output logs and artifacts Indicates if there is public access to pipelines and job details of the project, including output logs and artifacts
...@@ -12001,7 +12056,7 @@ type ProjectEdge { ...@@ -12001,7 +12056,7 @@ type ProjectEdge {
} }
""" """
Represents a Project Member Represents a Project Membership
""" """
type ProjectMember implements MemberInterface { type ProjectMember implements MemberInterface {
""" """
......
...@@ -18300,7 +18300,7 @@ ...@@ -18300,7 +18300,7 @@
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "GroupMember", "name": "GroupMember",
"description": "Represents a Group Member", "description": "Represents a Group Membership",
"fields": [ "fields": [
{ {
"name": "accessLevel", "name": "accessLevel",
...@@ -18372,6 +18372,24 @@ ...@@ -18372,6 +18372,24 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "id",
"description": "ID of the member",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "updatedAt", "name": "updatedAt",
"description": "Date and time the membership was last updated", "description": "Date and time the membership was last updated",
...@@ -18386,6 +18404,24 @@ ...@@ -18386,6 +18404,24 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "user",
"description": "User that is associated with the member object",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "User",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "userPermissions", "name": "userPermissions",
"description": "Permissions for the current user on the resource", "description": "Permissions for the current user on the resource",
...@@ -23177,6 +23213,24 @@ ...@@ -23177,6 +23213,24 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "id",
"description": "ID of the member",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "updatedAt", "name": "updatedAt",
"description": "Date and time the membership was last updated", "description": "Date and time the membership was last updated",
...@@ -23190,6 +23244,24 @@ ...@@ -23190,6 +23244,24 @@
}, },
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
},
{
"name": "user",
"description": "User that is associated with the member object",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "User",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
} }
], ],
"inputFields": null, "inputFields": null,
...@@ -23208,6 +23280,118 @@ ...@@ -23208,6 +23280,118 @@
} }
] ]
}, },
{
"kind": "OBJECT",
"name": "MemberInterfaceConnection",
"description": "The connection type for MemberInterface.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "MemberInterfaceEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "INTERFACE",
"name": "MemberInterface",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "MemberInterfaceEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "INTERFACE",
"name": "MemberInterface",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "MergeRequest", "name": "MergeRequest",
...@@ -33999,7 +34183,7 @@ ...@@ -33999,7 +34183,7 @@
], ],
"type": { "type": {
"kind": "OBJECT", "kind": "OBJECT",
"name": "ProjectMemberConnection", "name": "MemberInterfaceConnection",
"ofType": null "ofType": null
}, },
"isDeprecated": false, "isDeprecated": false,
...@@ -35133,7 +35317,7 @@ ...@@ -35133,7 +35317,7 @@
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "ProjectMember", "name": "ProjectMember",
"description": "Represents a Project Member", "description": "Represents a Project Membership",
"fields": [ "fields": [
{ {
"name": "accessLevel", "name": "accessLevel",
...@@ -999,7 +999,7 @@ Autogenerated return type of EpicTreeReorder ...@@ -999,7 +999,7 @@ Autogenerated return type of EpicTreeReorder
## GroupMember ## GroupMember
Represents a Group Member Represents a Group Membership
| Name | Type | Description | | Name | Type | Description |
| --- | ---- | ---------- | | --- | ---- | ---------- |
...@@ -1008,7 +1008,9 @@ Represents a Group Member ...@@ -1008,7 +1008,9 @@ Represents a Group Member
| `createdBy` | User | User that authorized membership | | `createdBy` | User | User that authorized membership |
| `expiresAt` | Time | Date and time the membership expires | | `expiresAt` | Time | Date and time the membership expires |
| `group` | Group | Group that a User is a member of | | `group` | Group | Group that a User is a member of |
| `id` | ID! | ID of the member |
| `updatedAt` | Time | Date and time the membership was last updated | | `updatedAt` | Time | Date and time the membership was last updated |
| `user` | User! | User that is associated with the member object |
| `userPermissions` | GroupPermissions! | Permissions for the current user on the resource | | `userPermissions` | GroupPermissions! | Permissions for the current user on the resource |
## GroupPermissions ## GroupPermissions
...@@ -1690,7 +1692,7 @@ Information about pagination in a connection. ...@@ -1690,7 +1692,7 @@ Information about pagination in a connection.
## ProjectMember ## ProjectMember
Represents a Project Member Represents a Project Membership
| Name | Type | Description | | Name | Type | Description |
| --- | ---- | ---------- | | --- | ---- | ---------- |
......
...@@ -9,7 +9,7 @@ RSpec.describe Resolvers::ProjectMembersResolver do ...@@ -9,7 +9,7 @@ RSpec.describe Resolvers::ProjectMembersResolver do
let_it_be(:root_group) { create(:group) } let_it_be(:root_group) { create(:group) }
let_it_be(:group_1) { create(:group, parent: root_group) } let_it_be(:group_1) { create(:group, parent: root_group) }
let_it_be(:group_2) { create(:group, parent: root_group) } let_it_be(:group_2) { create(:group, parent: root_group) }
let_it_be(:project) { create(:project, :public, group: group_1) } let_it_be(:project) { create(:project, group: group_1) }
let_it_be(:user_1) { create(:user, name: 'test user') } let_it_be(:user_1) { create(:user, name: 'test user') }
let_it_be(:user_2) { create(:user, name: 'test user 2') } let_it_be(:user_2) { create(:user, name: 'test user 2') }
...@@ -24,7 +24,7 @@ RSpec.describe Resolvers::ProjectMembersResolver do ...@@ -24,7 +24,7 @@ RSpec.describe Resolvers::ProjectMembersResolver do
let(:args) { {} } let(:args) { {} }
subject do subject do
resolve(described_class, obj: project, args: args, ctx: { context: user_4 }) resolve(described_class, obj: project, args: args, ctx: { current_user: user_4 })
end end
describe '#resolve' do describe '#resolve' do
...@@ -50,11 +50,15 @@ RSpec.describe Resolvers::ProjectMembersResolver do ...@@ -50,11 +50,15 @@ RSpec.describe Resolvers::ProjectMembersResolver do
end end
end end
context 'when project is nil' do context 'when user can not see project members' do
let(:project) { nil } let_it_be(:other_user) { create(:user) }
it 'returns nil' do subject do
expect(subject).to be_empty resolve(described_class, obj: project, args: args, ctx: { current_user: other_user })
end
it 'raises an error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end end
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::MemberInterface do
it 'exposes the expected fields' do
expected_fields = %i[
id
access_level
created_by
created_at
updated_at
expires_at
user
]
expect(described_class).to have_graphql_fields(*expected_fields)
end
describe '.resolve_type' do
subject { described_class.resolve_type(object, {}) }
context 'for project member' do
let(:object) { build(:project_member) }
it { is_expected.to be Types::ProjectMemberType }
end
context 'for group member' do
let(:object) { build(:group_member) }
it { is_expected.to be Types::GroupMemberType }
end
context 'for an unkown type' do
let(:object) { build(:user) }
it 'raises an error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::BaseError)
end
end
end
end
...@@ -108,7 +108,7 @@ RSpec.describe GitlabSchema.types['Project'] do ...@@ -108,7 +108,7 @@ RSpec.describe GitlabSchema.types['Project'] do
describe 'members field' do describe 'members field' do
subject { described_class.fields['projectMembers'] } subject { described_class.fields['projectMembers'] }
it { is_expected.to have_graphql_type(Types::ProjectMemberType.connection_type) } it { is_expected.to have_graphql_type(Types::MemberInterface.connection_type) }
it { is_expected.to have_graphql_resolver(Resolvers::ProjectMembersResolver) } it { is_expected.to have_graphql_resolver(Resolvers::ProjectMembersResolver) }
end end
......
...@@ -5,7 +5,8 @@ require 'spec_helper' ...@@ -5,7 +5,8 @@ require 'spec_helper'
RSpec.describe 'getting project information' do RSpec.describe 'getting project information' do
include GraphqlHelpers include GraphqlHelpers
let(:project) { create(:project, :repository) } let(:group) { create(:group) }
let(:project) { create(:project, :repository, group: group) }
let(:current_user) { create(:user) } let(:current_user) { create(:user) }
let(:query) do let(:query) do
...@@ -60,6 +61,51 @@ RSpec.describe 'getting project information' do ...@@ -60,6 +61,51 @@ RSpec.describe 'getting project information' do
expect(graphql_data['project']['pipelines']['edges'].size).to eq(1) expect(graphql_data['project']['pipelines']['edges'].size).to eq(1)
end end
end end
it 'includes inherited members in project_members' do
group_member = create(:group_member, group: group)
project_member = create(:project_member, project: project)
member_query = <<~GQL
query {
project(fullPath: "#{project.full_path}") {
projectMembers {
nodes {
id
user {
username
}
... on ProjectMember {
project {
id
}
}
... on GroupMember {
group {
id
}
}
}
}
}
}
GQL
post_graphql(member_query, current_user: current_user)
member_ids = graphql_data.dig('project', 'projectMembers', 'nodes')
expect(member_ids).to include(
a_hash_including(
'id' => group_member.to_global_id.to_s,
'group' => { 'id' => group.to_global_id.to_s }
)
)
expect(member_ids).to include(
a_hash_including(
'id' => project_member.to_global_id.to_s,
'project' => { 'id' => project.to_global_id.to_s }
)
)
end
end end
describe 'performance' do describe 'performance' 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