diff --git a/app/services/boards/create_service.rb b/app/services/boards/create_service.rb index 2ccaea64d14acf462d0d38d6388871584e0c27a9..54dab581686642698959b332f7bdc81129b2406d 100644 --- a/app/services/boards/create_service.rb +++ b/app/services/boards/create_service.rb @@ -13,11 +13,11 @@ def execute private def can_create_board? - parent.boards.empty? || parent.multiple_issue_boards_available? + parent_board_collection.empty? || parent.multiple_issue_boards_available? end def create_board! - board = parent.boards.create(params) + board = parent_board_collection.create(params) unless board.persisted? return ServiceResponse.error(message: "There was an error when creating a board.", payload: board) @@ -30,6 +30,10 @@ def create_board! ServiceResponse.success(payload: board) end + + def parent_board_collection + parent.boards + end end end diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 23673071eaa6bcf6adf031e7121abf958fe0bdc2..bf7966d2a6796dac96083f48ed2920843ddc9aa6 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -8967,6 +8967,56 @@ type EpicBoardConnection { pageInfo: PageInfo! } +""" +Autogenerated input type of EpicBoardCreate +""" +input EpicBoardCreateInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Full path of the group with which the resource is associated. + """ + groupPath: ID + + """ + Whether or not backlog list is hidden. + """ + hideBacklogList: Boolean + + """ + Whether or not closed list is hidden. + """ + hideClosedList: Boolean + + """ + The board name. + """ + name: String +} + +""" +Autogenerated return type of EpicBoardCreate +""" +type EpicBoardCreatePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The created epic board. + """ + epicBoard: EpicBoard + + """ + Errors encountered during execution of the mutation. + """ + errors: [String!]! +} + """ An edge in a connection. """ @@ -16051,6 +16101,7 @@ type Mutation { dismissVulnerability(input: DismissVulnerabilityInput!): DismissVulnerabilityPayload @deprecated(reason: "Use vulnerabilityDismiss. Deprecated in 13.5.") environmentsCanaryIngressUpdate(input: EnvironmentsCanaryIngressUpdateInput!): EnvironmentsCanaryIngressUpdatePayload epicAddIssue(input: EpicAddIssueInput!): EpicAddIssuePayload + epicBoardCreate(input: EpicBoardCreateInput!): EpicBoardCreatePayload epicSetSubscription(input: EpicSetSubscriptionInput!): EpicSetSubscriptionPayload epicTreeReorder(input: EpicTreeReorderInput!): EpicTreeReorderPayload exportRequirements(input: ExportRequirementsInput!): ExportRequirementsPayload diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 720b8ee53adf46a808883bda617d00ea966b82e5..aa4dd9d1133d7900e1a25fbcba2415c83b98e439 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -24811,6 +24811,134 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "EpicBoardCreateInput", + "description": "Autogenerated input type of EpicBoardCreate", + "fields": null, + "inputFields": [ + { + "name": "name", + "description": "The board name.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "hideBacklogList", + "description": "Whether or not backlog list is hidden.", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "hideClosedList", + "description": "Whether or not closed list is hidden.", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "groupPath", + "description": "Full path of the group with which the resource is associated.", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "EpicBoardCreatePayload", + "description": "Autogenerated return type of EpicBoardCreate", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "epicBoard", + "description": "The created epic board.", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "EpicBoard", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Errors encountered during execution of the mutation.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "EpicBoardEdge", @@ -45574,6 +45702,33 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "epicBoardCreate", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "EpicBoardCreateInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "EpicBoardCreatePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "epicSetSubscription", "description": null, diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 317d94ec28f5bca6408770f30cc948bc699bf478..1b6be9237928c5223a4b520541b58a7dd31e5307 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -1448,6 +1448,16 @@ Represents an epic board. | `lists` | EpicListConnection | Epic board lists. | | `name` | String | Name of the board. | +### EpicBoardCreatePayload + +Autogenerated return type of EpicBoardCreate. + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `epicBoard` | EpicBoard | The created epic board. | +| `errors` | String! => Array | Errors encountered during execution of the mutation. | + ### EpicDescendantCount Counts of descendent epics. diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb index 49f1ce12e8a3c88ba189f6d08fd88759184d9fc1..f06f2d4b541e1ae543eff6b2545916ff0ed6601f 100644 --- a/ee/app/graphql/ee/types/mutation_type.rb +++ b/ee/app/graphql/ee/types/mutation_type.rb @@ -35,8 +35,9 @@ module MutationType mount_mutation ::Mutations::Vulnerabilities::CreateExternalIssueLink mount_mutation ::Mutations::Vulnerabilities::DestroyExternalIssueLink mount_mutation ::Mutations::Boards::Update - mount_mutation ::Mutations::Boards::Lists::UpdateLimitMetrics mount_mutation ::Mutations::Boards::UpdateEpicUserPreferences + mount_mutation ::Mutations::Boards::EpicBoards::Create + mount_mutation ::Mutations::Boards::Lists::UpdateLimitMetrics mount_mutation ::Mutations::InstanceSecurityDashboard::AddProject mount_mutation ::Mutations::InstanceSecurityDashboard::RemoveProject mount_mutation ::Mutations::DastOnDemandScans::Create diff --git a/ee/app/graphql/mutations/boards/epic_boards/create.rb b/ee/app/graphql/mutations/boards/epic_boards/create.rb new file mode 100644 index 0000000000000000000000000000000000000000..0a7ba86b49979856d0812c6bd64e7959ed1583c2 --- /dev/null +++ b/ee/app/graphql/mutations/boards/epic_boards/create.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Mutations + module Boards + module EpicBoards + class Create < ::Mutations::BaseMutation + include Mutations::ResolvesGroup + include Mutations::Boards::CommonMutationArguments + + graphql_name 'EpicBoardCreate' + + authorize :admin_epic_board + + argument :group_path, GraphQL::ID_TYPE, + required: false, + description: 'Full path of the group with which the resource is associated.' + + field :epic_board, + Types::Boards::EpicBoardType, + null: true, + description: 'The created epic board.' + + def resolve(args) + group_path = args.delete(:group_path) + + group = authorized_find!(group_path: group_path) + service_response = ::Boards::EpicBoards::CreateService.new(group, current_user, args).execute + + { + epic_board: service_response.payload, + errors: service_response.errors + } + end + + private + + def find_object(group_path:) + resolve_group(full_path: group_path) + end + end + end + end +end diff --git a/ee/app/models/boards/epic_board.rb b/ee/app/models/boards/epic_board.rb index 5aedadb7a152668cfd01911855dbb1bdc3086049..a592a4a29ee99725deb5fa4efa2ef71fbf8a2dc9 100644 --- a/ee/app/models/boards/epic_board.rb +++ b/ee/app/models/boards/epic_board.rb @@ -7,7 +7,7 @@ class EpicBoard < ApplicationRecord has_many :epic_board_positions, foreign_key: :epic_board_id, inverse_of: :epic_board has_many :epic_lists, -> { ordered }, foreign_key: :epic_board_id, inverse_of: :epic_board - validates :name, length: { maximum: 255 } + validates :name, length: { maximum: 255 }, presence: true scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc).order(id: :asc) } diff --git a/ee/app/policies/ee/group_policy.rb b/ee/app/policies/ee/group_policy.rb index 2c9dde505d0e3c6618000da8bb8bcf9191f5c229..89ca8b84d86f8eb4083f927a05086c1f1bbf6bcd 100644 --- a/ee/app/policies/ee/group_policy.rb +++ b/ee/app/policies/ee/group_policy.rb @@ -197,6 +197,7 @@ module GroupPolicy enable :update_epic enable :read_confidential_epic enable :destroy_epic_link + enable :admin_epic_board end rule { reporter & subepics_available }.policy do diff --git a/ee/app/services/boards/epic_boards/create_service.rb b/ee/app/services/boards/epic_boards/create_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..07fcae6dc0eb4be08d68318c3d6ff998a52126b1 --- /dev/null +++ b/ee/app/services/boards/epic_boards/create_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Boards + module EpicBoards + class CreateService < Boards::CreateService + extend ::Gitlab::Utils::Override + + override :can_create_board? + def can_create_board? + Feature.enabled?(:epic_boards, parent) + end + + override :parent_board_collection + def parent_board_collection + parent.epic_boards + end + end + end +end diff --git a/ee/changelogs/unreleased/233434-cablett-create_epic_board.yml b/ee/changelogs/unreleased/233434-cablett-create_epic_board.yml new file mode 100644 index 0000000000000000000000000000000000000000..a059e0b9a11b90d3efe9516c57b269257e2b0b8a --- /dev/null +++ b/ee/changelogs/unreleased/233434-cablett-create_epic_board.yml @@ -0,0 +1,5 @@ +--- +title: Add create epic board via GraphQL +merge_request: 52258 +author: +type: added diff --git a/ee/spec/graphql/mutations/boards/epic_boards/create_spec.rb b/ee/spec/graphql/mutations/boards/epic_boards/create_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..26f234bf294c386f4bb89b50d14d4bba20738500 --- /dev/null +++ b/ee/spec/graphql/mutations/boards/epic_boards/create_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Mutations::Boards::EpicBoards::Create do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:group) { create(:group, :private) } + + let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) } + let(:name) { 'A glorious epic board' } + + subject { mutation.resolve(group_path: group.full_path, name: name) } + + shared_examples 'epic board creation error' do + it 'raises error' do + expect { mutation.resolve(group_path: group.full_path, name: name) } + .to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'field tests' do + subject { described_class } + + it { is_expected.to have_graphql_arguments(:groupPath, :name, :hideBacklogList, :hideClosedList) } + it { is_expected.to have_graphql_fields(:epic_board).at_least } + end + + context 'with epic feature enabled and epic_boards feature flag enabled' do + before do + stub_licensed_features(epics: true) + stub_feature_flags(epic_boards: true) + end + + context 'when user does not have permission to create epic board' do + it_behaves_like 'epic board creation error' + end + + context 'when user has permission to create epic board' do + before do + group.add_reporter(current_user) + end + + it 'creates an epic board' do + result = mutation.resolve(group_path: group.full_path, name: name) + + expect(result[:epic_board]).to be_valid + expect(result[:epic_board].group).to eq(group) + expect(result[:epic_board].name).to eq(name) + end + end + end + + context 'with epic_boards feature flag disabled' do + before do + stub_feature_flags(epic_boards: false) + end + + it_behaves_like 'epic board creation error' + end + + context 'with epic feature disabled' do + before do + stub_licensed_features(epics: false) + end + + it_behaves_like 'epic board creation error' + end +end diff --git a/ee/spec/policies/group_policy_spec.rb b/ee/spec/policies/group_policy_spec.rb index 5033519b59ee7dfd1676db6ee6cfd0f22f9ec87a..0b7ebb7635facfeba391f233c374fbd29b1a3bad 100644 --- a/ee/spec/policies/group_policy_spec.rb +++ b/ee/spec/policies/group_policy_spec.rb @@ -7,7 +7,7 @@ let(:epic_rules) do %i(read_epic create_epic admin_epic destroy_epic read_confidential_epic - destroy_epic_link read_epic_board read_epic_list) + destroy_epic_link read_epic_board read_epic_list admin_epic_board) end context 'when epics feature is disabled' do diff --git a/ee/spec/services/boards/create_service_spec.rb b/ee/spec/services/boards/create_service_spec.rb index a3e424abc9e82a622c06e0f80df5a9afb028a087..d80ddeb8c59a4c9f2d08053b5ad0d33b8cc8da5f 100644 --- a/ee/spec/services/boards/create_service_spec.rb +++ b/ee/spec/services/boards/create_service_spec.rb @@ -13,60 +13,7 @@ def created_board stub_licensed_features(multiple_group_issue_boards: true) end - context 'with valid params' do - subject(:service) { described_class.new(parent, double, name: 'Backend') } - - it 'creates a new board' do - expect { service.execute }.to change(parent.boards, :count).by(1) - end - - it 'returns a successful response' do - expect(service.execute).to be_success - end - - it 'creates the default lists' do - board = created_board - - expect(board.lists.size).to eq 2 - expect(board.lists.first).to be_backlog - expect(board.lists.last).to be_closed - end - end - - context 'with invalid params' do - subject(:service) { described_class.new(parent, double, name: nil) } - - it 'does not create a new parent board' do - expect { service.execute }.not_to change(parent.boards, :count) - end - - it 'returns an error response' do - expect(service.execute).to be_error - end - - it "does not create board's default lists" do - expect(created_board.lists.size).to eq 0 - end - end - - context 'without params' do - subject(:service) { described_class.new(parent, double) } - - it 'creates a new parent board' do - expect { service.execute }.to change(parent.boards, :count).by(1) - end - - it 'returns a successful response' do - expect(service.execute).to be_success - end - - it "creates board's default lists" do - board = created_board - - expect(board.lists.size).to eq 2 - expect(board.lists.last).to be_closed - end - end + it_behaves_like 'create a board', :boards end end diff --git a/ee/spec/services/boards/epic_boards/create_service_spec.rb b/ee/spec/services/boards/epic_boards/create_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0820784a2346eae8b02bbdc9f6aa355cd35f07e8 --- /dev/null +++ b/ee/spec/services/boards/epic_boards/create_service_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Boards::EpicBoards::CreateService, services: true do + def created_board + service.execute.payload + end + + let(:parent) { create(:group) } + let(:epic_boards_enabled) { false } + + before do + stub_feature_flags(epic_boards: epic_boards_enabled) + end + + context 'with epic boards feature not available' do + it 'does not create a board' do + service = described_class.new(parent, double) + + expect(service.execute.payload).not_to be_nil + expect { service.execute }.not_to change(parent.epic_boards, :count) + end + end + + context 'with epic boards feature available' do + let(:epic_boards_enabled) { true } + + it_behaves_like 'create a board', :epic_boards + end +end diff --git a/ee/spec/support/shared_examples/services/boards/create_boards_shared_examples.rb b/ee/spec/support/shared_examples/services/boards/create_boards_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..ab2536f65bcf564ea20386c5845d732697d3587d --- /dev/null +++ b/ee/spec/support/shared_examples/services/boards/create_boards_shared_examples.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'create a board' do |scope| + context 'with valid params' do + subject(:service) { described_class.new(parent, double, name: 'Backend') } + + it 'creates a new board' do + expect { service.execute }.to change(parent.send(scope), :count).by(1) + end + + it 'returns a successful response' do + expect(service.execute).to be_success + end + + it 'creates the default lists' do + board = created_board + + expect(board.lists.size).to eq 2 + expect(board.lists.first).to be_backlog + expect(board.lists.last).to be_closed + end + end + + context 'with invalid params' do + subject(:service) { described_class.new(parent, double, name: nil) } + + it 'does not create a new parent board' do + expect { service.execute }.not_to change(parent.send(scope), :count) + end + + it 'returns an error response' do + expect(service.execute).to be_error + end + + it "does not create board's default lists" do + expect(created_board.lists.size).to eq 0 + end + end + + context 'without params' do + subject(:service) { described_class.new(parent, double) } + + it 'creates a new parent board' do + expect { service.execute }.to change(parent.send(scope), :count).by(1) + end + + it 'returns a successful response' do + expect(service.execute).to be_success + end + + it "creates board's default lists" do + board = created_board + + expect(board.lists.size).to eq 2 + expect(board.lists.last).to be_closed + end + end +end