Commit 5d90ce1b authored by Mayra Cabrera's avatar Mayra Cabrera

Merge branch '26732-manageable-groups-api' into 'master'

Add groups field for User type in GraphQL API

See merge request gitlab-org/gitlab!66112
parents 792b4b8f 914b1138
# frozen_string_literal: true
# Groups::UserGroupsFinder
#
# Used to filter Groups where a user is member
#
# Arguments:
# current_user - user requesting group info on target user
# target_user - user for which groups will be found
# params:
# permissions: string (see Types::Groups::UserPermissionsEnum)
# search: string used for search on path and group name
#
# Initially created to filter user groups and descendants where the user can create projects
module Groups
class UserGroupsFinder
def initialize(current_user, target_user, params = {})
@current_user = current_user
@target_user = target_user
@params = params
end
def execute
return Group.none unless current_user&.can?(:read_user_groups, target_user)
return Group.none if target_user.blank?
items = by_permission_scope
items = by_search(items)
sort(items)
end
private
attr_reader :current_user, :target_user, :params
def sort(items)
items.order(path: :asc, id: :asc) # rubocop: disable CodeReuse/ActiveRecord
end
def by_search(items)
return items if params[:search].blank?
items.search(params[:search])
end
def by_permission_scope
if permission_scope_create_projects?
target_user.manageable_groups(include_groups_with_developer_maintainer_access: true)
else
target_user.groups
end
end
def permission_scope_create_projects?
params[:permission_scope] == :create_projects &&
Feature.enabled?(:paginatable_namespace_drop_down_for_project_creation, current_user, default_enabled: :yaml)
end
end
end
......@@ -124,6 +124,16 @@ module Resolvers
[args[:iid], args[:iids]].any? ? 0 : 0.01
end
def self.before_connection_authorization(&block)
@before_connection_authorization_block = block
end
# rubocop: disable Style/TrivialAccessors
def self.before_connection_authorization_block
@before_connection_authorization_block
end
# rubocop: enable Style/TrivialAccessors
def offset_pagination(relation)
::Gitlab::Graphql::Pagination::OffsetPaginatedRelation.new(relation)
end
......
# frozen_string_literal: true
module Resolvers
module Users
class GroupsResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
include LooksAhead
type Types::GroupType.connection_type, null: true
authorize :read_user_groups
authorizes_object!
argument :search, GraphQL::Types::String,
required: false,
description: 'Search by group name or path.'
argument :permission_scope,
::Types::PermissionTypes::GroupEnum,
required: false,
description: 'Filter by permissions the user has on groups.'
before_connection_authorization do |nodes, current_user|
Preloaders::UserMaxAccessLevelInGroupsPreloader.new(nodes, current_user).execute
end
def resolve_with_lookahead(**args)
return unless Feature.enabled?(:paginatable_namespace_drop_down_for_project_creation, current_user, default_enabled: :yaml)
apply_lookahead(Groups::UserGroupsFinder.new(current_user, object, args).execute)
end
private
def preloads
{
path: [:route],
full_path: [:route]
}
end
end
end
end
Resolvers::Users::GroupsResolver.prepend_mod_with('Resolvers::Users::GroupsResolver')
......@@ -5,7 +5,7 @@ module Types
class Group < BasePermissionType
graphql_name 'GroupPermissions'
abilities :read_group
abilities :read_group, :create_projects
end
end
end
# frozen_string_literal: true
module Types
module PermissionTypes
class GroupEnum < BaseEnum
graphql_name 'GroupPermission'
description 'User permission on groups'
value 'CREATE_PROJECTS', value: :create_projects, description: 'Groups where the user can create projects.'
end
end
end
......@@ -59,6 +59,9 @@ module Types
type: Types::GroupMemberType.connection_type,
null: true,
description: 'Group memberships of the user.'
field :groups,
resolver: Resolvers::Users::GroupsResolver,
description: 'Groups where the user has access.'
field :group_count,
resolver: Resolvers::Users::GroupCountResolver,
description: 'Group count for the user.'
......
# frozen_string_literal: true
module Preloaders
# This class preloads the max access level (role) for the user within the given groups and
# stores the values in requests store.
# Will only be able to preload max access level for groups where the user is a direct member
class UserMaxAccessLevelInGroupsPreloader
include BulkMemberAccessLoad
def initialize(groups, user)
@groups = groups
@user = user
end
def execute
group_memberships = GroupMember.active_without_invites_and_requests
.non_minimal_access
.where(user: @user, source_id: @groups)
.group(:source_id)
.maximum(:access_level)
group_memberships.each do |group_id, max_access_level|
merge_value_to_request_store(User, @user.id, group_id, max_access_level)
end
end
end
end
......@@ -25,6 +25,7 @@ class UserPolicy < BasePolicy
enable :update_user_status
enable :read_user_personal_access_tokens
enable :read_group_count
enable :read_user_groups
end
rule { default }.enable :read_user_profile
......
---
name: paginatable_namespace_drop_down_for_project_creation
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/66112
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338930
milestone: '14.3'
type: development
group: group::project management
default_enabled: false
......@@ -10176,6 +10176,7 @@ Represents a Group Membership.
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="grouppermissionscreateprojects"></a>`createProjects` | [`Boolean!`](#boolean) | Indicates the user can perform `create_projects` on this resource. |
| <a id="grouppermissionsreadgroup"></a>`readGroup` | [`Boolean!`](#boolean) | Indicates the user can perform `read_group` on this resource. |
### `GroupReleaseStats`
......@@ -10910,6 +10911,23 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="mergerequestassigneeauthoredmergerequestsstate"></a>`state` | [`MergeRequestState`](#mergerequeststate) | Merge request state. If provided, all resolved merge requests will have this state. |
| <a id="mergerequestassigneeauthoredmergerequeststargetbranches"></a>`targetBranches` | [`[String!]`](#string) | Array of target branch names. All resolved merge requests will have one of these branches as their target. |
##### `MergeRequestAssignee.groups`
Groups where the user has access.
Returns [`GroupConnection`](#groupconnection).
This field returns a [connection](#connections). It accepts the
four standard [pagination arguments](#connection-pagination-arguments):
`before: String`, `after: String`, `first: Int`, `last: Int`.
###### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mergerequestassigneegroupspermissionscope"></a>`permissionScope` | [`GroupPermission`](#grouppermission) | Filter by permissions the user has on groups. |
| <a id="mergerequestassigneegroupssearch"></a>`search` | [`String`](#string) | Search by group name or path. |
##### `MergeRequestAssignee.reviewRequestedMergeRequests`
Merge requests assigned to the user for review.
......@@ -11139,6 +11157,23 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="mergerequestreviewerauthoredmergerequestsstate"></a>`state` | [`MergeRequestState`](#mergerequeststate) | Merge request state. If provided, all resolved merge requests will have this state. |
| <a id="mergerequestreviewerauthoredmergerequeststargetbranches"></a>`targetBranches` | [`[String!]`](#string) | Array of target branch names. All resolved merge requests will have one of these branches as their target. |
##### `MergeRequestReviewer.groups`
Groups where the user has access.
Returns [`GroupConnection`](#groupconnection).
This field returns a [connection](#connections). It accepts the
four standard [pagination arguments](#connection-pagination-arguments):
`before: String`, `after: String`, `first: Int`, `last: Int`.
###### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mergerequestreviewergroupspermissionscope"></a>`permissionScope` | [`GroupPermission`](#grouppermission) | Filter by permissions the user has on groups. |
| <a id="mergerequestreviewergroupssearch"></a>`search` | [`String`](#string) | Search by group name or path. |
##### `MergeRequestReviewer.reviewRequestedMergeRequests`
Merge requests assigned to the user for review.
......@@ -14010,6 +14045,23 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="usercoreauthoredmergerequestsstate"></a>`state` | [`MergeRequestState`](#mergerequeststate) | Merge request state. If provided, all resolved merge requests will have this state. |
| <a id="usercoreauthoredmergerequeststargetbranches"></a>`targetBranches` | [`[String!]`](#string) | Array of target branch names. All resolved merge requests will have one of these branches as their target. |
##### `UserCore.groups`
Groups where the user has access.
Returns [`GroupConnection`](#groupconnection).
This field returns a [connection](#connections). It accepts the
four standard [pagination arguments](#connection-pagination-arguments):
`before: String`, `after: String`, `first: Int`, `last: Int`.
###### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="usercoregroupspermissionscope"></a>`permissionScope` | [`GroupPermission`](#grouppermission) | Filter by permissions the user has on groups. |
| <a id="usercoregroupssearch"></a>`search` | [`String`](#string) | Search by group name or path. |
##### `UserCore.reviewRequestedMergeRequests`
Merge requests assigned to the user for review.
......@@ -15162,6 +15214,14 @@ Group member relation.
| <a id="groupmemberrelationdirect"></a>`DIRECT` | Members in the group itself. |
| <a id="groupmemberrelationinherited"></a>`INHERITED` | Members in the group's ancestor groups. |
### `GroupPermission`
User permission on groups.
| Value | Description |
| ----- | ----------- |
| <a id="grouppermissioncreate_projects"></a>`CREATE_PROJECTS` | Groups where the user can create projects. |
### `HealthStatus`
Health status of an issue or epic.
......@@ -16961,6 +17021,23 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="userauthoredmergerequestsstate"></a>`state` | [`MergeRequestState`](#mergerequeststate) | Merge request state. If provided, all resolved merge requests will have this state. |
| <a id="userauthoredmergerequeststargetbranches"></a>`targetBranches` | [`[String!]`](#string) | Array of target branch names. All resolved merge requests will have one of these branches as their target. |
###### `User.groups`
Groups where the user has access.
Returns [`GroupConnection`](#groupconnection).
This field returns a [connection](#connections). It accepts the
four standard [pagination arguments](#connection-pagination-arguments):
`before: String`, `after: String`, `first: Int`, `last: Int`.
####### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="usergroupspermissionscope"></a>`permissionScope` | [`GroupPermission`](#grouppermission) | Filter by permissions the user has on groups. |
| <a id="usergroupssearch"></a>`search` | [`String`](#string) | Search by group name or path. |
###### `User.reviewRequestedMergeRequests`
Merge requests assigned to the user for review.
......
# frozen_string_literal: true
module EE
module Resolvers
module Users
module GroupsResolver
extend ActiveSupport::Concern
extend ::Gitlab::Utils::Override
private
override :unconditional_includes
def unconditional_includes
[:saml_provider, *super]
end
end
end
end
end
......@@ -7,12 +7,14 @@ module Gitlab
class Redactor
include ::Gitlab::Graphql::Laziness
def initialize(type, context)
def initialize(type, context, resolver)
@type = type
@context = context
@resolver = resolver
end
def redact(nodes)
perform_before_authorize_action(nodes)
remove_unauthorized(nodes)
nodes
......@@ -29,6 +31,13 @@ module Gitlab
private
def perform_before_authorize_action(nodes)
before_connection_authorization_block = @resolver&.before_connection_authorization_block
return unless before_connection_authorization_block.respond_to?(:call)
before_connection_authorization_block.call(nodes, @context[:current_user])
end
def remove_unauthorized(nodes)
nodes
.map! { |lazy| force(lazy) }
......@@ -49,14 +58,14 @@ module Gitlab
end
def redact_connection(conn, context)
redactor = Redactor.new(@field.type.unwrap.node_type, context)
redactor = Redactor.new(@field.type.unwrap.node_type, context, @field.resolver)
return unless redactor.active?
conn.redactor = redactor if conn.respond_to?(:redactor=)
end
def redact_list(list, context)
redactor = Redactor.new(@field.type.unwrap, context)
redactor = Redactor.new(@field.type.unwrap, context, @field.resolver)
redactor.redact(list) if redactor.active?
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Groups::UserGroupsFinder do
describe '#execute' do
let_it_be(:user) { create(:user) }
let_it_be(:guest_group) { create(:group, name: 'public guest', path: 'public-guest') }
let_it_be(:private_maintainer_group) { create(:group, :private, name: 'b private maintainer', path: 'b-private-maintainer') }
let_it_be(:public_developer_group) { create(:group, project_creation_level: nil, name: 'c public developer', path: 'c-public-developer') }
let_it_be(:public_maintainer_group) { create(:group, name: 'a public maintainer', path: 'a-public-maintainer') }
subject { described_class.new(current_user, target_user, arguments).execute }
let(:arguments) { {} }
let(:current_user) { user }
let(:target_user) { user }
before_all do
guest_group.add_guest(user)
private_maintainer_group.add_maintainer(user)
public_developer_group.add_developer(user)
public_maintainer_group.add_maintainer(user)
end
it 'returns all groups where the user is a direct member' do
is_expected.to match(
[
public_maintainer_group,
private_maintainer_group,
public_developer_group,
guest_group
]
)
end
context 'when target_user is nil' do
let(:target_user) { nil }
it { is_expected.to be_empty }
end
context 'when current_user is nil' do
let(:current_user) { nil }
it { is_expected.to be_empty }
end
context 'when permission is :create_projects' do
let(:arguments) { { permission_scope: :create_projects } }
specify do
is_expected.to match(
[
public_maintainer_group,
private_maintainer_group,
public_developer_group
]
)
end
context 'when paginatable_namespace_drop_down_for_project_creation feature flag is disabled' do
before do
stub_feature_flags(paginatable_namespace_drop_down_for_project_creation: false)
end
it 'ignores project creation scope and returns all groups where the user is a direct member' do
is_expected.to match(
[
public_maintainer_group,
private_maintainer_group,
public_developer_group,
guest_group
]
)
end
end
context 'when search is provided' do
let(:arguments) { { permission_scope: :create_projects, search: 'maintainer' } }
specify do
is_expected.to match(
[
public_maintainer_group,
private_maintainer_group
]
)
end
end
end
context 'when search is provided' do
let(:arguments) { { search: 'maintainer' } }
specify do
is_expected.to match(
[
public_maintainer_group,
private_maintainer_group
]
)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Users::GroupsResolver do
include GraphqlHelpers
include AdminModeHelper
describe '#resolve' do
let_it_be(:user) { create(:user) }
let_it_be(:guest_group) { create(:group, name: 'public guest', path: 'public-guest') }
let_it_be(:private_maintainer_group) { create(:group, :private, name: 'b private maintainer', path: 'b-private-maintainer') }
let_it_be(:public_developer_group) { create(:group, project_creation_level: nil, name: 'c public developer', path: 'c-public-developer') }
let_it_be(:public_maintainer_group) { create(:group, name: 'a public maintainer', path: 'a-public-maintainer') }
subject(:resolved_items) { resolve_groups(args: group_arguments, current_user: current_user, obj: resolver_object) }
let(:group_arguments) { {} }
let(:current_user) { user }
let(:resolver_object) { user }
before_all do
guest_group.add_guest(user)
private_maintainer_group.add_maintainer(user)
public_developer_group.add_developer(user)
public_maintainer_group.add_maintainer(user)
end
context 'when paginatable_namespace_drop_down_for_project_creation feature flag is disabled' do
before do
stub_feature_flags(paginatable_namespace_drop_down_for_project_creation: false)
end
it { is_expected.to be_nil }
end
context 'when resolver object is current user' do
context 'when permission is :create_projects' do
let(:group_arguments) { { permission_scope: :create_projects } }
specify do
is_expected.to match(
[
public_maintainer_group,
private_maintainer_group,
public_developer_group
]
)
end
end
specify do
is_expected.to match(
[
public_maintainer_group,
private_maintainer_group,
public_developer_group,
guest_group
]
)
end
context 'when search is provided' do
let(:group_arguments) { { search: 'maintainer' } }
specify do
is_expected.to match(
[
public_maintainer_group,
private_maintainer_group
]
)
end
end
end
context 'when resolver object is different from current user' do
let(:current_user) { create(:user) }
it { is_expected.to be_nil }
context 'when current_user is admin' do
let(:current_user) { create(:user, :admin) }
before do
enable_admin_mode!(current_user)
end
specify do
is_expected.to match(
[
public_maintainer_group,
private_maintainer_group,
public_developer_group,
guest_group
]
)
end
end
end
end
def resolve_groups(args:, current_user:, obj:)
resolve(described_class, args: args, ctx: { current_user: current_user }, obj: obj)&.items
end
end
......@@ -33,6 +33,7 @@ RSpec.describe GitlabSchema.types['MergeRequestReviewer'] do
merge_request_interaction
namespace
timelogs
groups
]
expect(described_class).to have_graphql_fields(*expected_fields)
......
......@@ -38,6 +38,7 @@ RSpec.describe GitlabSchema.types['User'] do
callouts
namespace
timelogs
groups
]
expect(described_class).to have_graphql_fields(*expected_fields)
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Preloaders::UserMaxAccessLevelInGroupsPreloader do
let_it_be(:user) { create(:user) }
let_it_be(:group1) { create(:group, :private).tap { |g| g.add_developer(user) } }
let_it_be(:group2) { create(:group, :private).tap { |g| g.add_developer(user) } }
let_it_be(:group3) { create(:group, :private) }
let(:max_query_regex) { /SELECT MAX\("members"\."access_level"\).+/ }
let(:groups) { [group1, group2, group3] }
shared_examples 'executes N max member permission queries to the DB' do
it 'executes the specified max membership queries' do
queries = ActiveRecord::QueryRecorder.new do
groups.each { |group| user.can?(:read_group, group) }
end
max_queries = queries.log.grep(max_query_regex)
expect(max_queries.count).to eq(expected_query_count)
end
end
context 'when the preloader is used', :request_store do
before do
described_class.new(groups, user).execute
end
it_behaves_like 'executes N max member permission queries to the DB' do
# Will query all groups where the user is not already a member
let(:expected_query_count) { 1 }
end
context 'when user has access but is not a direct member of the group' do
let(:groups) { [group1, group2, group3, create(:group, :private, parent: group1)] }
it_behaves_like 'executes N max member permission queries to the DB' do
# One query for group with no access and another one where the user is not a direct member
let(:expected_query_count) { 2 }
end
end
end
context 'when the preloader is not used', :request_store do
it_behaves_like 'executes N max member permission queries to the DB' do
let(:expected_query_count) { groups.count }
end
end
end
......@@ -3,8 +3,12 @@
require 'spec_helper'
RSpec.describe UserPolicy do
let(:current_user) { create(:user) }
let(:user) { create(:user) }
let_it_be(:admin) { create(:user, :admin) }
let_it_be(:regular_user) { create(:user) }
let_it_be(:subject_user) { create(:user) }
let(:current_user) { regular_user }
let(:user) { subject_user }
subject { described_class.new(current_user, user) }
......@@ -16,7 +20,7 @@ RSpec.describe UserPolicy do
let(:token) { create(:personal_access_token, user: user) }
context 'when user is admin' do
let(:current_user) { create(:user, :admin) }
let(:current_user) { admin }
context 'when admin mode is enabled', :enable_admin_mode do
it { is_expected.to be_allowed(:read_user_personal_access_tokens) }
......@@ -42,7 +46,7 @@ RSpec.describe UserPolicy do
describe "creating a different user's Personal Access Tokens" do
context 'when current_user is admin' do
let(:current_user) { create(:user, :admin) }
let(:current_user) { admin }
context 'when admin mode is enabled and current_user is not blocked', :enable_admin_mode do
it { is_expected.to be_allowed(:create_user_personal_access_token) }
......@@ -92,7 +96,7 @@ RSpec.describe UserPolicy do
end
context "when an admin user tries to destroy a regular user" do
let(:current_user) { create(:user, :admin) }
let(:current_user) { admin }
context 'when admin mode is enabled', :enable_admin_mode do
it { is_expected.to be_allowed(ability) }
......@@ -104,7 +108,7 @@ RSpec.describe UserPolicy do
end
context "when an admin user tries to destroy a ghost user" do
let(:current_user) { create(:user, :admin) }
let(:current_user) { admin }
let(:user) { create(:user, :ghost) }
it { is_expected.not_to be_allowed(ability) }
......@@ -132,7 +136,7 @@ RSpec.describe UserPolicy do
context 'disabling the two-factor authentication of another user' do
context 'when the executor is an admin', :enable_admin_mode do
let(:current_user) { create(:user, :admin) }
let(:current_user) { admin }
it { is_expected.to be_allowed(:disable_two_factor) }
end
......@@ -145,7 +149,7 @@ RSpec.describe UserPolicy do
describe "reading a user's group count" do
context "when current_user is an admin", :enable_admin_mode do
let(:current_user) { create(:user, :admin) }
let(:current_user) { admin }
it { is_expected.to be_allowed(:read_group_count) }
end
......@@ -172,4 +176,30 @@ RSpec.describe UserPolicy do
it { is_expected.to be_allowed(:read_user_profile) }
end
end
describe ':read_user_groups' do
context 'when user is admin' do
let(:current_user) { admin }
context 'when admin mode is enabled', :enable_admin_mode do
it { is_expected.to be_allowed(:read_user_groups) }
end
context 'when admin mode is disabled' do
it { is_expected.not_to be_allowed(:read_user_groups) }
end
end
context 'when user is not an admin' do
context 'requesting their own manageable groups' do
subject { described_class.new(current_user, current_user) }
it { is_expected.to be_allowed(:read_user_groups) }
end
context "requesting a different user's manageable groups" do
it { is_expected.not_to be_allowed(:read_user_groups) }
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Query current user groups' do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:guest_group) { create(:group, name: 'public guest', path: 'public-guest') }
let_it_be(:private_maintainer_group) { create(:group, :private, name: 'b private maintainer', path: 'b-private-maintainer') }
let_it_be(:public_developer_group) { create(:group, :private, project_creation_level: nil, name: 'c public developer', path: 'c-public-developer') }
let_it_be(:public_maintainer_group) { create(:group, :private, name: 'a public maintainer', path: 'a-public-maintainer') }
let(:group_arguments) { {} }
let(:current_user) { user }
let(:fields) do
<<~GRAPHQL
nodes { id path fullPath name }
GRAPHQL
end
let(:query) do
graphql_query_for('currentUser', {}, query_graphql_field('groups', group_arguments, fields))
end
before_all do
guest_group.add_guest(user)
private_maintainer_group.add_maintainer(user)
public_developer_group.add_developer(user)
public_maintainer_group.add_maintainer(user)
end
subject { graphql_data.dig('currentUser', 'groups', 'nodes') }
before do
post_graphql(query, current_user: current_user)
end
it_behaves_like 'a working graphql query'
it 'avoids N+1 queries', :request_store do
control = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) }
new_group = create(:group, :private)
new_group.add_maintainer(current_user)
expect { post_graphql(query, current_user: current_user) }.not_to exceed_query_limit(control)
end
it 'returns all groups where the user is a direct member' do
is_expected.to match(
expected_group_hash(
public_maintainer_group,
private_maintainer_group,
public_developer_group,
guest_group
)
)
end
context 'when permission_scope is CREATE_PROJECTS' do
let(:group_arguments) { { permission_scope: :CREATE_PROJECTS } }
specify do
is_expected.to match(
expected_group_hash(
public_maintainer_group,
private_maintainer_group,
public_developer_group
)
)
end
context 'when search is provided' do
let(:group_arguments) { { permission_scope: :CREATE_PROJECTS, search: 'maintainer' } }
specify do
is_expected.to match(
expected_group_hash(
public_maintainer_group,
private_maintainer_group
)
)
end
end
end
context 'when search is provided' do
let(:group_arguments) { { search: 'maintainer' } }
specify do
is_expected.to match(
expected_group_hash(
public_maintainer_group,
private_maintainer_group
)
)
end
end
def expected_group_hash(*groups)
groups.map do |group|
{
'id' => group.to_global_id.to_s,
'name' => group.name,
'path' => group.path,
'fullPath' => group.full_path
}
end
end
end
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