Commit f37fd3ec authored by Sean McGivern's avatar Sean McGivern

Merge branch 'req-graphql-create' into 'master'

GraphQL: Allow creation of Requirement

See merge request gitlab-org/gitlab!26998
parents 601c3564 81d7073c
...@@ -601,6 +601,46 @@ type CreateNotePayload { ...@@ -601,6 +601,46 @@ type CreateNotePayload {
note: Note note: Note
} }
"""
Autogenerated input type of CreateRequirement
"""
input CreateRequirementInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The project full path the requirement is associated with
"""
projectPath: ID!
"""
Title of the requirement
"""
title: String!
}
"""
Autogenerated return type of CreateRequirement
"""
type CreateRequirementPayload {
"""
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 CreateSnippet Autogenerated input type of CreateSnippet
""" """
...@@ -4952,6 +4992,7 @@ type Mutation { ...@@ -4952,6 +4992,7 @@ type Mutation {
createEpic(input: CreateEpicInput!): CreateEpicPayload createEpic(input: CreateEpicInput!): CreateEpicPayload
createImageDiffNote(input: CreateImageDiffNoteInput!): CreateImageDiffNotePayload createImageDiffNote(input: CreateImageDiffNoteInput!): CreateImageDiffNotePayload
createNote(input: CreateNoteInput!): CreateNotePayload createNote(input: CreateNoteInput!): CreateNotePayload
createRequirement(input: CreateRequirementInput!): CreateRequirementPayload
createSnippet(input: CreateSnippetInput!): CreateSnippetPayload createSnippet(input: CreateSnippetInput!): CreateSnippetPayload
designManagementDelete(input: DesignManagementDeleteInput!): DesignManagementDeletePayload designManagementDelete(input: DesignManagementDeleteInput!): DesignManagementDeletePayload
designManagementUpload(input: DesignManagementUploadInput!): DesignManagementUploadPayload designManagementUpload(input: DesignManagementUploadInput!): DesignManagementUploadPayload
...@@ -6574,6 +6615,94 @@ type Repository { ...@@ -6574,6 +6615,94 @@ type Repository {
): Tree ): Tree
} }
"""
Represents a requirement.
"""
type Requirement {
"""
Author of the requirement
"""
author: User!
"""
Timestamp of when the requirement was created
"""
createdAt: Time!
"""
ID of the requirement
"""
id: ID!
"""
Internal ID of the requirement
"""
iid: ID!
"""
Project to which the requirement belongs
"""
project: Project!
"""
State of the requirement
"""
state: RequirementState!
"""
Title of the requirement
"""
title: String
"""
Timestamp of when the requirement was last updated
"""
updatedAt: Time!
"""
Permissions for the current user on the resource
"""
userPermissions: RequirementPermissions!
}
"""
Check permissions for the current user on a requirement
"""
type RequirementPermissions {
"""
Indicates the user can perform `admin_requirement` on this resource
"""
adminRequirement: Boolean!
"""
Indicates the user can perform `create_requirement` on this resource
"""
createRequirement: Boolean!
"""
Indicates the user can perform `destroy_requirement` on this resource
"""
destroyRequirement: Boolean!
"""
Indicates the user can perform `read_requirement` on this resource
"""
readRequirement: Boolean!
"""
Indicates the user can perform `update_requirement` on this resource
"""
updateRequirement: Boolean!
}
"""
State of a requirement
"""
enum RequirementState {
ARCHIVED
OPENED
}
type RootStorageStatistics { type RootStorageStatistics {
""" """
The CI artifacts size in bytes The CI artifacts size in bytes
......
...@@ -1762,6 +1762,122 @@ ...@@ -1762,6 +1762,122 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "INPUT_OBJECT",
"name": "CreateRequirementInput",
"description": "Autogenerated input type of CreateRequirement",
"fields": null,
"inputFields": [
{
"name": "title",
"description": "Title of the requirement",
"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": "CreateRequirementPayload",
"description": "Autogenerated return type of CreateRequirement",
"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", "kind": "INPUT_OBJECT",
"name": "CreateSnippetInput", "name": "CreateSnippetInput",
...@@ -14390,6 +14506,33 @@ ...@@ -14390,6 +14506,33 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "createRequirement",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "CreateRequirementInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "CreateRequirementPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "createSnippet", "name": "createSnippet",
"description": null, "description": null,
...@@ -19792,6 +19935,303 @@ ...@@ -19792,6 +19935,303 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "Requirement",
"description": "Represents a requirement.",
"fields": [
{
"name": "author",
"description": "Author of the requirement",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "User",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createdAt",
"description": "Timestamp of when the requirement was created",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "ID of the requirement",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "iid",
"description": "Internal ID of the requirement",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "project",
"description": "Project to which the requirement belongs",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Project",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "state",
"description": "State of the requirement",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "RequirementState",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "title",
"description": "Title of the requirement",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updatedAt",
"description": "Timestamp of when the requirement was last updated",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "userPermissions",
"description": "Permissions for the current user on the resource",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "RequirementPermissions",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "RequirementPermissions",
"description": "Check permissions for the current user on a requirement",
"fields": [
{
"name": "adminRequirement",
"description": "Indicates the user can perform `admin_requirement` on this resource",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createRequirement",
"description": "Indicates the user can perform `create_requirement` on this resource",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "destroyRequirement",
"description": "Indicates the user can perform `destroy_requirement` on this resource",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "readRequirement",
"description": "Indicates the user can perform `read_requirement` on this resource",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updateRequirement",
"description": "Indicates the user can perform `update_requirement` on this resource",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "RequirementState",
"description": "State of a requirement",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "OPENED",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "ARCHIVED",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "RootStorageStatistics", "name": "RootStorageStatistics",
......
...@@ -129,6 +129,16 @@ Autogenerated return type of CreateNote ...@@ -129,6 +129,16 @@ Autogenerated return type of CreateNote
| `errors` | String! => Array | Reasons why the mutation failed. | | `errors` | String! => Array | Reasons why the mutation failed. |
| `note` | Note | The note after mutation | | `note` | Note | The note after mutation |
## CreateRequirementPayload
Autogenerated return type of CreateRequirement
| 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 |
## CreateSnippetPayload ## CreateSnippetPayload
Autogenerated return type of CreateSnippet Autogenerated return type of CreateSnippet
...@@ -982,6 +992,34 @@ Autogenerated return type of RemoveAwardEmoji ...@@ -982,6 +992,34 @@ Autogenerated return type of RemoveAwardEmoji
| `rootRef` | String | Default branch of the repository | | `rootRef` | String | Default branch of the repository |
| `tree` | Tree | Tree of the repository | | `tree` | Tree | Tree of the repository |
## Requirement
Represents a requirement.
| Name | Type | Description |
| --- | ---- | ---------- |
| `author` | User! | Author of the requirement |
| `createdAt` | Time! | Timestamp of when the requirement was created |
| `id` | ID! | ID of the requirement |
| `iid` | ID! | Internal ID of the requirement |
| `project` | Project! | Project to which the requirement belongs |
| `state` | RequirementState! | State of the requirement |
| `title` | String | Title of the requirement |
| `updatedAt` | Time! | Timestamp of when the requirement was last updated |
| `userPermissions` | RequirementPermissions! | Permissions for the current user on the resource |
## RequirementPermissions
Check permissions for the current user on a requirement
| Name | Type | Description |
| --- | ---- | ---------- |
| `adminRequirement` | Boolean! | Indicates the user can perform `admin_requirement` on this resource |
| `createRequirement` | Boolean! | Indicates the user can perform `create_requirement` on this resource |
| `destroyRequirement` | Boolean! | Indicates the user can perform `destroy_requirement` on this resource |
| `readRequirement` | Boolean! | Indicates the user can perform `read_requirement` on this resource |
| `updateRequirement` | Boolean! | Indicates the user can perform `update_requirement` on this resource |
## RootStorageStatistics ## RootStorageStatistics
| Name | Type | Description | | Name | Type | Description |
......
...@@ -14,6 +14,7 @@ module EE ...@@ -14,6 +14,7 @@ module EE
mount_mutation ::Mutations::Epics::Create mount_mutation ::Mutations::Epics::Create
mount_mutation ::Mutations::Epics::SetSubscription mount_mutation ::Mutations::Epics::SetSubscription
mount_mutation ::Mutations::Epics::AddIssue mount_mutation ::Mutations::Epics::AddIssue
mount_mutation ::Mutations::Requirements::Create
end end
end end
end end
......
# frozen_string_literal: true
module Mutations
module Requirements
class Create < BaseMutation
include Mutations::ResolvesProject
graphql_name 'CreateRequirement'
authorize :create_requirement
field :requirement,
Types::RequirementType,
null: true,
description: 'The requirement after mutation'
argument :title, GraphQL::STRING_TYPE,
required: true,
description: 'Title of the requirement'
argument :project_path, GraphQL::ID_TYPE,
required: true,
description: 'The project full path the requirement is associated with'
def resolve(args)
project_path = args.delete(:project_path)
project = authorized_find!(full_path: project_path)
validate_flag!(project)
requirement = ::Requirements::CreateService.new(
project,
context[:current_user],
args
).execute
{
requirement: requirement.valid? ? requirement : nil,
errors: errors_on_object(requirement)
}
end
private
def validate_flag!(project)
return if ::Feature.enabled?(:requirements_management, project)
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'requirements_management flag is not enabled on this project'
end
def find_object(full_path:)
resolve_project(full_path: full_path)
end
end
end
end
# frozen_string_literal: true
module Types
module PermissionTypes
class Requirement < BasePermissionType
graphql_name 'RequirementPermissions'
description 'Check permissions for the current user on a requirement'
abilities :read_requirement, :update_requirement, :destroy_requirement,
:admin_requirement, :create_requirement
end
end
end
# frozen_string_literal: true
module Types
class RequirementStateEnum < BaseEnum
graphql_name 'RequirementState'
description 'State of a requirement'
value 'OPENED', value: 'opened'
value 'ARCHIVED', value: 'archived'
end
end
# frozen_string_literal: true
module Types
class RequirementType < BaseObject
graphql_name 'Requirement'
description 'Represents a requirement.'
authorize :read_requirement
expose_permissions Types::PermissionTypes::Requirement
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the requirement'
field :iid, GraphQL::ID_TYPE, null: false,
description: 'Internal ID of the requirement'
field :title, GraphQL::STRING_TYPE, null: true,
description: 'Title of the requirement'
field :state, RequirementStateEnum, null: false,
description: 'State of the requirement'
field :project, ProjectType, null: false,
description: 'Project to which the requirement belongs',
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, obj.project_id).find }
field :author, Types::UserType, null: false,
description: 'Author of the requirement',
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find }
field :created_at, Types::TimeType, null: false,
description: 'Timestamp of when the requirement was created'
field :updated_at, Types::TimeType, null: false,
description: 'Timestamp of when the requirement was last updated'
end
end
# frozen_string_literal: true
module Requirements
class CreateService < BaseService
include Gitlab::Allowable
def execute
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :create_requirement, project)
attrs = whitelisted_requirement_params.merge(author: current_user)
project.requirements.create(attrs)
end
private
def whitelisted_requirement_params
params.slice(:title)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Mutations::Requirements::Create do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
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,
title: 'foo'
)
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 'creates new requirement' do
expect(subject[:requirement][:title]).to eq('foo')
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 GitlabSchema.types['RequirementState'] do
it { expect(described_class.graphql_name).to eq('RequirementState') }
it 'exposes all the existing requirement states' do
expect(described_class.values.keys).to include(*%w[OPENED ARCHIVED])
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['Requirement'] do
fields = %i[id iid title state project author created_at updated_at user_permissions]
it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Requirement) }
it { expect(described_class.graphql_name).to eq('Requirement') }
it { expect(described_class).to require_graphql_authorizations(:read_requirement) }
it { expect(described_class).to have_graphql_fields(fields) }
end
# frozen_string_literal: true
require 'spec_helper'
describe 'Creating a Requirement' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
let(:attributes) { { title: 'title' } }
let(:mutation) do
params = { project_path: project.full_path }.merge(attributes)
graphql_mutation(:create_requirement, params)
end
def mutation_response
graphql_mutation_response(:create_requirement)
end
context 'when the user does not have permission' do
before do
stub_licensed_features(requirements: true)
end
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 create requirement' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Requirement, :count)
end
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 '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']
end
context 'when requirements are enabled' do
before do
stub_licensed_features(requirements: true)
end
it 'creates the requirement' 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('OPENED')
expect(requirement_hash['author']['username']).to eq(current_user.username)
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 create the requirement' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Requirement, :count)
end
end
context 'when requirements_management flag is dissabled' do
before do
stub_feature_flags(requirements_management: false)
end
it_behaves_like 'a mutation that returns top-level errors',
errors: ['requirements_management flag is not enabled on this project']
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Requirements::CreateService do
let_it_be(:project) { create(:project)}
let_it_be(:user) { create(:user) }
let_it_be(:other_user) { create(:user) }
let(:params) { { title: 'foo', author_id: other_user.id, created_at: 2.days.ago } }
subject { described_class.new(project, user, params).execute }
describe '#execute' do
before do
stub_licensed_features(requirements: true)
end
context 'when user can create requirements' do
before do
project.add_reporter(user)
end
it 'creates new requirement' do
expect { subject }.to change { Requirement.count }.by(1)
end
it 'uses only permitted params' do
requirement = subject
expect(requirement).to be_persisted
expect(requirement.title).to eq(params[:title])
expect(requirement.state).to eq('opened')
expect(requirement.created_at).not_to eq(params[:created_at])
expect(requirement.author_id).not_to eq(params[:author_id])
end
end
context 'when user is not allowed to create 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