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 ...@@ -109,6 +109,8 @@ class GraphqlController < ApplicationController
end end
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 def context
@context ||= { current_user: current_user, is_sessionless_user: !!sessionless_user?, request: request } @context ||= { current_user: current_user, is_sessionless_user: !!sessionless_user?, request: request }
end end
......
...@@ -10,6 +10,7 @@ class GitlabSchema < GraphQL::Schema ...@@ -10,6 +10,7 @@ class GitlabSchema < GraphQL::Schema
DEFAULT_MAX_DEPTH = 15 DEFAULT_MAX_DEPTH = 15
AUTHENTICATED_MAX_DEPTH = 20 AUTHENTICATED_MAX_DEPTH = 20
use GraphQL::Subscriptions::ActionCableSubscriptions
use GraphQL::Pagination::Connections use GraphQL::Pagination::Connections
use BatchLoader::GraphQL use BatchLoader::GraphQL
use Gitlab::Graphql::Pagination::Connections use Gitlab::Graphql::Pagination::Connections
...@@ -24,6 +25,7 @@ class GitlabSchema < GraphQL::Schema ...@@ -24,6 +25,7 @@ class GitlabSchema < GraphQL::Schema
query Types::QueryType query Types::QueryType
mutation Types::MutationType mutation Types::MutationType
subscription Types::SubscriptionType
default_max_page_size 100 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 ...@@ -61,12 +61,7 @@ module Issues
todo_service.update_issue(issue, current_user, old_mentioned_users) todo_service.update_issue(issue, current_user, old_mentioned_users)
end end
if issue.assignees != old_assignees handle_assignee_changes(issue, 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
if issue.previous_changes.include?('confidential') if issue.previous_changes.include?('confidential')
# don't enqueue immediately to prevent todos removal in case of a mistake # don't enqueue immediately to prevent todos removal in case of a mistake
...@@ -90,6 +85,19 @@ module Issues ...@@ -90,6 +85,19 @@ module Issues
end end
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) def handle_task_changes(issuable)
todo_service.resolve_todos_for_target(issuable, current_user) todo_service.resolve_todos_for_target(issuable, current_user)
todo_service.update_issue(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. ...@@ -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="submoduletype"></a>`type` | [`EntryType!`](#entrytype) | Type of tree entry. |
| <a id="submoduleweburl"></a>`webUrl` | [`String`](#string) | Web URL for the sub-module. | | <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` ### `TaskCompletionStatus`
Completion status of tasks. Completion status of tasks.
...@@ -14181,6 +14197,12 @@ An example `IncidentManagementOncallRotationID` is: `"gid://gitlab/IncidentManag ...@@ -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. 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` ### `IssueID`
A `IssueID` is a global ID. It is encoded as a string. A `IssueID` is a global ID. It is encoded as a string.
...@@ -14390,6 +14412,16 @@ abstract types. ...@@ -14390,6 +14412,16 @@ abstract types.
### Unions ### Unions
#### `Issuable`
Represents an issuable.
One of:
- [`Epic`](#epic)
- [`Issue`](#issue)
- [`MergeRequest`](#mergerequest)
#### `PackageMetadata` #### `PackageMetadata`
Represents metadata associated with a Package. 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 ...@@ -8,7 +8,7 @@ module RuboCop
'https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#type-authorization' 'https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#type-authorization'
# We want to exclude our own basetypes and scalars # 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 QueryType GraphQL::Schema BaseUnion BaseInputObject].freeze
def_node_search :authorize?, <<~PATTERN 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 ...@@ -1014,13 +1014,15 @@ RSpec.describe Issues::UpdateService, :mailer do
with_them do with_them do
it 'broadcasts to the issues channel based on ActionCable and feature flag values' 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) stub_feature_flags(broadcast_issue_updates: feature_flag_enabled)
if should_broadcast if should_broadcast
expect(IssuesChannel).to receive(:broadcast_to).with(issue, event: 'updated') expect(IssuesChannel).to receive(:broadcast_to).with(issue, event: 'updated')
expect(GraphqlTriggers).to receive(:issuable_assignees_updated).with(issue)
else else
expect(IssuesChannel).not_to receive(:broadcast_to) expect(IssuesChannel).not_to receive(:broadcast_to)
expect(GraphqlTriggers).not_to receive(:issuable_assignees_updated).with(issue)
end end
update_issue(update_params) update_issue(update_params)
......
...@@ -142,9 +142,9 @@ module GraphqlHelpers ...@@ -142,9 +142,9 @@ module GraphqlHelpers
Class.new(::Types::BaseObject) { graphql_name name } Class.new(::Types::BaseObject) { graphql_name name }
end 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) 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) ctx = GraphQL::Query::Context.new(query: q, object: obj, values: ctx)
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