Commit f936fba8 authored by Vitali Tatarintev's avatar Vitali Tatarintev

Merge branch '262860-graphql' into 'master'

Add graphql type for oncall shifts

See merge request gitlab-org/gitlab!50006
parents 7c4ccdc9 09d91c65
......@@ -11661,6 +11661,41 @@ type IncidentManagementOncallRotation {
last: Int
): OncallParticipantTypeConnection
"""
Blocks of time for which a participant is on-call within a given timeframe. Timeframe cannot exceed one month.
"""
shifts(
"""
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
"""
End of timeframe to include shifts for. Cannot exceed one month after start.
"""
endTime: Time!
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
Start of timeframe to include shifts for.
"""
startTime: Time!
): IncidentManagementOncallShiftConnection
"""
Start date of the on-call rotation.
"""
......@@ -11792,6 +11827,61 @@ type IncidentManagementOncallScheduleEdge {
node: IncidentManagementOncallSchedule
}
"""
A block of time for which a participant is on-call.
"""
type IncidentManagementOncallShift {
"""
End time of the on-call shift.
"""
endsAt: Time
"""
Participant assigned to the on-call shift.
"""
participant: OncallParticipantType
"""
Start time of the on-call shift.
"""
startsAt: Time
}
"""
The connection type for IncidentManagementOncallShift.
"""
type IncidentManagementOncallShiftConnection {
"""
A list of edges.
"""
edges: [IncidentManagementOncallShiftEdge]
"""
A list of nodes.
"""
nodes: [IncidentManagementOncallShift]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type IncidentManagementOncallShiftEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: IncidentManagementOncallShift
}
type InstanceSecurityDashboard {
"""
Projects selected in Instance Security Dashboard
......
......@@ -31818,6 +31818,87 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "shifts",
"description": "Blocks of time for which a participant is on-call within a given timeframe. Timeframe cannot exceed one month.",
"args": [
{
"name": "startTime",
"description": "Start of timeframe to include shifts for.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "endTime",
"description": "End of timeframe to include shifts for. Cannot exceed one month after start.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "IncidentManagementOncallShiftConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "startsAt",
"description": "Start date of the on-call rotation.",
......@@ -32212,6 +32293,173 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "IncidentManagementOncallShift",
"description": "A block of time for which a participant is on-call.",
"fields": [
{
"name": "endsAt",
"description": "End time of the on-call shift.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "participant",
"description": "Participant assigned to the on-call shift.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "OncallParticipantType",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "startsAt",
"description": "Start time of the on-call shift.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "IncidentManagementOncallShiftConnection",
"description": "The connection type for IncidentManagementOncallShift.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "IncidentManagementOncallShiftEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "IncidentManagementOncallShift",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "IncidentManagementOncallShiftEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "IncidentManagementOncallShift",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "InstanceSecurityDashboard",
......@@ -1777,6 +1777,7 @@ Describes an incident management on-call rotation.
| `lengthUnit` | OncallRotationUnitEnum | Unit of the on-call rotation length. |
| `name` | String! | Name of the on-call rotation. |
| `participants` | OncallParticipantTypeConnection | Participants of the on-call rotation. |
| `shifts` | IncidentManagementOncallShiftConnection | Blocks of time for which a participant is on-call within a given timeframe. Timeframe cannot exceed one month. |
| `startsAt` | Time | Start date of the on-call rotation. |
### IncidentManagementOncallSchedule
......@@ -1791,6 +1792,16 @@ Describes an incident management on-call schedule.
| `rotations` | IncidentManagementOncallRotationConnection! | On-call rotations for the on-call schedule |
| `timezone` | String! | Time zone of the on-call schedule |
### IncidentManagementOncallShift
A block of time for which a participant is on-call..
| Field | Type | Description |
| ----- | ---- | ----------- |
| `endsAt` | Time | End time of the on-call shift. |
| `participant` | OncallParticipantType | Participant assigned to the on-call shift. |
| `startsAt` | Time | Start time of the on-call shift. |
### InstanceSecurityDashboard
| Field | Type | Description |
......
# frozen_string_literal: true
module Resolvers
module IncidentManagement
class OncallShiftsResolver < BaseResolver
alias_method :rotation, :synchronized_object
type Types::IncidentManagement::OncallShiftType.connection_type, null: true
argument :start_time,
::Types::TimeType,
required: true,
description: 'Start of timeframe to include shifts for.'
argument :end_time,
::Types::TimeType,
required: true,
description: 'End of timeframe to include shifts for. Cannot exceed one month after start.'
def resolve(start_time:, end_time:)
result = ::IncidentManagement::OncallShifts::ReadService.new(
rotation,
current_user,
start_time: start_time,
end_time: end_time
).execute
raise Gitlab::Graphql::Errors::ResourceNotAvailable, result.errors.join(', ') if result.error?
result.payload[:shifts]
end
end
end
end
......@@ -37,6 +37,12 @@ module Types
::Types::IncidentManagement::OncallParticipantType.connection_type,
null: true,
description: 'Participants of the on-call rotation.'
field :shifts,
::Types::IncidentManagement::OncallShiftType.connection_type,
null: true,
description: 'Blocks of time for which a participant is on-call within a given timeframe. Timeframe cannot exceed one month.',
resolver: ::Resolvers::IncidentManagement::OncallShiftsResolver
end
end
end
# frozen_string_literal: true
module Types
module IncidentManagement
class OncallShiftType < BaseObject
graphql_name 'IncidentManagementOncallShift'
description 'A block of time for which a participant is on-call.'
authorize :read_incident_management_oncall_schedule
field :participant,
::Types::IncidentManagement::OncallParticipantType,
null: true,
description: 'Participant assigned to the on-call shift.'
field :starts_at,
Types::TimeType,
null: true,
description: 'Start time of the on-call shift.'
field :ends_at,
Types::TimeType,
null: true,
description: 'End time of the on-call shift.'
end
end
end
# frozen_string_literal: true
module IncidentManagement
class OncallShiftPolicy < ::BasePolicy
delegate :rotation
end
end
......@@ -3,32 +3,36 @@
module IncidentManagement
module OncallShifts
class ReadService
MAXIMUM_TIMEFRAME = 1.month
# @param rotation [IncidentManagement::OncallRotation]
# @param current_user [User]
# @param params [Hash<Symbol,Any>]
# @option params - starts_at [Time]
# @option params - ends_at [Time]
def initialize(rotation, current_user, starts_at:, ends_at:)
# @option params - start_time [Time]
# @option params - end_time [Time]
def initialize(rotation, current_user, start_time:, end_time:)
@rotation = rotation
@current_user = current_user
@starts_at = starts_at
@ends_at = ends_at
@start_time = start_time
@end_time = end_time
end
def execute
return error_no_license unless available?
return error_no_permissions unless allowed?
return error_invalid_range unless start_before_end?
return error_excessive_range unless under_max_timeframe?
success(
::IncidentManagement::OncallShiftGenerator
.new(rotation)
.for_timeframe(starts_at: starts_at, ends_at: ends_at)
.for_timeframe(starts_at: start_time, ends_at: end_time)
)
end
private
attr_reader :rotation, :current_user, :starts_at, :ends_at
attr_reader :rotation, :current_user, :start_time, :end_time
def available?
::Gitlab::IncidentManagement.oncall_schedules_available?(rotation.project)
......@@ -38,6 +42,14 @@ module IncidentManagement
Ability.allowed?(current_user, :read_incident_management_oncall_schedule, rotation)
end
def start_before_end?
start_time < end_time
end
def under_max_timeframe?
end_time.to_date <= start_time.to_date + MAXIMUM_TIMEFRAME
end
def error(message)
ServiceResponse.error(message: message)
end
......@@ -53,6 +65,14 @@ module IncidentManagement
def error_no_license
error(_('Your license does not support on-call rotations'))
end
def error_invalid_range
error(_('`start_time` should precede `end_time`'))
end
def error_excessive_range
error(_('`end_time` should not exceed one month after `start_time`'))
end
end
end
end
......@@ -2,7 +2,7 @@
FactoryBot.define do
factory :incident_management_oncall_shift, class: 'IncidentManagement::OncallShift' do
association :participant, factory: :incident_management_oncall_participant
association :participant, :with_developer_access, factory: :incident_management_oncall_participant
rotation { participant.rotation }
starts_at { 5.days.ago }
ends_at { 2.days.from_now }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::IncidentManagement::OncallShiftsResolver do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:rotation) { create(:incident_management_oncall_rotation, :with_participant) }
let_it_be(:project) { rotation.project }
let(:args) { { start_time: rotation.starts_at, end_time: rotation.starts_at + rotation.shift_duration } }
subject(:shifts) { sync(resolve_oncall_shifts(args)) }
before do
stub_licensed_features(oncall_schedules: true)
project.add_reporter(current_user)
end
specify do
expect(described_class).to have_nullable_graphql_type(Types::IncidentManagement::OncallShiftType.connection_type)
end
it 'returns on-call schedules' do
expect(shifts.length).to eq(1)
expect(shifts.first).to be_a(::IncidentManagement::OncallShift)
expect(shifts.first).to have_attributes(rotation: rotation, starts_at: args[:start_time], ends_at: args[:end_time])
end
context 'when an error occurs while finding shifts' do
subject(:shifts) { sync(resolve_oncall_shifts(args, current_user: nil)) }
it 'raises ResourceNotAvailable error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
private
def resolve_oncall_shifts(args = {}, context = { current_user: current_user })
resolve(described_class, obj: rotation, args: args, ctx: context)
end
end
......@@ -15,6 +15,7 @@ RSpec.describe GitlabSchema.types['IncidentManagementOncallRotation'] do
length
length_unit
participants
shifts
]
expect(described_class).to have_graphql_fields(*expected_fields)
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['IncidentManagementOncallShift'] do
specify { expect(described_class.graphql_name).to eq('IncidentManagementOncallShift') }
specify { expect(described_class).to require_graphql_authorizations(:read_incident_management_oncall_schedule) }
it 'exposes the expected fields' do
expected_fields = %i[
participant
starts_at
ends_at
]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe IncidentManagement::OncallShiftPolicy do
let_it_be_with_refind(:shift) { create(:incident_management_oncall_shift) }
let_it_be(:project) { shift.rotation.project }
let_it_be(:user) { create(:user) }
subject(:policy) { described_class.new(user, shift) }
before do
stub_feature_flags(oncall_schedules_mvc: project)
stub_licensed_features(oncall_schedules: true)
end
describe 'rules' do
it { is_expected.to be_disallowed :read_incident_management_oncall_schedule }
context 'when guest' do
before do
project.add_guest(user)
end
it { is_expected.to be_disallowed :read_incident_management_oncall_schedule }
end
context 'when reporter' do
before do
project.add_reporter(user)
end
it { is_expected.to be_allowed :read_incident_management_oncall_schedule }
context 'licensed feature disabled' do
before do
stub_licensed_features(oncall_schedules: false)
end
it { is_expected.to be_disallowed :read_incident_management_oncall_schedule }
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'getting Incident Management on-call shifts' do
include GraphqlHelpers
let_it_be(:participant) { create(:incident_management_oncall_participant, :with_developer_access) }
let_it_be(:rotation) { participant.rotation }
let_it_be(:project) { rotation.project }
let_it_be(:current_user) { participant.user }
let(:starts_at) { rotation.starts_at }
let(:ends_at) { rotation.starts_at + rotation.shift_duration } # intentionally return one shift
let(:params) { { start_time: starts_at.iso8601, end_time: ends_at.iso8601 } }
let(:shift_fields) do
<<~QUERY
nodes {
participant { id }
endsAt
startsAt
}
QUERY
end
let(:schedule_fields) do
<<~QUERY
nodes {
rotations {
nodes {
#{query_graphql_field('shifts', params, shift_fields)}
}
}
}
QUERY
end
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('incidentManagementOncallSchedules', {}, schedule_fields)
)
end
let(:shifts) do
graphql_data
.dig('project', 'incidentManagementOncallSchedules', 'nodes').first
.dig('rotations', 'nodes').first
.dig('shifts', 'nodes')
end
before do
stub_licensed_features(oncall_schedules: true)
post_graphql(query, current_user: current_user)
end
it_behaves_like 'a working graphql query'
it 'returns the correct properties of the on-call shifts' do
expect(shifts.first).to include(
'participant' => { 'id' => participant.to_global_id.to_s },
'startsAt' => params[:start_time],
'endsAt' => params[:end_time]
)
end
context "without required argument starts_at" do
let(:params) { { end_time: ends_at.iso8601 } }
it 'raises an exception' do
expect(graphql_errors).to include(a_hash_including('message' => "Field 'shifts' is missing required arguments: startTime"))
end
end
context "without required argument ends_at" do
let(:params) { { start_time: starts_at.iso8601 } }
it 'raises an exception' do
expect(graphql_errors).to include(a_hash_including('message' => "Field 'shifts' is missing required arguments: endTime"))
end
end
end
......@@ -3,13 +3,14 @@
require 'spec_helper'
RSpec.describe ::IncidentManagement::OncallShifts::ReadService do
let_it_be_with_refind(:rotation) { create(:incident_management_oncall_rotation, :with_participant) }
let_it_be_with_refind(:rotation) { create(:incident_management_oncall_rotation) }
let_it_be(:participant) { create(:incident_management_oncall_participant, :with_developer_access, rotation: rotation) }
let_it_be(:project) { rotation.project }
let_it_be(:user_with_permissions) { create(:user) }
let_it_be(:user_without_permissions) { create(:user) }
let_it_be(:current_user) { user_with_permissions }
let(:params) { { starts_at: 15.minutes.since(rotation.starts_at), ends_at: 3.weeks.since(rotation.starts_at) } }
let(:params) { { start_time: 15.minutes.since(rotation.starts_at), end_time: 3.weeks.since(rotation.starts_at) } }
let(:service) { described_class.new(rotation, current_user, params) }
before_all do
......@@ -42,6 +43,18 @@ RSpec.describe ::IncidentManagement::OncallShifts::ReadService do
it_behaves_like 'error response', 'You have insufficient permissions to view shifts for this rotation'
end
context 'when the start time is after the end time' do
let(:params) { { start_time: rotation.starts_at, end_time: rotation.starts_at - 1.day } }
it_behaves_like 'error response', '`start_time` should precede `end_time`'
end
context 'when timeframe exceeds one month' do
let(:params) { { start_time: rotation.starts_at, end_time: rotation.starts_at + 1.month + 1.day } }
it_behaves_like 'error response', '`end_time` should not exceed one month after `start_time`'
end
context 'when feature is not available' do
before do
stub_licensed_features(oncall_schedules: false)
......@@ -67,8 +80,14 @@ RSpec.describe ::IncidentManagement::OncallShifts::ReadService do
expect(shifts).to all(be_a(::IncidentManagement::OncallShift))
expect(shifts).to all(be_valid)
expect(shifts.sort_by(&:starts_at)).to eq(shifts)
expect(shifts.first.starts_at).to be <= params[:starts_at]
expect(shifts.last.ends_at).to be >= params[:ends_at]
expect(shifts.first.starts_at).to be <= params[:start_time]
expect(shifts.last.ends_at).to be >= params[:end_time]
end
context 'when timeframe is exactly 1 month' do
let(:params) { { start_time: rotation.starts_at.beginning_of_day, end_time: (rotation.starts_at + 1.month).end_of_day } }
it { is_expected.to be_success }
end
end
end
......
......@@ -33038,6 +33038,12 @@ msgstr ""
msgid "[No reason]"
msgstr ""
msgid "`end_time` should not exceed one month after `start_time`"
msgstr ""
msgid "`start_time` should precede `end_time`"
msgstr ""
msgid "a deleted user"
msgstr ""
......
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