Commit 9b71d27b authored by Jan Provaznik's avatar Jan Provaznik Committed by Grzegorz Bizon

Allow updating of requirements

Requirement's title and state attributes can be updated through
GraphQL API.
parent 99b76451
......@@ -5046,6 +5046,7 @@ type Mutation {
will be destroyed during the update, and no Note will be returned
"""
updateNote(input: UpdateNoteInput!): UpdateNotePayload
updateRequirement(input: UpdateRequirementInput!): UpdateRequirementPayload
updateSnippet(input: UpdateSnippetInput!): UpdateSnippetPayload
}
......@@ -8498,6 +8499,56 @@ type UpdateNotePayload {
note: Note
}
"""
Autogenerated input type of UpdateRequirement
"""
input UpdateRequirementInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The iid of the requirement to update
"""
iid: String!
"""
The project full path the requirement is associated with
"""
projectPath: ID!
"""
State of the requirement
"""
state: RequirementState
"""
Title of the requirement
"""
title: String
}
"""
Autogenerated return type of UpdateRequirement
"""
type UpdateRequirementPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Reasons why the mutation failed.
"""
errors: [String!]!
"""
The requirement after mutation
"""
requirement: Requirement
}
"""
Autogenerated input type of UpdateSnippet
"""
......
......@@ -15331,6 +15331,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updateRequirement",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "UpdateRequirementInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "UpdateRequirementPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updateSnippet",
"description": null,
......@@ -25662,6 +25689,142 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "UpdateRequirementInput",
"description": "Autogenerated input type of UpdateRequirement",
"fields": null,
"inputFields": [
{
"name": "title",
"description": "Title of the requirement",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "state",
"description": "State of the requirement",
"type": {
"kind": "ENUM",
"name": "RequirementState",
"ofType": null
},
"defaultValue": null
},
{
"name": "iid",
"description": "The iid of the requirement to update",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "projectPath",
"description": "The project full path the requirement is associated with",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"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": "UpdateRequirementPayload",
"description": "Autogenerated return type of UpdateRequirement",
"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": "errors",
"description": "Reasons why the mutation failed.",
"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
},
{
"name": "requirement",
"description": "The requirement after mutation",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Requirement",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "UpdateSnippetInput",
......
......@@ -1372,6 +1372,16 @@ Autogenerated return type of UpdateNote
| `errors` | String! => Array | Reasons why the mutation failed. |
| `note` | Note | The note after mutation |
## UpdateRequirementPayload
Autogenerated return type of UpdateRequirement
| Name | Type | Description |
| --- | ---- | ---------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Reasons why the mutation failed. |
| `requirement` | Requirement | The requirement after mutation |
## UpdateSnippetPayload
Autogenerated return type of UpdateSnippet
......
......@@ -15,6 +15,7 @@ module EE
mount_mutation ::Mutations::Epics::SetSubscription
mount_mutation ::Mutations::Epics::AddIssue
mount_mutation ::Mutations::Requirements::Create
mount_mutation ::Mutations::Requirements::Update
end
end
end
......
......@@ -9,8 +9,7 @@ module Mutations
authorize :create_requirement
field :requirement,
Types::RequirementType,
field :requirement, Types::RequirementType,
null: true,
description: 'The requirement after mutation'
......
# frozen_string_literal: true
module Mutations
module Requirements
class Update < BaseMutation
include Mutations::ResolvesProject
graphql_name 'UpdateRequirement'
authorize :update_requirement
field :requirement, Types::RequirementType,
null: true,
description: 'The requirement after mutation'
argument :title, GraphQL::STRING_TYPE,
required: false,
description: 'Title of the requirement'
argument :state, Types::RequirementStateEnum,
required: false,
description: 'State of the requirement'
argument :iid, GraphQL::STRING_TYPE,
required: true,
description: 'The iid of the requirement to update'
argument :project_path, GraphQL::ID_TYPE,
required: true,
description: 'The project full path the requirement is associated with'
def ready?(**args)
if args.values_at(:title, :state).compact.blank?
raise Gitlab::Graphql::Errors::ArgumentError,
'title or state argument is required'
end
super
end
def resolve(args)
project_path = args.delete(:project_path)
requirement_iid = args.delete(:iid)
requirement = authorized_find!(project_path: project_path, iid: requirement_iid)
requirement = ::Requirements::UpdateService.new(
requirement.project,
context[:current_user],
args
).execute(requirement)
{
requirement: requirement.reset,
errors: errors_on_object(requirement)
}
end
private
def find_object(project_path:, iid:)
project = resolve_project(full_path: project_path)
resolver = Resolvers::RequirementsResolver
.single.new(object: project, context: context, field: nil)
resolver.resolve(iid: iid)
end
end
end
end
# frozen_string_literal: true
module Requirements
class UpdateService < BaseService
def execute(requirement)
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :update_requirement, project)
attrs = whitelisted_requirement_params
requirement.update(attrs)
requirement
end
private
def whitelisted_requirement_params
params.slice(:title, :state)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Mutations::Requirements::Update do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:requirement) { create(:requirement, project: project) }
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
describe '#resolve' do
shared_examples 'requirements not available' do
it 'raises a not accessible error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
subject do
mutation.resolve(
project_path: project.full_path,
iid: requirement.iid.to_s,
title: 'foo',
state: 'archived'
)
end
it_behaves_like 'requirements not available'
context 'when the user can update the epic' do
before do
project.add_developer(user)
end
context 'when requirements feature is available' do
before do
stub_licensed_features(requirements: true)
end
it 'updates new requirement', :aggregate_failures do
expect(subject[:requirement]).to have_attributes(
title: 'foo',
state: 'archived'
)
expect(subject[:errors]).to be_empty
end
context 'when requirements_management flag is disabled' do
before do
stub_feature_flags(requirements_management: false)
end
it_behaves_like 'requirements not available'
end
end
context 'when requirements feature is disabled' do
before do
stub_licensed_features(requirements: false)
end
it_behaves_like 'requirements not available'
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'Updating a Requirement' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:requirement) { create(:requirement, project: project) }
let(:attributes) { { title: 'title', state: 'ARCHIVED' } }
let(:mutation) do
params = { project_path: project.full_path, iid: requirement.iid.to_s }.merge(attributes)
graphql_mutation(:update_requirement, params)
end
shared_examples 'requirement update fails' do
it_behaves_like 'a mutation that returns top-level errors',
errors: ['The resource that you are attempting to access does not exist '\
'or you don\'t have permission to perform this action']
it 'does not update requirement' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
end.not_to change { requirement.reload }
end
end
def mutation_response
graphql_mutation_response(:update_requirement)
end
context 'when the user does not have permission' do
before do
stub_licensed_features(requirements: true)
end
it_behaves_like 'requirement update fails'
end
context 'when the user has permission' do
before do
project.add_reporter(current_user)
end
context 'when requirements are disabled' do
before do
stub_licensed_features(requirements: false)
end
it_behaves_like 'requirement update fails'
end
context 'when requirements are enabled' do
before do
stub_licensed_features(requirements: true)
end
it 'updates the requirement', :aggregate_failures do
post_graphql_mutation(mutation, current_user: current_user)
requirement_hash = mutation_response['requirement']
expect(requirement_hash['title']).to eq('title')
expect(requirement_hash['state']).to eq('ARCHIVED')
end
context 'when there are ActiveRecord validation errors' do
let(:attributes) { { title: '' } }
it_behaves_like 'a mutation that returns errors in the response',
errors: ['Title can\'t be blank']
it 'does not update the requirement' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
end.not_to change { requirement.reload }
end
end
context 'when there are no update params' do
let(:attributes) { {} }
it_behaves_like 'a mutation that returns top-level errors',
errors: ['title or state argument is required']
end
context 'when requirements_management flag is disabled' do
before do
stub_feature_flags(requirements_management: false)
end
it_behaves_like 'requirement update fails'
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Requirements::UpdateService do
let_it_be(:project) { create(:project)}
let_it_be(:user) { create(:user) }
let_it_be(:requirement) { create(:requirement, project: project) }
let(:params) do
{
title: 'foo',
state: 'archived',
created_at: 2.days.ago,
author_id: create(:user).id
}
end
subject { described_class.new(project, user, params).execute(requirement) }
describe '#execute' do
before do
stub_licensed_features(requirements: true)
end
context 'when user can update requirements' do
before do
project.add_reporter(user)
end
it 'updates the requirement with only permitted params', :aggregate_failures do
is_expected.to have_attributes(
errors: be_empty,
title: params[:title],
state: params[:state]
)
is_expected.not_to have_attributes(
created_at: params[:created_at],
author_id: params[:author_id]
)
end
end
context 'when user is not allowed to update requirements' do
it 'raises an exception' do
expect { subject }.to raise_exception(Gitlab::Access::AccessDeniedError)
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