Commit 45fbc742 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Add GraphQL subscription for assignee updates

This will be used for updating sidebar assignees in real time
parent 7955bc37
...@@ -18,6 +18,12 @@ module Subscriptions ...@@ -18,6 +18,12 @@ module Subscriptions
private private
def unauthorized!
unsubscribe if context.query.subscription_update?
raise GraphQL::ExecutionError, 'Unauthorized subscription'
end
def current_user def current_user
context[:current_user] context[:current_user]
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
...@@ -11795,6 +11795,22 @@ Represents the Geo sync and verification state of a snippet repository. ...@@ -11795,6 +11795,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.
...@@ -14158,6 +14174,12 @@ An example `IncidentManagementOncallRotationID` is: `"gid://gitlab/IncidentManag ...@@ -14158,6 +14174,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.
...@@ -14367,6 +14389,16 @@ abstract types. ...@@ -14367,6 +14389,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 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
...@@ -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