Commit 7b366fde authored by Dmitry Gruzd's avatar Dmitry Gruzd

Merge branch 'graphql-subscriptions-backend' into 'master'

Backend for implementing real-time issue assignees using GraphQL subscriptions

See merge request gitlab-org/gitlab!59834
parents 418450af d141128d
# frozen_string_literal: true
# This is based on https://github.com/rmosolgo/graphql-ruby/blob/v1.11.8/lib/graphql/subscriptions/action_cable_subscriptions.rb#L19-L82
# modified to work with our own ActionCableLink client
class GraphqlChannel < ApplicationCable::Channel # rubocop:disable Gitlab/NamespacedClass
def subscribed
@subscription_ids = []
query = params['query']
variables = Gitlab::Graphql::Variables.new(params['variables']).to_h
operation_name = params['operationName']
result = GitlabSchema.execute(
query,
context: context,
variables: variables,
operation_name: operation_name
)
payload = {
result: result.to_h,
more: result.subscription?
}
# Track the subscription here so we can remove it
# on unsubscribe.
if result.context[:subscription_id]
@subscription_ids << result.context[:subscription_id]
end
transmit(payload)
end
def unsubscribed
@subscription_ids.each do |sid|
GitlabSchema.subscriptions.delete_subscription(sid)
end
end
rescue_from Gitlab::Graphql::Variables::Invalid do |exception|
transmit({ errors: [{ message: exception.message }] })
end
private
# When modifying the context, also update GraphqlController#context if needed
# so that we have similar context when executing queries, mutations, and subscriptions
#
# Objects added to the context may also need to be reloaded in
# `Subscriptions::BaseSubscription` so that they are not stale
def context
# is_sessionless_user is always false because we only support cookie auth in ActionCable
{ channel: self, current_user: current_user, is_sessionless_user: false }
end
end
......@@ -109,6 +109,8 @@ class GraphqlController < ApplicationController
end
end
# When modifying the context, also update GraphqlChannel#context if needed
# so that we have similar context when executing queries, mutations, and subscriptions
def context
@context ||= { current_user: current_user, is_sessionless_user: !!sessionless_user?, request: request }
end
......
......@@ -10,6 +10,7 @@ class GitlabSchema < GraphQL::Schema
DEFAULT_MAX_DEPTH = 15
AUTHENTICATED_MAX_DEPTH = 20
use GraphQL::Subscriptions::ActionCableSubscriptions
use GraphQL::Pagination::Connections
use BatchLoader::GraphQL
use Gitlab::Graphql::Pagination::Connections
......@@ -24,6 +25,7 @@ class GitlabSchema < GraphQL::Schema
query Types::QueryType
mutation Types::MutationType
subscription Types::SubscriptionType
default_max_page_size 100
......
# frozen_string_literal: true
module GraphqlTriggers
def self.issuable_assignees_updated(issuable)
GitlabSchema.subscriptions.trigger('issuableAssigneesUpdated', { issuable_id: issuable.to_gid }, issuable)
end
end
# frozen_string_literal: true
module Subscriptions
class BaseSubscription < GraphQL::Schema::Subscription
object_class Types::BaseObject
field_class Types::BaseField
def initialize(object:, context:, field:)
super
# Reset user so that we don't use a stale user for authorization
current_user.reset if current_user
end
def authorized?(*)
raise NotImplementedError
end
private
def unauthorized!
unsubscribe if context.query.subscription_update?
raise GraphQL::ExecutionError, 'Unauthorized subscription'
end
def current_user
context[:current_user]
end
end
end
# frozen_string_literal: true
module Subscriptions
class IssuableUpdated < BaseSubscription
include Gitlab::Graphql::Laziness
payload_type Types::IssuableType
argument :issuable_id, Types::GlobalIDType[Issuable],
required: true,
description: 'ID of the issuable.'
def subscribe(issuable_id:)
nil
end
def authorized?(issuable_id:)
# TODO: remove this check when the compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
raise Gitlab::Graphql::Errors::ArgumentError, 'Invalid IssuableID' unless issuable_id.is_a?(GlobalID)
issuable = force(GitlabSchema.find_by_gid(issuable_id))
unauthorized! unless issuable && Ability.allowed?(current_user, :"read_#{issuable.to_ability_name}", issuable)
true
end
end
end
# frozen_string_literal: true
module Types
class IssuableType < BaseUnion
graphql_name 'Issuable'
description 'Represents an issuable.'
possible_types Types::IssueType, Types::MergeRequestType
def self.resolve_type(object, context)
case object
when Issue
Types::IssueType
when MergeRequest
Types::MergeRequestType
else
raise 'Unsupported issuable type'
end
end
end
end
Types::IssuableType.prepend_if_ee('::EE::Types::IssuableType')
# frozen_string_literal: true
module Types
class SubscriptionType < ::Types::BaseObject
graphql_name 'Subscription'
field :issuable_assignees_updated, subscription: Subscriptions::IssuableUpdated, null: true,
description: 'Triggered when the assignees of an issuable are updated.'
end
end
......@@ -61,12 +61,7 @@ module Issues
todo_service.update_issue(issue, current_user, old_mentioned_users)
end
if issue.assignees != old_assignees
create_assignee_note(issue, old_assignees)
notification_service.async.reassigned_issue(issue, current_user, old_assignees)
todo_service.reassigned_assignable(issue, current_user, old_assignees)
track_incident_action(current_user, issue, :incident_assigned)
end
handle_assignee_changes(issue, old_assignees)
if issue.previous_changes.include?('confidential')
# don't enqueue immediately to prevent todos removal in case of a mistake
......@@ -90,6 +85,19 @@ module Issues
end
end
def handle_assignee_changes(issue, old_assignees)
return if issue.assignees == old_assignees
create_assignee_note(issue, old_assignees)
notification_service.async.reassigned_issue(issue, current_user, old_assignees)
todo_service.reassigned_assignable(issue, current_user, old_assignees)
track_incident_action(current_user, issue, :incident_assigned)
if Gitlab::ActionCable::Config.in_app? || Feature.enabled?(:broadcast_issue_updates, issue.project)
GraphqlTriggers.issuable_assignees_updated(issue)
end
end
def handle_task_changes(issuable)
todo_service.resolve_todos_for_target(issuable, current_user)
todo_service.update_issue(issuable, current_user)
......
......@@ -11817,6 +11817,22 @@ Represents the Geo sync and verification state of a snippet repository.
| <a id="submoduletype"></a>`type` | [`EntryType!`](#entrytype) | Type of tree entry. |
| <a id="submoduleweburl"></a>`webUrl` | [`String`](#string) | Web URL for the sub-module. |
### `Subscription`
#### Fields with arguments
##### `Subscription.issuableAssigneesUpdated`
Triggered when the assignees of an issuable are updated.
Returns [`Issuable`](#issuable).
###### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="subscriptionissuableassigneesupdatedissuableid"></a>`issuableId` | [`IssuableID!`](#issuableid) | ID of the issuable. |
### `TaskCompletionStatus`
Completion status of tasks.
......@@ -14181,6 +14197,12 @@ An example `IncidentManagementOncallRotationID` is: `"gid://gitlab/IncidentManag
Represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.
### `IssuableID`
A `IssuableID` is a global ID. It is encoded as a string.
An example `IssuableID` is: `"gid://gitlab/Issuable/1"`.
### `IssueID`
A `IssueID` is a global ID. It is encoded as a string.
......@@ -14390,6 +14412,16 @@ abstract types.
### Unions
#### `Issuable`
Represents an issuable.
One of:
- [`Epic`](#epic)
- [`Issue`](#issue)
- [`MergeRequest`](#mergerequest)
#### `PackageMetadata`
Represents metadata associated with a Package.
......
# frozen_string_literal: true
module EE
module Types
module IssuableType
extend ActiveSupport::Concern
prepended do
possible_types ::Types::EpicType
end
class_methods do
def resolve_type(object, context)
case object
when ::Epic
::Types::EpicType
else
super
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['Issuable'] do
it 'returns possible types' do
expect(described_class.possible_types).to include(Types::EpicType)
end
describe '.resolve_type' do
it 'resolves epics' do
expect(described_class.resolve_type(build(:epic), {})).to eq(Types::EpicType)
end
end
end
......@@ -8,7 +8,7 @@ module RuboCop
'https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#type-authorization'
# We want to exclude our own basetypes and scalars
ALLOWED_TYPES = %w[BaseEnum BaseScalar BasePermissionType MutationType
ALLOWED_TYPES = %w[BaseEnum BaseScalar BasePermissionType MutationType SubscriptionType
QueryType GraphQL::Schema BaseUnion BaseInputObject].freeze
def_node_search :authorize?, <<~PATTERN
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GraphqlTriggers do
describe '.issuable_assignees_updated' do
it 'triggers the issuableAssigneesUpdated subscription' do
assignees = create_list(:user, 2)
issue = create(:issue, assignees: assignees)
expect(GitlabSchema.subscriptions).to receive(:trigger).with(
'issuableAssigneesUpdated',
{ issuable_id: issue.to_gid },
issue
)
GraphqlTriggers.issuable_assignees_updated(issue)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Subscriptions::IssuableUpdated do
include GraphqlHelpers
it { expect(described_class).to have_graphql_arguments(:issuable_id) }
it { expect(described_class.payload_type).to eq(Types::IssuableType) }
describe '#resolve' do
let_it_be(:unauthorized_user) { create(:user) }
let_it_be(:issue) { create(:issue) }
let(:current_user) { issue.author }
let(:issuable_id) { issue.to_gid }
subject { resolver.resolve_with_support(issuable_id: issuable_id) }
context 'initial subscription' do
let(:resolver) { resolver_instance(described_class, ctx: { current_user: current_user }, subscription_update: false) }
it 'returns nil' do
expect(subject).to eq(nil)
end
context 'when user is unauthorized' do
let(:current_user) { unauthorized_user }
it 'raises an exception' do
expect { subject }.to raise_error(GraphQL::ExecutionError)
end
end
context 'when issue does not exist' do
let(:issuable_id) { GlobalID.parse("gid://gitlab/Issue/#{non_existing_record_id}") }
it 'raises an exception' do
expect { subject }.to raise_error(GraphQL::ExecutionError)
end
end
context 'when a GraphQL::ID_TYPE is provided' do
let(:issuable_id) { issue.to_gid.to_s }
it 'raises an exception' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
end
end
end
context 'subscription updates' do
let(:resolver) { resolver_instance(described_class, obj: issue, ctx: { current_user: current_user }, subscription_update: true) }
it 'returns the resolved object' do
expect(subject).to eq(issue)
end
context 'when user is unauthorized' do
let(:current_user) { unauthorized_user }
it 'unsubscribes the user' do
expect { subject }.to throw_symbol(:graphql_subscription_unsubscribed)
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['Issuable'] do
it 'returns possible types' do
expect(described_class.possible_types).to include(Types::IssueType, Types::MergeRequestType)
end
describe '.resolve_type' do
it 'resolves issues' do
expect(described_class.resolve_type(build(:issue), {})).to eq(Types::IssueType)
end
it 'resolves merge requests' do
expect(described_class.resolve_type(build(:merge_request), {})).to eq(Types::MergeRequestType)
end
it 'raises an error for invalid types' do
expect { described_class.resolve_type(build(:user), {}) }.to raise_error 'Unsupported issuable type'
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['Subscription'] do
it 'has the expected fields' do
expected_fields = %i[
issuable_assignees_updated
]
expect(described_class).to have_graphql_fields(*expected_fields).only
end
end
......@@ -1014,13 +1014,15 @@ RSpec.describe Issues::UpdateService, :mailer do
with_them do
it 'broadcasts to the issues channel based on ActionCable and feature flag values' do
expect(Gitlab::ActionCable::Config).to receive(:in_app?).and_return(action_cable_in_app_enabled)
allow(Gitlab::ActionCable::Config).to receive(:in_app?).and_return(action_cable_in_app_enabled)
stub_feature_flags(broadcast_issue_updates: feature_flag_enabled)
if should_broadcast
expect(IssuesChannel).to receive(:broadcast_to).with(issue, event: 'updated')
expect(GraphqlTriggers).to receive(:issuable_assignees_updated).with(issue)
else
expect(IssuesChannel).not_to receive(:broadcast_to)
expect(GraphqlTriggers).not_to receive(:issuable_assignees_updated).with(issue)
end
update_issue(update_params)
......
......@@ -142,9 +142,9 @@ module GraphqlHelpers
Class.new(::Types::BaseObject) { graphql_name name }
end
def resolver_instance(resolver_class, obj: nil, ctx: {}, field: nil, schema: GitlabSchema)
def resolver_instance(resolver_class, obj: nil, ctx: {}, field: nil, schema: GitlabSchema, subscription_update: false)
if ctx.is_a?(Hash)
q = double('Query', schema: schema)
q = double('Query', schema: schema, subscription_update?: subscription_update)
ctx = GraphQL::Query::Context.new(query: q, object: obj, values: ctx)
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