Commit cf40a4a6 authored by Mario Celi's avatar Mario Celi

Add WorkItemUpdate mutation to GraphQL API

Initial version of WorkItemUpdate. Only supports
updating state_event by specifying GlobalID of the
work item
parent 053b7006
...@@ -428,6 +428,7 @@ Gitlab/NamespacedClass: ...@@ -428,6 +428,7 @@ Gitlab/NamespacedClass:
- app/policies/user_policy.rb - app/policies/user_policy.rb
- app/policies/wiki_page_policy.rb - app/policies/wiki_page_policy.rb
- app/policies/wiki_policy.rb - app/policies/wiki_policy.rb
- app/policies/work_item_policy.rb
- app/presenters/award_emoji_presenter.rb - app/presenters/award_emoji_presenter.rb
- app/presenters/blob_presenter.rb - app/presenters/blob_presenter.rb
- app/presenters/board_presenter.rb - app/presenters/board_presenter.rb
......
# frozen_string_literal: true
module Mutations
module WorkItems
class Update < BaseMutation
include Mutations::SpamProtection
description "Updates a work item by Global ID." \
" Available only when feature flag `work_items` is enabled. The feature is experimental and is subject to change without notice."
graphql_name 'WorkItemUpdate'
authorize :update_work_item
argument :id, ::Types::GlobalIDType[::WorkItem],
required: true,
description: 'Global ID of the work item.'
argument :state_event, Types::WorkItems::StateEventEnum,
description: 'Close or reopen a work item.',
required: false
field :work_item, Types::WorkItemType,
null: true,
description: 'Updated work item.'
def resolve(id:, **attributes)
work_item = authorized_find!(id: id)
unless Feature.enabled?(:work_items, work_item.project)
return { errors: ['`work_items` feature flag disabled for this project'] }
end
spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
::WorkItems::UpdateService.new(
project: work_item.project,
current_user: current_user,
params: attributes,
spam_params: spam_params
).execute(work_item)
check_spam_action_response!(work_item)
{
work_item: work_item.valid? ? work_item : nil,
errors: errors_on_object(work_item)
}
end
private
def find_object(id:)
# TODO: Remove coercion when working on https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = ::Types::GlobalIDType[::WorkItem].coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
end
end
end
...@@ -126,6 +126,7 @@ module Types ...@@ -126,6 +126,7 @@ module Types
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 mount_mutation Mutations::WorkItems::Create, feature_flag: :work_items
mount_mutation Mutations::WorkItems::Update
end end
end end
......
# frozen_string_literal: true
module Types
class WorkItemStateEnum < BaseEnum
graphql_name 'WorkItemState'
description 'State of a GitLab work item'
value 'OPEN', 'In open state.', value: 'opened'
value 'CLOSED', 'In closed state.', value: 'closed'
end
end
...@@ -12,6 +12,8 @@ module Types ...@@ -12,6 +12,8 @@ module Types
description: 'Global ID of the work item.' description: 'Global ID of the work item.'
field :iid, GraphQL::Types::ID, null: false, field :iid, GraphQL::Types::ID, null: false,
description: 'Internal ID of the work item.' description: 'Internal ID of the work item.'
field :state, WorkItemStateEnum, null: false,
description: 'State of the work item.'
field :title, GraphQL::Types::String, null: false, field :title, GraphQL::Types::String, null: false,
description: 'Title of the work item.' description: 'Title of the work item.'
field :work_item_type, Types::WorkItems::TypeType, null: false, field :work_item_type, Types::WorkItems::TypeType, null: false,
......
# frozen_string_literal: true
module Types
module WorkItems
class StateEventEnum < BaseEnum
graphql_name 'WorkItemStateEvent'
description 'Values for work item state events'
value 'REOPEN', 'Reopens the work item.', value: 'reopen'
value 'CLOSE', 'Closes the work item.', value: 'close'
end
end
end
...@@ -176,7 +176,7 @@ module Noteable ...@@ -176,7 +176,7 @@ module Noteable
Gitlab::Routing.url_helpers.project_noteable_notes_path( Gitlab::Routing.url_helpers.project_noteable_notes_path(
project, project,
target_type: self.class.name.underscore, target_type: noteable_target_type_name,
target_id: id target_id: id
) )
end end
...@@ -201,6 +201,10 @@ module Noteable ...@@ -201,6 +201,10 @@ module Noteable
project_email.sub('@', "-#{iid}@") project_email.sub('@', "-#{iid}@")
end end
def noteable_target_type_name
model_name.singular
end
private private
# Synthetic system notes don't have discussion IDs because these are generated dynamically # Synthetic system notes don't have discussion IDs because these are generated dynamically
......
...@@ -3,4 +3,8 @@ ...@@ -3,4 +3,8 @@
class WorkItem < Issue class WorkItem < Issue
self.table_name = 'issues' self.table_name = 'issues'
self.inheritance_column = :_type_disabled self.inheritance_column = :_type_disabled
def noteable_target_type_name
'issue'
end
end end
...@@ -258,11 +258,13 @@ class ProjectPolicy < BasePolicy ...@@ -258,11 +258,13 @@ 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 rule { can?(:create_issue) }.policy do
enable :create_task enable :create_task
enable :create_work_item enable :create_work_item
end end
rule { can?(:update_issue) }.enable :update_work_item
# 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
......
# frozen_string_literal: true
class WorkItemPolicy < BasePolicy
delegate { @subject.project }
end
...@@ -523,7 +523,7 @@ class IssuableBaseService < ::BaseProjectService ...@@ -523,7 +523,7 @@ class IssuableBaseService < ::BaseProjectService
def invalidate_cache_counts(issuable, users: []) def invalidate_cache_counts(issuable, users: [])
users.each do |user| users.each do |user|
user.public_send("invalidate_#{issuable.model_name.singular}_cache_counts") # rubocop:disable GitlabSecurity/PublicSend user.public_send("invalidate_#{issuable.noteable_target_type_name}_cache_counts") # rubocop:disable GitlabSecurity/PublicSend
end end
end end
......
...@@ -14,7 +14,7 @@ module ResourceEvents ...@@ -14,7 +14,7 @@ module ResourceEvents
ResourceStateEvent.create( ResourceStateEvent.create(
user: user, user: user,
resource.class.underscore => resource, resource.noteable_target_type_name => resource,
source_commit: commit_id_of(mentionable_source), source_commit: commit_id_of(mentionable_source),
source_merge_request_id: merge_request_id_of(mentionable_source), source_merge_request_id: merge_request_id_of(mentionable_source),
state: ResourceStateEvent.states[state], state: ResourceStateEvent.states[state],
......
# frozen_string_literal: true
module WorkItems
class UpdateService < ::Issues::UpdateService
end
end
...@@ -5099,6 +5099,28 @@ Input type: `WorkItemCreateInput` ...@@ -5099,6 +5099,28 @@ Input type: `WorkItemCreateInput`
| <a id="mutationworkitemcreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of 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. | | <a id="mutationworkitemcreateworkitem"></a>`workItem` | [`WorkItem`](#workitem) | Created work item. |
### `Mutation.workItemUpdate`
Updates a work item by Global ID. Available only when feature flag `work_items` is enabled. The feature is experimental and is subject to change without notice.
Input type: `WorkItemUpdateInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationworkitemupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationworkitemupdateid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. |
| <a id="mutationworkitemupdatestateevent"></a>`stateEvent` | [`WorkItemStateEvent`](#workitemstateevent) | Close or reopen a work item. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationworkitemupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationworkitemupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationworkitemupdateworkitem"></a>`workItem` | [`WorkItem`](#workitem) | Updated 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
...@@ -16287,6 +16309,7 @@ Represents vulnerability letter grades with associated projects. ...@@ -16287,6 +16309,7 @@ Represents vulnerability letter grades with associated projects.
| <a id="workitemdescriptionhtml"></a>`descriptionHtml` | [`String`](#string) | The GitLab Flavored Markdown rendering of `description`. | | <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="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="workitemiid"></a>`iid` | [`ID!`](#id) | Internal ID of the work item. |
| <a id="workitemstate"></a>`state` | [`WorkItemState!`](#workitemstate) | State of the work item. |
| <a id="workitemtitle"></a>`title` | [`String!`](#string) | Title 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="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. | | <a id="workitemworkitemtype"></a>`workItemType` | [`WorkItemType!`](#workitemtype) | Type assigned to the work item. |
...@@ -17891,6 +17914,24 @@ Weight ID wildcard values. ...@@ -17891,6 +17914,24 @@ Weight ID wildcard values.
| <a id="weightwildcardidany"></a>`ANY` | Weight is assigned. | | <a id="weightwildcardidany"></a>`ANY` | Weight is assigned. |
| <a id="weightwildcardidnone"></a>`NONE` | No weight is assigned. | | <a id="weightwildcardidnone"></a>`NONE` | No weight is assigned. |
### `WorkItemState`
State of a GitLab work item.
| Value | Description |
| ----- | ----------- |
| <a id="workitemstateclosed"></a>`CLOSED` | In closed state. |
| <a id="workitemstateopen"></a>`OPEN` | In open state. |
### `WorkItemStateEvent`
Values for work item state events.
| Value | Description |
| ----- | ----------- |
| <a id="workitemstateeventclose"></a>`CLOSE` | Closes the work item. |
| <a id="workitemstateeventreopen"></a>`REOPEN` | Reopens the work item. |
## Scalar types ## Scalar types
Scalar values are atomic values, and do not have fields of their own. Scalar values are atomic values, and do not have fields of their own.
# frozen_string_literal: true
FactoryBot.define do
factory :work_item, traits: [:has_internal_id] do
title { generate(:title) }
project
author { project.creator }
updated_by { author }
relative_position { RelativePositioning::START_POSITION }
issue_type { :issue }
association :work_item_type, :default
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe WorkItem do
describe '#noteable_target_type_name' do
it 'returns `issue` as the target name' do
work_item = build(:work_item)
expect(work_item.noteable_target_type_name).to eq('issue')
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Update 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_it_be(:work_item, refind: true) { create(:work_item, project: project) }
let(:work_item_event) { 'CLOSE' }
let(:input) { { 'stateEvent' => work_item_event } }
let(:mutation) { graphql_mutation(:workItemUpdate, input.merge('id' => work_item.to_global_id.to_s)) }
let(:mutation_response) { graphql_mutation_response(:work_item_update) }
context 'the user is not allowed to update 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 update a work item' do
let(:current_user) { developer }
context 'when the work item is open' do
it 'closes the work item' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
work_item.reload
end.to change(work_item, :state).from('opened').to('closed')
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['workItem']).to include(
'state' => 'CLOSED'
)
end
end
context 'when the work item is closed' do
let(:work_item_event) { 'REOPEN' }
before do
work_item.close!
end
it 'reopens the work item' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
work_item.reload
end.to change(work_item, :state).from('closed').to('opened')
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['workItem']).to include(
'state' => 'OPEN'
)
end
end
it_behaves_like 'has spam protection' do
let(:mutation_class) { ::Mutations::WorkItems::Update }
end
context 'when the work_items feature flag is disabled' do
before do
stub_feature_flags(work_items: false)
end
it 'does nothing and returns and error' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
end.to not_change(WorkItem, :count)
expect(mutation_response['errors']).to contain_exactly('`work_items` feature flag disabled for this project')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe WorkItems::UpdateService do
let_it_be(:developer) { create(:user) }
let_it_be(:project) { create(:project).tap { |proj| proj.add_developer(developer) } }
let_it_be_with_reload(:work_item) { create(:work_item, project: project, assignees: [developer]) }
let(:spam_params) { double }
let(:opts) { {} }
let(:current_user) { developer }
describe '#execute' do
subject(:update_work_item) { described_class.new(project: project, current_user: current_user, params: opts, spam_params: spam_params).execute(work_item) }
before do
stub_spam_services
end
context 'when updating state_event' do
context 'when state_event is close' do
let(:opts) { { state_event: 'close' } }
it 'closes the work item' do
expect do
update_work_item
work_item.reload
end.to change(work_item, :state).from('opened').to('closed')
end
end
context 'when state_event is reopen' do
let(:opts) { { state_event: 'reopen' } }
before do
work_item.close!
end
it 'reopens the work item' do
expect do
update_work_item
work_item.reload
end.to change(work_item, :state).from('closed').to('opened')
end
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