Commit cac2115c authored by Michael Kozono's avatar Michael Kozono

Merge branch 'epic_boards_graphql' into 'master'

Expose epic boards in GraphQL API

See merge request gitlab-org/gitlab!48893
parents a48c3e6a c4fe6bd9
......@@ -2195,6 +2195,11 @@ type BoardListUpdateLimitMetricsPayload {
list: BoardList
}
"""
Identifier of Boards::EpicBoard
"""
scalar BoardsEpicBoardID
type Branch {
"""
Commit for the branch
......@@ -7943,6 +7948,56 @@ type EpicAddIssuePayload {
errors: [String!]!
}
"""
Represents an epic board
"""
type EpicBoard {
"""
Global ID of the board
"""
id: BoardsEpicBoardID!
"""
Name of the board
"""
name: String
}
"""
The connection type for EpicBoard.
"""
type EpicBoardConnection {
"""
A list of edges.
"""
edges: [EpicBoardEdge]
"""
A list of nodes.
"""
nodes: [EpicBoard]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type EpicBoardEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: EpicBoard
}
"""
The connection type for Epic.
"""
......@@ -9237,6 +9292,41 @@ type Group {
timeframe: Timeframe
): Epic
"""
Find a single epic board
"""
epicBoard(
"""
Find an epic board by ID
"""
id: BoardsEpicBoardID!
): EpicBoard
"""
Find epic boards
"""
epicBoards(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
): EpicBoardConnection
"""
Find epics
"""
......
......@@ -5842,6 +5842,16 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "BoardsEpicBoardID",
"description": "Identifier of Boards::EpicBoard",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "Boolean",
......@@ -22237,6 +22247,163 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "EpicBoard",
"description": "Represents an epic board",
"fields": [
{
"name": "id",
"description": "Global ID of the board",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "BoardsEpicBoardID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": "Name of the board",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "EpicBoardConnection",
"description": "The connection type for EpicBoard.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "EpicBoardEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "EpicBoard",
"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": "EpicBoardEdge",
"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": "OBJECT",
"name": "EpicBoard",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "EpicConnection",
......@@ -25698,6 +25865,86 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "epicBoard",
"description": "Find a single epic board",
"args": [
{
"name": "id",
"description": "Find an epic board by ID",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "BoardsEpicBoardID",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "EpicBoard",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "epicBoards",
"description": "Find epic boards",
"args": [
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "EpicBoardConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "epics",
"description": "Find epics",
......@@ -1352,6 +1352,15 @@ Autogenerated return type of EpicAddIssue.
| `epicIssue` | EpicIssue | The epic-issue relation |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
### EpicBoard
Represents an epic board.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | BoardsEpicBoardID! | Global ID of the board |
| `name` | String | Name of the board |
### EpicDescendantCount
Counts of descendent epics.
......@@ -1532,6 +1541,8 @@ Autogenerated return type of EpicTreeReorder.
| `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` |
| `emailsDisabled` | Boolean | Indicates if a group has email notifications disabled |
| `epic` | Epic | Find a single epic |
| `epicBoard` | EpicBoard | Find a single epic board |
| `epicBoards` | EpicBoardConnection | Find epic boards |
| `epics` | EpicConnection | Find epics |
| `epicsEnabled` | Boolean | Indicates if Epics are enabled for namespace |
| `fullName` | String! | Full name of the namespace |
......
# frozen_string_literal: true
module Boards
class EpicBoardsFinder
attr_reader :group, :params
def initialize(group, params = {})
@group = group
@params = params
end
def execute
relation = group.epic_boards
relation = by_id(relation)
relation.order_by_name_asc
end
private
def by_id(relation)
return relation unless params[:id].present?
relation.id_in(params[:id])
end
end
end
......@@ -25,6 +25,16 @@ module EE
max_page_size: 2000,
resolver: ::Resolvers::EpicsResolver
field :epic_board,
::Types::Boards::EpicBoardType, null: true,
description: 'Find a single epic board',
resolver: ::Resolvers::Boards::EpicBoardsResolver.single
field :epic_boards,
::Types::Boards::EpicBoardType.connection_type, null: true,
description: 'Find epic boards',
resolver: ::Resolvers::Boards::EpicBoardsResolver
field :iterations, ::Types::IterationType.connection_type, null: true,
description: 'Find iterations',
resolver: ::Resolvers::IterationsResolver
......
# frozen_string_literal: true
module Resolvers
module Boards
class EpicBoardsResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::Boards::EpicBoardType.connection_type, null: true
when_single do
argument :id, ::Types::GlobalIDType[::Boards::EpicBoard],
required: true,
description: 'Find an epic board by ID'
end
alias_method :group, :object
def resolve(id: nil)
return unless Feature.enabled?(:epic_boards, group)
return unless group.feature_available?(:epics)
authorize!
::Boards::EpicBoardsFinder.new(group, id: id&.model_id).execute
end
private
def authorize!
Ability.allowed?(context[:current_user], :read_epic_board, group) || raise_resource_not_available_error!
end
end
end
end
# frozen_string_literal: true
module Types
module Boards
class EpicBoardType < BaseObject
graphql_name 'EpicBoard'
description 'Represents an epic board'
accepts ::Boards::EpicBoard
authorize :read_epic_board
field :id, type: ::Types::GlobalIDType[::Boards::EpicBoard], null: false,
description: 'Global ID of the board'
field :name, type: GraphQL::STRING_TYPE, null: true,
description: 'Name of the board'
end
end
end
......@@ -7,5 +7,7 @@ module Boards
has_many :epic_board_positions, foreign_key: :epic_board_id, inverse_of: :epic_board
validates :name, length: { maximum: 255 }
scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc).order(id: :asc) }
end
end
# frozen_string_literal: true
module Boards
class EpicBoardPolicy < ::BasePolicy
delegate { subject.group }
end
end
......@@ -165,7 +165,10 @@ module EE
enable :change_prevent_group_forking
end
rule { can?(:read_group) & epics_available }.enable :read_epic
rule { can?(:read_group) & epics_available }.policy do
enable :read_epic
enable :read_epic_board
end
rule { can?(:read_group) & iterations_available }.enable :read_iteration
......
---
name: epic_boards
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48893
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/290039
milestone: '13.7'
type: development
group: group::plan
default_enabled: false
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Boards::EpicBoardsFinder do
describe '#execute' do
let_it_be(:group) { create(:group) }
let_it_be(:epic_board1) { create(:epic_board, name: 'Acd', group: group) }
let_it_be(:epic_board2) { create(:epic_board, name: 'abd', group: group) }
let_it_be(:epic_board3) { create(:epic_board, name: 'Bbd', group: group) }
let_it_be(:epic_board4) { create(:epic_board) }
let(:params) { {} }
subject(:result) { described_class.new(group, params).execute }
it 'finds all epic boards in the group ordered by case-insensitive name' do
expect(result).to eq([epic_board2, epic_board1, epic_board3])
end
context 'when ID parameter is set' do
let(:params) { { id: epic_board2.id } }
it 'finds epic board by ID' do
expect(result).to eq([epic_board2])
end
end
end
end
......@@ -5,8 +5,10 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['Group'] do
describe 'nested epic request' do
it { expect(described_class).to have_graphql_field(:epicsEnabled) }
it { expect(described_class).to have_graphql_field(:epics) }
it { expect(described_class).to have_graphql_field(:epic) }
it { expect(described_class).to have_graphql_field(:epics) }
it { expect(described_class).to have_graphql_field(:epic_board) }
it { expect(described_class).to have_graphql_field(:epic_boards) }
end
it { expect(described_class).to have_graphql_field(:iterations) }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Boards::EpicBoardsResolver do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be_with_refind(:group) { create(:group, :private) }
let_it_be(:epic_board1) { create(:epic_board, name: 'fooB', group: group) }
let_it_be(:epic_board2) { create(:epic_board, name: 'fooA', group: group) }
specify do
expect(described_class).to have_nullable_graphql_type(Types::Boards::EpicBoardType.connection_type)
end
describe '#resolve' do
subject(:result) { resolve(described_class, ctx: { current_user: user }, obj: group) }
context 'when epics are not available' do
before do
stub_licensed_features(epics: false)
end
it 'returns nil' do
expect(result).to be_nil
end
end
context 'when epics are available' do
before do
stub_licensed_features(epics: true)
end
it 'raises an error if user cannot read epic boards' do
expect { result}.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
context 'when user is member of the group' do
before do
group.add_reporter(user)
end
it 'returns epic boards in the group ordered by name' do
expect(result).to match_array([epic_board2, epic_board1])
end
context 'when epic_boards flag is disabled' do
before do
stub_feature_flags(epic_boards: false)
end
it 'returns nil' do
expect(result).to be_nil
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['EpicBoard'] do
specify { expect(described_class.graphql_name).to eq('EpicBoard') }
specify { expect(described_class).to require_graphql_authorizations(:read_epic_board) }
it 'has specific fields' do
expected_fields = %w[id name]
expect(described_class).to include_graphql_fields(*expected_fields)
end
end
......@@ -12,4 +12,14 @@ RSpec.describe Boards::EpicBoard do
describe 'validations' do
it { is_expected.to validate_length_of(:name).is_at_most(255) }
end
describe '.order_by_name_asc' do
let_it_be(:board1) { create(:epic_board, name: 'B') }
let_it_be(:board2) { create(:epic_board, name: 'a') }
let_it_be(:board3) { create(:epic_board, name: 'A') }
it 'returns in case-insensitive alphabetical order and then by ascending ID' do
expect(described_class.order_by_name_asc).to eq [board2, board3, board1]
end
end
end
......@@ -5,10 +5,12 @@ require 'spec_helper'
RSpec.describe GroupPolicy do
include_context 'GroupPolicy context'
let(:epic_rules) { %i(read_epic create_epic admin_epic destroy_epic read_confidential_epic destroy_epic_link read_epic_board) }
context 'when epics feature is disabled' do
let(:current_user) { owner }
it { is_expected.to be_disallowed(:read_epic, :create_epic, :admin_epic, :destroy_epic, :read_confidential_epic, :destroy_epic_link) }
it { is_expected.to be_disallowed(*epic_rules) }
end
context 'when epics feature is enabled' do
......@@ -19,53 +21,53 @@ RSpec.describe GroupPolicy do
context 'when user is owner' do
let(:current_user) { owner }
it { is_expected.to be_allowed(:read_epic, :create_epic, :admin_epic, :destroy_epic, :read_confidential_epic, :destroy_epic_link) }
it { is_expected.to be_allowed(*epic_rules) }
end
context 'when user is admin' do
let(:current_user) { admin }
it { is_expected.to be_allowed(:read_epic, :create_epic, :admin_epic, :destroy_epic, :read_confidential_epic, :destroy_epic_link) }
it { is_expected.to be_allowed(*epic_rules) }
end
context 'when user is maintainer' do
let(:current_user) { maintainer }
it { is_expected.to be_allowed(:read_epic, :create_epic, :admin_epic, :read_confidential_epic, :destroy_epic_link) }
it { is_expected.to be_allowed(*(epic_rules - [:destroy_epic])) }
it { is_expected.to be_disallowed(:destroy_epic) }
end
context 'when user is developer' do
let(:current_user) { developer }
it { is_expected.to be_allowed(:read_epic, :create_epic, :admin_epic, :read_confidential_epic, :destroy_epic_link) }
it { is_expected.to be_allowed(*(epic_rules - [:destroy_epic])) }
it { is_expected.to be_disallowed(:destroy_epic) }
end
context 'when user is reporter' do
let(:current_user) { reporter }
it { is_expected.to be_allowed(:read_epic, :create_epic, :admin_epic, :read_confidential_epic, :destroy_epic_link) }
it { is_expected.to be_allowed(*(epic_rules - [:destroy_epic])) }
it { is_expected.to be_disallowed(:destroy_epic) }
end
context 'when user is guest' do
let(:current_user) { guest }
it { is_expected.to be_allowed(:read_epic) }
it { is_expected.to be_disallowed(:create_epic, :admin_epic, :destroy_epic, :read_confidential_epic, :destroy_epic_link) }
it { is_expected.to be_allowed(:read_epic, :read_epic_board) }
it { is_expected.to be_disallowed(*(epic_rules - [:read_epic, :read_epic_board])) }
end
context 'when user is not member' do
let(:current_user) { create(:user) }
it { is_expected.to be_disallowed(:read_epic, :create_epic, :admin_epic, :destroy_epic, :read_confidential_epic, :destroy_epic_link) }
it { is_expected.to be_disallowed(*epic_rules) }
end
context 'when user is anonymous' do
let(:current_user) { nil }
it { is_expected.to be_disallowed(:read_epic, :create_epic, :admin_epic, :destroy_epic, :read_confidential_epic, :destroy_epic_link) }
it { is_expected.to be_disallowed(*epic_rules) }
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'get list of epic boards' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:group) { create(:group, :private) }
let_it_be(:board1) { create(:epic_board, group: group, name: 'B') }
let_it_be(:board2) { create(:epic_board, group: group, name: 'A') }
let_it_be(:board3) { create(:epic_board, group: group, name: 'a') }
def pagination_query(params = {})
graphql_query_for(:group, { full_path: group.full_path },
query_nodes(:epicBoards, all_graphql_fields_for('epic_boards'.classify), include_pagination_info: true, args: params)
)
end
before do
stub_licensed_features(epics: true)
end
context 'when the user does not have access to the epic board group' do
it 'returns nil group' do
post_graphql(pagination_query, current_user: current_user)
expect(graphql_data['group']).to be_nil
end
end
context 'when user can access the epic board group' do
before do
group.add_developer(current_user)
end
describe 'sorting and pagination' do
let(:data_path) { [:group, :epicBoards] }
let(:expected_results) { [board2.to_global_id.to_s, board3.to_global_id.to_s, board1.to_global_id.to_s] }
def pagination_results_data(nodes)
nodes.map { |board| board['id'] }
end
it_behaves_like 'sorted paginated query' do
# currently we don't support custom sorting for epic boards,
# nil value will be ignored by ::Graphql::Arguments
let(:sort_param) { nil }
let(:first_param) { 2 }
end
end
context 'when epic_boards flag is disabled' do
before do
stub_feature_flags(epic_boards: false)
end
it 'returns nil epic_boards' do
post_graphql(pagination_query, current_user: current_user)
boards = graphql_data.dig('group', 'epicBoards')
expect(boards).to be_nil
end
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