Commit c3196212 authored by Sean Arnold's avatar Sean Arnold

Add GraphQL mutation for adding oncall rotation

- Add oncall rotation type + enums
- Add mutation
- Update docs
parent 0675d91b
......@@ -10870,6 +10870,66 @@ An ISO 8601-encoded date
"""
scalar ISO8601Date
"""
Describes an incident management on-call rotation
"""
type IncidentManagementOncallRotation {
"""
ID of the on-call rotation
"""
id: IncidentManagementOncallRotationID!
"""
Time zone of the on-call schedule
"""
length: Int
"""
Unit of the on-call rotation length
"""
lengthUnit: OncallRotationUnitEnum
"""
Name of the on-call rotation
"""
name: String!
"""
Participants of the on-call rotation
"""
participants(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
): OncallUserTypeConnection
"""
Start date of the on-call rotation
"""
startsAt: Time
}
"""
Identifier of IncidentManagement::OncallRotation
"""
scalar IncidentManagementOncallRotationID
"""
Describes an incident management on-call schedule
"""
......@@ -14794,6 +14854,7 @@ type Mutation {
"""
mergeRequestUpdate(input: MergeRequestUpdateInput!): MergeRequestUpdatePayload
namespaceIncreaseStorageTemporarily(input: NamespaceIncreaseStorageTemporarilyInput!): NamespaceIncreaseStorageTemporarilyPayload
oncallRotationCreate(input: OncallRotationCreateInput!): OncallRotationCreatePayload
oncallScheduleCreate(input: OncallScheduleCreateInput!): OncallScheduleCreatePayload
oncallScheduleDestroy(input: OncallScheduleDestroyInput!): OncallScheduleDestroyPayload
oncallScheduleUpdate(input: OncallScheduleUpdateInput!): OncallScheduleUpdatePayload
......@@ -15404,6 +15465,116 @@ Identifier of Noteable
"""
scalar NoteableID
"""
Autogenerated input type of OncallRotationCreate
"""
input OncallRotationCreateInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The name of the on-call rotation
"""
name: String!
"""
The usernames of users participating in the on-call rotation
"""
participants: [OncallUserInputType!]!
"""
The project to create the on-call schedule in
"""
projectPath: ID!
"""
The rotation length of the on-call rotation
"""
rotationLength: OncallRotationLengthInputType!
"""
The iid of the on-call schedule to create the on-call rotation in
"""
scheduleIid: String!
"""
The start date and time of the on-call rotation
"""
startsAt: OncallRotationDateInputType!
}
"""
Autogenerated return type of OncallRotationCreate
"""
type OncallRotationCreatePayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The on-call rotation
"""
oncallRotation: IncidentManagementOncallRotation
}
"""
Date input type for on-call rotation
"""
input OncallRotationDateInputType {
"""
The date component of the date in YYYY-MM-DD format
"""
date: String!
"""
The time component of the date in 24hr HH:MM format
"""
time: String!
}
"""
The rotation length of the on-call rotation
"""
input OncallRotationLengthInputType {
"""
The rotation length of the on-call rotation
"""
length: Int!
"""
The unit of the rotation length of the on-call rotation
"""
unit: OncallRotationUnitEnum!
}
"""
Rotation length unit of an on-call rotation
"""
enum OncallRotationUnitEnum {
"""
One Days
"""
DAYS
"""
One Hours
"""
HOURS
"""
One Weeks
"""
WEEKS
}
"""
Autogenerated input type of OncallScheduleCreate
"""
......@@ -15549,6 +15720,81 @@ type OncallScheduleUpdatePayload {
oncallSchedule: IncidentManagementOncallSchedule
}
"""
The rotation user and color palette
"""
input OncallUserInputType {
"""
The color palette to assign to the on-call user, for example: "blue".
"""
colorPalette: String
"""
The color weight to assign to for the on-call user, for example "500". Max 4 chars. For easy identification of the user.
"""
colorWeight: String
"""
The username of the user to participate in the on-call rotation. i.e user_one
"""
username: String!
}
"""
The rotation user and color palette
"""
type OncallUserType {
"""
The color palette to assign to the on-call user. For example "blue".
"""
colorPalette: String
"""
The color weight to assign to for the on-call user, for example "500". Max 4 chars. For easy identification of the user.
"""
colorWeight: String
"""
The user who is participating
"""
user: User!
}
"""
The connection type for OncallUserType.
"""
type OncallUserTypeConnection {
"""
A list of edges.
"""
edges: [OncallUserTypeEdge]
"""
A list of nodes.
"""
nodes: [OncallUserType]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type OncallUserTypeEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: OncallUserType
}
"""
Represents a package
"""
......
......@@ -1712,6 +1712,19 @@ Autogenerated return type of HttpIntegrationUpdate.
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `integration` | AlertManagementHttpIntegration | The HTTP integration |
### IncidentManagementOncallRotation
Describes an incident management on-call rotation.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | IncidentManagementOncallRotationID! | ID of the on-call rotation |
| `length` | Int | Time zone of the on-call schedule |
| `lengthUnit` | OncallRotationUnitEnum | Unit of the on-call rotation length |
| `name` | String! | Name of the on-call rotation |
| `participants` | OncallUserTypeConnection | Participants of the on-call rotation |
| `startsAt` | Time | Start date of the on-call rotation |
### IncidentManagementOncallSchedule
Describes an incident management on-call schedule.
......@@ -2377,6 +2390,16 @@ Autogenerated return type of NamespaceIncreaseStorageTemporarily.
| `repositionNote` | Boolean! | Indicates the user can perform `reposition_note` on this resource |
| `resolveNote` | Boolean! | Indicates the user can perform `resolve_note` on this resource |
### OncallRotationCreatePayload
Autogenerated return type of OncallRotationCreate.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `oncallRotation` | IncidentManagementOncallRotation | The on-call rotation |
### OncallScheduleCreatePayload
Autogenerated return type of OncallScheduleCreate.
......@@ -2407,6 +2430,16 @@ Autogenerated return type of OncallScheduleUpdate.
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `oncallSchedule` | IncidentManagementOncallSchedule | The on-call schedule |
### OncallUserType
The rotation user and color palette.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `colorPalette` | String | The color palette to assign to the on-call user. For example "blue". |
| `colorWeight` | String | The color weight to assign to for the on-call user, for example "500". Max 4 chars. For easy identification of the user. |
| `user` | User! | The user who is participating |
### Package
Represents a package.
......@@ -4574,6 +4607,16 @@ Values for sorting projects.
| `SIMILARITY` | Most similar to the search query |
| `STORAGE` | Sort by storage size |
### OncallRotationUnitEnum
Rotation length unit of an on-call rotation.
| Value | Description |
| ----- | ----------- |
| `DAYS` | One Days |
| `HOURS` | One Hours |
| `WEEKS` | One Weeks |
### PackageTypeEnum
| Value | Description |
......
......@@ -55,6 +55,7 @@ module EE
mount_mutation ::Mutations::IncidentManagement::OncallSchedule::Create
mount_mutation ::Mutations::IncidentManagement::OncallSchedule::Update
mount_mutation ::Mutations::IncidentManagement::OncallSchedule::Destroy
mount_mutation ::Mutations::IncidentManagement::OncallRotation::Create
prepend(Types::DeprecatedMutations)
end
......
# frozen_string_literal: true
module Mutations
module IncidentManagement
module OncallRotation
class Base < BaseMutation
field :oncall_rotation,
::Types::IncidentManagement::OncallRotationType,
null: true,
description: 'The on-call rotation'
authorize :admin_incident_management_oncall_schedule
private
def response(result, errors = nil)
{
oncall_rotation: result.payload[:oncall_rotation],
errors: errors.presence || result.errors
}
end
end
end
end
end
# frozen_string_literal: true
module Mutations
module IncidentManagement
module OncallRotation
class Create < Base
include ResolvesProject
graphql_name 'OncallRotationCreate'
argument :project_path, GraphQL::ID_TYPE,
required: true,
description: 'The project to create the on-call schedule in'
argument :schedule_iid, GraphQL::STRING_TYPE,
required: true,
description: 'The iid of the on-call schedule to create the on-call rotation in',
as: :iid
argument :name, GraphQL::STRING_TYPE,
required: true,
description: 'The name of the on-call rotation'
argument :starts_at, Types::IncidentManagement::OncallRotationDateInputType,
required: true,
description: 'The start date and time of the on-call rotation'
argument :rotation_length, Types::IncidentManagement::OncallRotationLengthInputType,
required: true,
description: 'The rotation length of the on-call rotation'
argument :participants,
[Types::IncidentManagement::OncallUserInputType],
required: true,
description: 'The usernames of users participating in the on-call rotation'
MAXIMUM_PARTICIPANTS = 100
def resolve(iid:, project_path:, participants:, **args)
project = authorized_find!(full_path: project_path)
schedule = ::IncidentManagement::OncallSchedulesFinder.new(current_user, project, iids: iid)
.execute
.first
raise_schedule_not_found unless schedule
result = ::IncidentManagement::OncallRotations::CreateService.new(
schedule,
project,
current_user,
prepare_params(schedule, participants, args)
).execute
errors = result.error? ? [result.message] : []
response(result, errors)
end
private
def prepare_params(schedule, participants, args)
rotation_length = args[:rotation_length][:length]
rotation_length_unit = args[:rotation_length][:unit]
starts_at = parse_start_time(schedule, args)
args.slice(:name).merge(
length: rotation_length,
length_unit: rotation_length_unit,
starts_at: starts_at,
participants: find_participants(participants)
)
end
def parse_start_time(schedule, args)
args[:starts_at].asctime.in_time_zone(schedule.timezone)
end
def find_object(full_path:)
resolve_project(full_path: full_path)
end
def find_participants(user_array)
raise_too_many_users_error if user_array.size > MAXIMUM_PARTICIPANTS
usernames = user_array.map {|h| h[:username] }
matched_users = UsersFinder.new(current_user, username: usernames).execute.order_by(:username)
raise_user_not_found if matched_users.size != user_array.size
user_array = user_array.sort_by! { |h| h[:username] }
user_array.map.with_index { |param, i| param.to_h.merge(user: matched_users[i]) }
end
def raise_schedule_not_found
raise Gitlab::Graphql::Errors::ArgumentError, 'The schedule could not be found'
end
def raise_too_many_users_error
raise Gitlab::Graphql::Errors::ArgumentError, "A maximum of #{MAXIMUM_PARTICIPANTS} participants can be added"
end
def raise_user_not_found
raise Gitlab::Graphql::Errors::ArgumentError, 'A username that was provided could not be matched to a user'
end
end
end
end
end
# frozen_string_literal: true
module Types
module IncidentManagement
# rubocop: disable Graphql/AuthorizeTypes
class OncallRotationDateInputType < BaseInputObject
graphql_name 'OncallRotationDateInputType'
description 'Date input type for on-call rotation'
argument :date, GraphQL::STRING_TYPE,
required: true,
description: 'The date component of the date in YYYY-MM-DD format'
argument :time, GraphQL::STRING_TYPE,
required: true,
description: 'The time component of the date in 24hr HH:MM format'
DATE_FORMAT = %r[\d{4}-[0123]\d-\d{2}].freeze
TIME_FORMAT = %r[[012]\d:\d{2}].freeze
def prepare
raise Gitlab::Graphql::Errors::ArgumentError, 'Date given is invalid' unless DATE_FORMAT.match(date)
raise Gitlab::Graphql::Errors::ArgumentError, 'Time given is invalid' unless TIME_FORMAT.match(time)
Time.parse("#{date} #{time}")
rescue ArgumentError, TypeError
raise Gitlab::Graphql::Errors::ArgumentError, 'Date & time is invalid'
end
end
# rubocop: enable Graphql/AuthorizeTypes
end
end
# frozen_string_literal: true
module Types
module IncidentManagement
# rubocop: disable Graphql/AuthorizeTypes
class OncallRotationLengthInputType < BaseInputObject
graphql_name 'OncallRotationLengthInputType'
description 'The rotation length of the on-call rotation'
argument :length, GraphQL::INT_TYPE,
required: true,
description: 'The rotation length of the on-call rotation'
argument :unit, Types::IncidentManagement::OncallRotationLengthUnitEnum,
required: true,
description: 'The unit of the rotation length of the on-call rotation'
end
# rubocop: enable Graphql/AuthorizeTypes
end
end
# frozen_string_literal: true
module Types
module IncidentManagement
class OncallRotationLengthUnitEnum < BaseEnum
graphql_name 'OncallRotationUnitEnum'
description 'Rotation length unit of an on-call rotation'
::IncidentManagement::OncallRotation.length_units.keys.each do |unit|
value unit.upcase, value: unit, description: "One #{unit.titleize}"
end
end
end
end
# frozen_string_literal: true
module Types
module IncidentManagement
class OncallRotationType < BaseObject
graphql_name 'IncidentManagementOncallRotation'
description 'Describes an incident management on-call rotation'
authorize :read_incident_management_oncall_schedule
field :id,
Types::GlobalIDType[::IncidentManagement::OncallRotation],
null: false,
description: 'ID of the on-call rotation'
field :name,
GraphQL::STRING_TYPE,
null: false,
description: 'Name of the on-call rotation'
field :starts_at,
Types::TimeType,
null: true,
description: 'Start date of the on-call rotation'
field :length,
GraphQL::INT_TYPE,
null: true,
description: 'Time zone of the on-call schedule'
field :length_unit,
Types::IncidentManagement::OncallRotationLengthUnitEnum,
null: true,
description: 'Unit of the on-call rotation length'
field :participants,
::Types::IncidentManagement::OncallUserType.connection_type,
null: true,
description: 'Participants of the on-call rotation'
def participants
object.oncall_participants
end
end
end
end
# frozen_string_literal: true
module Types
module IncidentManagement
# rubocop: disable Graphql/AuthorizeTypes
class OncallUserInputType < BaseInputObject
graphql_name 'OncallUserInputType'
description 'The rotation user and color palette'
argument :username, GraphQL::STRING_TYPE,
required: true,
description: 'The username of the user to participate in the on-call rotation. i.e user_one'
argument :color_palette, GraphQL::STRING_TYPE,
required: false,
description: 'The color palette to assign to the on-call user, for example: "blue".'
argument :color_weight, GraphQL::STRING_TYPE,
required: false,
description: 'The color weight to assign to for the on-call user, for example "500". Max 4 chars. For easy identification of the user.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
end
# frozen_string_literal: true
module Types
module IncidentManagement
# rubocop: disable Graphql/AuthorizeTypes
class OncallUserType < BaseObject
graphql_name 'OncallUserType'
description 'The rotation user and color palette'
field :user, Types::UserType,
null: false,
description: 'The user who is participating'
field :color_palette, GraphQL::STRING_TYPE,
null: true,
description: 'The color palette to assign to the on-call user. For example "blue".'
field :color_weight, GraphQL::STRING_TYPE,
null: true,
description: 'The color weight to assign to for the on-call user, for example "500". Max 4 chars. For easy identification of the user.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::IncidentManagement::OncallRotation::Create do
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:schedule) { create(:incident_management_oncall_schedule, project: project) }
let(:args) do
{
project_path: project.full_path,
name: 'On-call rotation',
schedule_iid: schedule.iid,
starts_at: "2020-01-10 09:00".in_time_zone(schedule.timezone),
rotation_length: {
length: 1,
unit: ::IncidentManagement::OncallRotation.rotation_length_units[:days]
},
participants: [
{
username: current_user.username,
color_weight: "500",
color_palette: "black"
}
]
}
end
specify { expect(described_class).to require_graphql_authorizations(:admin_incident_management_oncall_schedule) }
describe '#resolve' do
subject(:resolve) { mutation_for(project, current_user).resolve(iid: schedule.iid, project_path: project.full_path, participants: args[:participants], **args) }
context 'user has access to project' do
before do
stub_licensed_features(oncall_schedules: true)
project.add_maintainer(current_user)
end
context 'when OncallRotation::CreateService responds with success' do
it 'returns the on-call rotation with no errors' do
expect(resolve).to match(
oncall_rotation: ::IncidentManagement::OncallRotation.last!,
errors: be_empty
)
end
end
context 'when OncallRotations::CreateService responds with an error' do
before do
allow_any_instance_of(::IncidentManagement::OncallRotations::CreateService)
.to receive(:execute)
.and_return(ServiceResponse.error(payload: { oncall_rotation: nil }, message: 'An on-call rotation already exists'))
end
it 'returns errors' do
expect(resolve).to eq(
oncall_rotation: nil,
errors: ['An on-call rotation already exists']
)
end
end
describe 'error cases' do
context 'user cannot be found' do
before do
args.merge!(participants: [username: 'unknown'])
end
it 'raises an error' do
expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'A username that was provided could not be matched to a user')
end
end
context 'user does not have access to the project' do
before do
other_user = create(:user)
args.merge!(
participants: [
{
username: other_user.username,
color_weight: "500",
color_palette: "black"
}
]
)
end
it 'raises an error' do
expect { resolve }.to raise_error(ActiveRecord::RecordInvalid, /participant does not have access to the project/)
end
end
context 'schedule does not exist' do
let(:schedule) { double(iid: non_existing_record_iid, timezone: 'UTC') }
it 'raises an error' do
expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'The schedule could not be found')
end
end
context 'too many users' do
before do
stub_const('Mutations::IncidentManagement::OncallRotation::Create::MAXIMUM_PARTICIPANTS', 0)
end
it 'raises an error' do
expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ArgumentError, "A maximum of #{described_class::MAXIMUM_PARTICIPANTS} participants can be added")
end
end
end
end
context 'when resource is not accessible to the user' do
it 'raises an error' do
expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
private
def mutation_for(project, user)
described_class.new(object: project, context: { current_user: user }, field: nil)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['IncidentManagementOncallRotation'] do
specify { expect(described_class.graphql_name).to eq('IncidentManagementOncallRotation') }
specify { expect(described_class).to require_graphql_authorizations(:read_incident_management_oncall_schedule) }
it 'exposes the expected fields' do
expected_fields = %i[
id
name
starts_at
rotation_length
rotation_length_unit
participants
]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Creating a new on-call schedule' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:schedule) { create(:incident_management_oncall_schedule, project: project) }
let(:args) do
{
project_path: project.full_path,
name: 'On-call rotation',
schedule_iid: schedule.iid.to_s,
starts_at: {
date: "2020-09-19",
time: "09:00"
},
rotation_length: {
length: 1,
unit: 'DAYS'
},
participants: [
{
username: current_user.username,
colorWeight: "500",
colorPalette: "black"
}
]
}
end
let(:mutation) do
graphql_mutation(:oncall_rotation_create, args) do
<<~QL
clientMutationId
errors
oncallRotation {
id
name
startsAt
length
lengthUnit
participants {
nodes {
user {
id
username
}
}
}
}
QL
end
end
let(:mutation_response) { graphql_mutation_response(:oncall_rotation_create) }
before do
stub_licensed_features(oncall_schedules: true)
project.add_maintainer(current_user)
end
it 'creates a new on-call rotation', :aggregate_failures do
post_graphql_mutation(mutation, current_user: current_user)
new_oncall_rotation = ::IncidentManagement::OncallRotation.last!
oncall_rotation_response = mutation_response['oncallRotation']
expect(response).to have_gitlab_http_status(:success)
expect(oncall_rotation_response.slice(*%w[id name length lengthUnit])).to eq(
'id' => global_id_of(new_oncall_rotation),
'name' => args[:name],
'length' => 1,
'lengthUnit' => 'DAYS'
)
start_time = "#{args[:starts_at][:date]} #{args[:starts_at][:time]}".in_time_zone(schedule.timezone)
expect(Time.parse(oncall_rotation_response['startsAt'])).to eq(start_time)
expect(oncall_rotation_response.dig('participants', 'nodes')).to contain_exactly(
{
'user' => {
'id' => global_id_of(current_user),
'username' => current_user.username
}
}
)
end
%i[project_path schedule_iid name starts_at rotation_length participants].each do |argument|
context "without required argument #{argument}" do
before do
args.delete(argument)
end
it_behaves_like 'an invalid argument to the mutation', argument_name: argument
end
end
context 'time is invalid' do
before do
args[:starts_at][:time] = '999:999'
end
it 'returns the on-call rotation with errors' do
post_graphql_mutation(mutation, current_user: current_user)
expect(graphql_errors).not_to be_empty
error = graphql_errors.first.dig('extensions', 'problems', 0, 'explanation')
expect(error).to match('Time given is invalid')
end
end
context 'date is invalid' do
before do
args[:starts_at][:date] = 'incorrect date'
end
it 'returns the on-call rotation with errors' do
post_graphql_mutation(mutation, current_user: current_user)
expect(graphql_errors).not_to be_empty
error = graphql_errors.first.dig('extensions', 'problems', 0, 'explanation')
expect(error).to match('Date given is invalid')
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