Commit 9ce3982e authored by Etienne Baqué's avatar Etienne Baqué

Merge branch '346041-create-work-item-mutation' into 'master'

Adds workItemCreate mutation to the GraphQL API

See merge request gitlab-org/gitlab!76401
parents 8997b8ab 74f70bd8
...@@ -369,6 +369,7 @@ Gitlab/NamespacedClass: ...@@ -369,6 +369,7 @@ Gitlab/NamespacedClass:
- app/models/wiki_page.rb - app/models/wiki_page.rb
- app/models/wiki_page/meta.rb - app/models/wiki_page/meta.rb
- app/models/wiki_page/slug.rb - app/models/wiki_page/slug.rb
- app/models/work_item.rb
- app/models/x509_certificate.rb - app/models/x509_certificate.rb
- app/models/x509_commit_signature.rb - app/models/x509_commit_signature.rb
- app/models/x509_issuer.rb - app/models/x509_issuer.rb
......
...@@ -291,10 +291,12 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -291,10 +291,12 @@ class Projects::IssuesController < Projects::ApplicationController
end end
def issue_params def issue_params
params.require(:issue).permit( all_params = params.require(:issue).permit(
*issue_params_attributes, *issue_params_attributes,
sentry_issue_attributes: [:sentry_issue_identifier] sentry_issue_attributes: [:sentry_issue_identifier]
) )
clean_params(all_params)
end end
def issue_params_attributes def issue_params_attributes
...@@ -348,6 +350,13 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -348,6 +350,13 @@ class Projects::IssuesController < Projects::ApplicationController
private private
def clean_params(all_params)
issue_type = all_params[:issue_type].to_s
all_params.delete(:issue_type) unless WorkItems::Type.allowed_types_for_issues.include?(issue_type)
all_params
end
def finder_options def finder_options
options = super options = super
......
# frozen_string_literal: true
module Mutations
module WorkItems
class Create < BaseMutation
include Mutations::SpamProtection
include FindsProject
graphql_name 'WorkItemCreate'
authorize :create_work_item
argument :description, GraphQL::Types::String,
required: false,
description: copy_field_description(Types::WorkItemType, :description)
argument :project_path, GraphQL::Types::ID,
required: true,
description: 'Full path of the project the work item is associated with.'
argument :title, GraphQL::Types::String,
required: true,
description: copy_field_description(Types::WorkItemType, :title)
argument :work_item_type_id, ::Types::GlobalIDType[::WorkItems::Type],
required: true,
description: 'Global ID of a work item type.'
field :work_item, Types::WorkItemType,
null: true,
description: 'Created work item.'
def resolve(project_path:, **attributes)
project = authorized_find!(project_path)
params = global_id_compatibility_params(attributes).merge(author_id: current_user.id)
spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
work_item = ::WorkItems::CreateService.new(project: project, current_user: current_user, params: params, spam_params: spam_params).execute
check_spam_action_response!(work_item)
{
work_item: work_item.valid? ? work_item : nil,
errors: errors_on_object(work_item)
}
end
private
def global_id_compatibility_params(params)
# TODO: remove this line when the compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
params[:work_item_type_id] = ::Types::GlobalIDType[::WorkItems::Type].coerce_isolated_input(params[:work_item_type_id]) if params[:work_item_type_id]
params[:work_item_type_id] = params[:work_item_type_id]&.model_id
params
end
end
end
end
...@@ -123,6 +123,7 @@ module Types ...@@ -123,6 +123,7 @@ module Types
mount_mutation Mutations::Packages::Destroy mount_mutation Mutations::Packages::Destroy
mount_mutation Mutations::Packages::DestroyFile mount_mutation Mutations::Packages::DestroyFile
mount_mutation Mutations::Echo mount_mutation Mutations::Echo
mount_mutation Mutations::WorkItems::Create, feature_flag: :work_items
end end
end end
......
# frozen_string_literal: true
module Types
class WorkItemType < BaseObject
graphql_name 'WorkItem'
authorize :read_issue
field :description, GraphQL::Types::String, null: true,
description: 'Description of the work item.'
field :id, Types::GlobalIDType[::WorkItem], null: false,
description: 'Global ID of the work item.'
field :iid, GraphQL::Types::ID, null: false,
description: 'Internal ID of the work item.'
field :title, GraphQL::Types::String, null: false,
description: 'Title of the work item.'
field :work_item_type, Types::WorkItems::TypeType, null: false,
description: 'Type assigned to the work item.'
markdown_field :title_html, null: true
markdown_field :description_html, null: true
end
end
...@@ -603,6 +603,11 @@ class Issue < ApplicationRecord ...@@ -603,6 +603,11 @@ class Issue < ApplicationRecord
author&.banned? author&.banned?
end end
# Necessary until all issues are backfilled and we add a NOT NULL constraint on the DB
def work_item_type
super || WorkItems::Type.default_by_type(issue_type)
end
private private
def spammable_attribute_changed? def spammable_attribute_changed?
......
# frozen_string_literal: true
class WorkItem < Issue
self.table_name = 'issues'
self.inheritance_column = :_type_disabled
end
...@@ -258,6 +258,11 @@ class ProjectPolicy < BasePolicy ...@@ -258,6 +258,11 @@ class ProjectPolicy < BasePolicy
rule { can?(:reporter_access) & can?(:create_issue) }.enable :create_incident rule { can?(:reporter_access) & can?(:create_issue) }.enable :create_incident
rule { can?(:guest_access) & can?(:create_issue) }.policy do
enable :create_task
enable :create_work_item
end
# These abilities are not allowed to admins that are not members of the project, # These abilities are not allowed to admins that are not members of the project,
# that's why they are defined separately. # that's why they are defined separately.
rule { guest & can?(:download_code) }.enable :build_download_code rule { guest & can?(:download_code) }.enable :build_download_code
......
...@@ -7,7 +7,7 @@ module Issues ...@@ -7,7 +7,7 @@ module Issues
def execute def execute
filter_resolve_discussion_params filter_resolve_discussion_params
@issue = project.issues.new(issue_params).tap do |issue| @issue = model_klass.new(issue_params.merge(project: project)).tap do |issue|
ensure_milestone_available(issue) ensure_milestone_available(issue)
end end
end end
...@@ -62,16 +62,25 @@ module Issues ...@@ -62,16 +62,25 @@ module Issues
def issue_params def issue_params
@issue_params ||= build_issue_params @issue_params ||= build_issue_params
# If :issue_type is nil then params[:issue_type] was either nil if @issue_params[:work_item_type].present?
# or not permitted. Either way, the :issue_type will default @issue_params[:issue_type] = @issue_params[:work_item_type].base_type
# to the column default of `issue`. And that means we need to else
# ensure the work_item_type_id is set # If :issue_type is nil then params[:issue_type] was either nil
@issue_params[:work_item_type_id] = get_work_item_type_id(@issue_params[:issue_type]) # or not permitted. Either way, the :issue_type will default
# to the column default of `issue`. And that means we need to
# ensure the work_item_type_id is set
@issue_params[:work_item_type_id] = get_work_item_type_id(@issue_params[:issue_type])
end
@issue_params @issue_params
end end
private private
def model_klass
::Issue
end
def allowed_issue_params def allowed_issue_params
allowed_params = [ allowed_params = [
:title, :title,
...@@ -79,8 +88,11 @@ module Issues ...@@ -79,8 +88,11 @@ module Issues
:confidential :confidential
] ]
params[:work_item_type] = WorkItems::Type.find_by(id: params[:work_item_type_id]) if params[:work_item_type_id].present? # rubocop: disable CodeReuse/ActiveRecord
allowed_params << :milestone_id if can?(current_user, :admin_issue, project) allowed_params << :milestone_id if can?(current_user, :admin_issue, project)
allowed_params << :issue_type if create_issue_type_allowed?(project, params[:issue_type]) allowed_params << :issue_type if create_issue_type_allowed?(project, params[:issue_type])
allowed_params << :work_item_type if create_issue_type_allowed?(project, params[:work_item_type]&.base_type)
params.slice(*allowed_params) params.slice(*allowed_params)
end end
......
...@@ -12,13 +12,14 @@ module Issues ...@@ -12,13 +12,14 @@ module Issues
# spam_checking is likely to be necessary. However, if there is not a request available in scope # spam_checking is likely to be necessary. However, if there is not a request available in scope
# in the caller (for example, an issue created via email) and the required arguments to the # in the caller (for example, an issue created via email) and the required arguments to the
# SpamParams constructor are not otherwise available, spam_params: must be explicitly passed as nil. # SpamParams constructor are not otherwise available, spam_params: must be explicitly passed as nil.
def initialize(project:, current_user: nil, params: {}, spam_params:) def initialize(project:, current_user: nil, params: {}, spam_params:, build_service: nil)
super(project: project, current_user: current_user, params: params) super(project: project, current_user: current_user, params: params)
@spam_params = spam_params @spam_params = spam_params
@build_service = build_service || BuildService.new(project: project, current_user: current_user, params: params)
end end
def execute(skip_system_notes: false) def execute(skip_system_notes: false)
@issue = BuildService.new(project: project, current_user: current_user, params: params).execute @issue = @build_service.execute
filter_resolve_discussion_params filter_resolve_discussion_params
......
# frozen_string_literal: true
module WorkItems
class BuildService < ::Issues::BuildService
private
def model_klass
::WorkItem
end
end
end
# frozen_string_literal: true
module WorkItems
class CreateService
def initialize(project:, current_user: nil, params: {}, spam_params:)
@create_service = ::Issues::CreateService.new(
project: project,
current_user: current_user,
params: params,
spam_params: spam_params,
build_service: ::WorkItems::BuildService.new(project: project, current_user: current_user, params: params)
)
end
def execute
@create_service.execute
end
end
end
...@@ -4951,6 +4951,30 @@ Input type: `VulnerabilityRevertToDetectedInput` ...@@ -4951,6 +4951,30 @@ Input type: `VulnerabilityRevertToDetectedInput`
| <a id="mutationvulnerabilityreverttodetectederrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | <a id="mutationvulnerabilityreverttodetectederrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationvulnerabilityreverttodetectedvulnerability"></a>`vulnerability` | [`Vulnerability`](#vulnerability) | Vulnerability after revert. | | <a id="mutationvulnerabilityreverttodetectedvulnerability"></a>`vulnerability` | [`Vulnerability`](#vulnerability) | Vulnerability after revert. |
### `Mutation.workItemCreate`
Available only when feature flag `work_items` is enabled. This flag is disabled by default, because the feature is experimental and is subject to change without notice.
Input type: `WorkItemCreateInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationworkitemcreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationworkitemcreatedescription"></a>`description` | [`String`](#string) | Description of the work item. |
| <a id="mutationworkitemcreateprojectpath"></a>`projectPath` | [`ID!`](#id) | Full path of the project the work item is associated with. |
| <a id="mutationworkitemcreatetitle"></a>`title` | [`String!`](#string) | Title of the work item. |
| <a id="mutationworkitemcreateworkitemtypeid"></a>`workItemTypeId` | [`WorkItemsTypeID!`](#workitemstypeid) | Global ID of a work item type. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationworkitemcreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationworkitemcreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationworkitemcreateworkitem"></a>`workItem` | [`WorkItem`](#workitem) | Created work item. |
## Connections ## Connections
Some types in our schema are `Connection` types - they represent a paginated Some types in our schema are `Connection` types - they represent a paginated
...@@ -16019,6 +16043,20 @@ Represents vulnerability letter grades with associated projects. ...@@ -16019,6 +16043,20 @@ Represents vulnerability letter grades with associated projects.
| <a id="vulnerableprojectsbygradegrade"></a>`grade` | [`VulnerabilityGrade!`](#vulnerabilitygrade) | Grade based on the highest severity vulnerability present. | | <a id="vulnerableprojectsbygradegrade"></a>`grade` | [`VulnerabilityGrade!`](#vulnerabilitygrade) | Grade based on the highest severity vulnerability present. |
| <a id="vulnerableprojectsbygradeprojects"></a>`projects` | [`ProjectConnection!`](#projectconnection) | Projects within this grade. (see [Connections](#connections)) | | <a id="vulnerableprojectsbygradeprojects"></a>`projects` | [`ProjectConnection!`](#projectconnection) | Projects within this grade. (see [Connections](#connections)) |
### `WorkItem`
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="workitemdescription"></a>`description` | [`String`](#string) | Description of the work item. |
| <a id="workitemdescriptionhtml"></a>`descriptionHtml` | [`String`](#string) | The GitLab Flavored Markdown rendering of `description`. |
| <a id="workitemid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. |
| <a id="workitemiid"></a>`iid` | [`ID!`](#id) | Internal ID of the work item. |
| <a id="workitemtitle"></a>`title` | [`String!`](#string) | Title of the work item. |
| <a id="workitemtitlehtml"></a>`titleHtml` | [`String`](#string) | The GitLab Flavored Markdown rendering of `title`. |
| <a id="workitemworkitemtype"></a>`workItemType` | [`WorkItemType!`](#workitemtype) | Type assigned to the work item. |
### `WorkItemType` ### `WorkItemType`
#### Fields #### Fields
...@@ -18121,6 +18159,12 @@ A `VulnerabilityID` is a global ID. It is encoded as a string. ...@@ -18121,6 +18159,12 @@ A `VulnerabilityID` is a global ID. It is encoded as a string.
An example `VulnerabilityID` is: `"gid://gitlab/Vulnerability/1"`. An example `VulnerabilityID` is: `"gid://gitlab/Vulnerability/1"`.
### `WorkItemID`
A `WorkItemID` is a global ID. It is encoded as a string.
An example `WorkItemID` is: `"gid://gitlab/WorkItem/1"`.
### `WorkItemsTypeID` ### `WorkItemsTypeID`
A `WorkItemsTypeID` is a global ID. It is encoded as a string. A `WorkItemsTypeID` is a global ID. It is encoded as a string.
...@@ -238,6 +238,17 @@ RSpec.describe Issue do ...@@ -238,6 +238,17 @@ RSpec.describe Issue do
end end
end end
# TODO: Remove when NOT NULL constraint is added to the relationship
describe '#work_item_type' do
let(:issue) { create(:issue, :incident, project: reusable_project, work_item_type: nil) }
it 'returns a default type if the legacy issue does not have a work item type associated yet' do
expect(issue.work_item_type_id).to be_nil
expect(issue.issue_type).to eq('incident')
expect(issue.work_item_type).to eq(WorkItems::Type.default_by_type(:incident))
end
end
describe '#sort' do describe '#sort' do
let(:project) { reusable_project } let(:project) { reusable_project }
......
...@@ -61,7 +61,7 @@ RSpec.describe ProjectPolicy do ...@@ -61,7 +61,7 @@ RSpec.describe ProjectPolicy do
end end
it 'does not include the issues permissions' do it 'does not include the issues permissions' do
expect_disallowed :read_issue, :read_issue_iid, :create_issue, :update_issue, :admin_issue, :create_incident expect_disallowed :read_issue, :read_issue_iid, :create_issue, :update_issue, :admin_issue, :create_incident, :create_work_item, :create_task
end end
it 'disables boards and lists permissions' do it 'disables boards and lists permissions' do
...@@ -73,7 +73,7 @@ RSpec.describe ProjectPolicy do ...@@ -73,7 +73,7 @@ RSpec.describe ProjectPolicy do
it 'does not include the issues permissions' do it 'does not include the issues permissions' do
create(:jira_integration, project: project) create(:jira_integration, project: project)
expect_disallowed :read_issue, :read_issue_iid, :create_issue, :update_issue, :admin_issue, :create_incident expect_disallowed :read_issue, :read_issue_iid, :create_issue, :update_issue, :admin_issue, :create_incident, :create_work_item, :create_task
end end
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Create a work item' do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } }
let(:input) do
{
'title' => 'new title',
'description' => 'new description',
'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_global_id.to_s
}
end
let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path)) }
let(:mutation_response) { graphql_mutation_response(:work_item_create) }
context 'the user is not allowed to create a work item' do
let(:current_user) { create(:user) }
it_behaves_like 'a mutation that returns a top-level access error'
end
context 'when user has permissions to create a work item' do
let(:current_user) { developer }
it 'creates the work item' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
end.to change(WorkItem, :count).by(1)
created_work_item = WorkItem.last
expect(response).to have_gitlab_http_status(:success)
expect(created_work_item.issue_type).to eq('task')
expect(created_work_item.work_item_type.base_type).to eq('task')
expect(mutation_response['workItem']).to include(
input.except('workItemTypeId').merge(
'id' => created_work_item.to_global_id.to_s,
'workItemType' => hash_including('name' => 'Task')
)
)
end
it_behaves_like 'has spam protection' do
let(:mutation_class) { ::Mutations::WorkItems::Create }
end
context 'when the work_items feature flag is disabled' do
before do
stub_feature_flags(work_items: false)
end
it_behaves_like 'a mutation that returns top-level errors',
errors: ["Field 'workItemCreate' doesn't exist on type 'Mutation'", "Variable $workItemCreateInput is declared by anonymous mutation but not used"]
end
end
end
...@@ -61,6 +61,7 @@ RSpec.describe Issues::CreateService do ...@@ -61,6 +61,7 @@ RSpec.describe Issues::CreateService do
expect(Issuable::CommonSystemNotesService).to receive_message_chain(:new, :execute) expect(Issuable::CommonSystemNotesService).to receive_message_chain(:new, :execute)
expect(issue).to be_persisted expect(issue).to be_persisted
expect(issue).to be_a(::Issue)
expect(issue.title).to eq('Awesome issue') expect(issue.title).to eq('Awesome issue')
expect(issue.assignees).to eq([assignee]) expect(issue.assignees).to eq([assignee])
expect(issue.labels).to match_array(labels) expect(issue.labels).to match_array(labels)
...@@ -69,6 +70,18 @@ RSpec.describe Issues::CreateService do ...@@ -69,6 +70,18 @@ RSpec.describe Issues::CreateService do
expect(issue.work_item_type.base_type).to eq('issue') expect(issue.work_item_type.base_type).to eq('issue')
end end
context 'when a build_service is provided' do
let(:issue) { described_class.new(project: project, current_user: user, params: opts, spam_params: spam_params, build_service: build_service).execute }
let(:issue_from_builder) { WorkItem.new(project: project, title: 'Issue from builder') }
let(:build_service) { double(:build_service, execute: issue_from_builder) }
it 'uses the provided service to build the issue' do
expect(issue).to be_persisted
expect(issue).to be_a(WorkItem)
end
end
context 'when skip_system_notes is true' do context 'when skip_system_notes is true' do
let(:issue) { described_class.new(project: project, current_user: user, params: opts, spam_params: spam_params).execute(skip_system_notes: true) } let(:issue) { described_class.new(project: project, current_user: user, params: opts, spam_params: spam_params).execute(skip_system_notes: true) }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe WorkItems::BuildService do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:guest) { create(:user) }
let(:user) { guest }
before_all do
project.add_guest(guest)
end
describe '#execute' do
subject { described_class.new(project: project, current_user: user, params: {}).execute }
it { is_expected.to be_a(::WorkItem) }
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe WorkItems::CreateService do
include AfterNextHelpers
let_it_be(:group) { create(:group) }
let_it_be_with_reload(:project) { create(:project, group: group) }
let_it_be(:user) { create(:user) }
let(:spam_params) { double }
describe '#execute' do
let(:work_item) { described_class.new(project: project, current_user: user, params: opts, spam_params: spam_params).execute }
before do
stub_spam_services
end
context 'when params are valid' do
before_all do
project.add_guest(user)
end
let(:opts) do
{
title: 'Awesome work_item',
description: 'please fix'
}
end
it 'created instance is a WorkItem' do
expect(Issuable::CommonSystemNotesService).to receive_message_chain(:new, :execute)
expect(work_item).to be_persisted
expect(work_item).to be_a(::WorkItem)
expect(work_item.title).to eq('Awesome work_item')
expect(work_item.description).to eq('please fix')
expect(work_item.work_item_type.base_type).to eq('issue')
end
end
context 'checking spam' do
let(:params) do
{
title: 'Spam work_item'
}
end
subject do
described_class.new(project: project, current_user: user, params: params, spam_params: spam_params)
end
it 'executes SpamActionService' do
expect_next_instance_of(
Spam::SpamActionService,
{
spammable: kind_of(WorkItem),
spam_params: spam_params,
user: an_instance_of(User),
action: :create
}
) do |instance|
expect(instance).to receive(:execute)
end
subject.execute
end
end
end
end
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
# There must be a method or let called `mutation` defined that executes # There must be a method or let called `mutation` defined that executes
# the mutation. # the mutation.
RSpec.shared_examples 'a mutation that returns top-level errors' do |errors: []| RSpec.shared_examples 'a mutation that returns top-level errors' do |errors: []|
let(:match_errors) { eq(errors) } let(:match_errors) { match_array(errors) }
it do it do
post_graphql_mutation(mutation, current_user: current_user) post_graphql_mutation(mutation, current_user: current_user)
......
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