From 81e79e88ac28e8d83676e155692d27d0526adb98 Mon Sep 17 00:00:00 2001
From: Heinrich Lee Yu <heinrich@gitlab.com>
Date: Wed, 29 Apr 2020 20:52:43 +0800
Subject: [PATCH] Broadcast issue updates to an ActionCable channel

This allows us to update the issue sidebar in real-time
---
 app/channels/application_cable/channel.rb     |  6 +++
 app/channels/application_cable/connection.rb  | 22 +++++++++
 app/channels/issues_channel.rb                | 13 +++++
 app/models/active_session.rb                  |  4 +-
 app/services/issues/update_service.rb         |  4 ++
 config/cable.yml.example                      | 14 ++++++
 lib/quality/test_level.rb                     |  1 +
 scripts/prepare_build.sh                      |  3 ++
 .../application_cable/connection_spec.rb      | 47 +++++++++++++++++++
 spec/channels/issues_channel_spec.rb          | 36 ++++++++++++++
 spec/lib/quality/test_level_spec.rb           |  4 +-
 spec/services/issues/update_service_spec.rb   | 28 +++++++++++
 12 files changed, 178 insertions(+), 4 deletions(-)
 create mode 100644 app/channels/application_cable/channel.rb
 create mode 100644 app/channels/application_cable/connection.rb
 create mode 100644 app/channels/issues_channel.rb
 create mode 100644 config/cable.yml.example
 create mode 100644 spec/channels/application_cable/connection_spec.rb
 create mode 100644 spec/channels/issues_channel_spec.rb

diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb
new file mode 100644
index 0000000000000..9aec2305390f8
--- /dev/null
+++ b/app/channels/application_cable/channel.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module ApplicationCable
+  class Channel < ActionCable::Channel::Base
+  end
+end
diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb
new file mode 100644
index 0000000000000..87c833f3593c2
--- /dev/null
+++ b/app/channels/application_cable/connection.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module ApplicationCable
+  class Connection < ActionCable::Connection::Base
+    identified_by :current_user
+
+    def connect
+      self.current_user = find_user_from_session_store
+    end
+
+    private
+
+    def find_user_from_session_store
+      session = ActiveSession.sessions_from_ids([session_id]).first
+      Warden::SessionSerializer.new('rack.session' => session).fetch(:user)
+    end
+
+    def session_id
+      Rack::Session::SessionId.new(cookies[Gitlab::Application.config.session_options[:key]])
+    end
+  end
+end
diff --git a/app/channels/issues_channel.rb b/app/channels/issues_channel.rb
new file mode 100644
index 0000000000000..5f3909b77167f
--- /dev/null
+++ b/app/channels/issues_channel.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class IssuesChannel < ApplicationCable::Channel
+  def subscribed
+    project = Project.find_by_full_path(params[:project_path])
+    return reject unless project
+
+    issue = project.issues.find_by_iid(params[:iid])
+    return reject unless issue && Ability.allowed?(current_user, :read_issue, issue)
+
+    stream_for issue
+  end
+end
diff --git a/app/models/active_session.rb b/app/models/active_session.rb
index 050155398ab39..065bd5507be80 100644
--- a/app/models/active_session.rb
+++ b/app/models/active_session.rb
@@ -124,7 +124,7 @@ def self.session_ids_for_user(user_id)
     end
   end
 
-  # Lists the ActiveSession objects for the given session IDs.
+  # Lists the session Hash objects for the given session IDs.
   #
   # session_ids - An array of Rack::Session::SessionId objects
   #
@@ -143,7 +143,7 @@ def self.sessions_from_ids(session_ids)
     end
   end
 
-  # Deserializes an ActiveSession object from Redis.
+  # Deserializes a session Hash object from Redis.
   #
   # raw_session - Raw bytes from Redis
   #
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index 78ebbd7bff259..ee1a22634af0b 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -21,6 +21,10 @@ def before_update(issue, skip_spam_check: false)
       spam_check(issue, current_user) unless skip_spam_check
     end
 
+    def after_update(issue)
+      IssuesChannel.broadcast_to(issue, event: 'updated') if Feature.enabled?(:broadcast_issue_updates, issue.project)
+    end
+
     def handle_changes(issue, options)
       old_associations = options.fetch(:old_associations, {})
       old_labels = old_associations.fetch(:labels, [])
diff --git a/config/cable.yml.example b/config/cable.yml.example
new file mode 100644
index 0000000000000..ee3a8da9be8c9
--- /dev/null
+++ b/config/cable.yml.example
@@ -0,0 +1,14 @@
+# This file is used for configuring ActionCable in our CI environment
+# When using GDK or Omnibus, cable.yml is generated from a different template
+development:
+  adapter: redis
+  url: redis://localhost:6379
+  channel_prefix: gitlab_development
+test:
+  adapter: redis
+  url: redis://localhost:6379
+  channel_prefix: gitlab_test
+production:
+  adapter: redis
+  url: unix:/var/run/redis/redis.sock
+  channel_prefix: gitlab_production
diff --git a/lib/quality/test_level.rb b/lib/quality/test_level.rb
index bbd8b4dcc3f62..97b86fa8c2e51 100644
--- a/lib/quality/test_level.rb
+++ b/lib/quality/test_level.rb
@@ -14,6 +14,7 @@ class TestLevel
       ],
       unit: %w[
         bin
+        channels
         config
         db
         dependencies
diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh
index e80d752f09fd0..7bf3f887e976b 100644
--- a/scripts/prepare_build.sh
+++ b/scripts/prepare_build.sh
@@ -33,6 +33,9 @@ if [ -f config/database_geo.yml ]; then
   sed -i 's/username: git/username: postgres/g' config/database_geo.yml
 fi
 
+cp config/cable.yml.example config/cable.yml
+sed -i 's|url:.*$|url: redis://redis:6379|g' config/cable.yml
+
 cp config/resque.yml.example config/resque.yml
 sed -i 's|url:.*$|url: redis://redis:6379|g' config/resque.yml
 
diff --git a/spec/channels/application_cable/connection_spec.rb b/spec/channels/application_cable/connection_spec.rb
new file mode 100644
index 0000000000000..f3d671335282f
--- /dev/null
+++ b/spec/channels/application_cable/connection_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe ApplicationCable::Connection, :clean_gitlab_redis_shared_state do
+  let(:session_id) { Rack::Session::SessionId.new('6919a6f1bb119dd7396fadc38fd18d0d') }
+
+  before do
+    Gitlab::Redis::SharedState.with do |redis|
+      redis.set("session:gitlab:#{session_id.private_id}", Marshal.dump(session_hash))
+    end
+
+    cookies[Gitlab::Application.config.session_options[:key]] = session_id.public_id
+  end
+
+  context 'when user is logged in' do
+    let(:user) { create(:user) }
+    let(:session_hash) { { 'warden.user.user.key' => [[user.id], user.encrypted_password[0, 29]] } }
+
+    it 'sets current_user' do
+      connect
+
+      expect(connection.current_user).to eq(user)
+    end
+
+    context 'with a stale password' do
+      let(:partial_password_hash) { build(:user, password: 'some_old_password').encrypted_password[0, 29] }
+      let(:session_hash) { { 'warden.user.user.key' => [[user.id], partial_password_hash] } }
+
+      it 'sets current_user to nil' do
+        connect
+
+        expect(connection.current_user).to be_nil
+      end
+    end
+  end
+
+  context 'when user is not logged in' do
+    let(:session_hash) { {} }
+
+    it 'sets current_user to nil' do
+      connect
+
+      expect(connection.current_user).to be_nil
+    end
+  end
+end
diff --git a/spec/channels/issues_channel_spec.rb b/spec/channels/issues_channel_spec.rb
new file mode 100644
index 0000000000000..1c88cc734564a
--- /dev/null
+++ b/spec/channels/issues_channel_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe IssuesChannel do
+  let_it_be(:issue) { create(:issue) }
+
+  it 'rejects when project path is invalid' do
+    subscribe(project_path: 'invalid_project_path', iid: issue.iid)
+
+    expect(subscription).to be_rejected
+  end
+
+  it 'rejects when iid is invalid' do
+    subscribe(project_path: issue.project.full_path, iid: non_existing_record_iid)
+
+    expect(subscription).to be_rejected
+  end
+
+  it 'rejects when the user does not have access' do
+    stub_connection current_user: nil
+
+    subscribe(project_path: issue.project.full_path, iid: issue.iid)
+
+    expect(subscription).to be_rejected
+  end
+
+  it 'subscribes to a stream when the user has access' do
+    stub_connection current_user: issue.author
+
+    subscribe(project_path: issue.project.full_path, iid: issue.iid)
+
+    expect(subscription).to be_confirmed
+    expect(subscription).to have_stream_for(issue)
+  end
+end
diff --git a/spec/lib/quality/test_level_spec.rb b/spec/lib/quality/test_level_spec.rb
index 6042ab2478739..b784a92fa8533 100644
--- a/spec/lib/quality/test_level_spec.rb
+++ b/spec/lib/quality/test_level_spec.rb
@@ -21,7 +21,7 @@
     context 'when level is unit' do
       it 'returns a pattern' do
         expect(subject.pattern(:unit))
-          .to eq("spec/{bin,config,db,dependencies,factories,finders,frontend,graphql,haml_lint,helpers,initializers,javascripts,lib,models,policies,presenters,rack_servers,replicators,routing,rubocop,serializers,services,sidekiq,support_specs,tasks,uploaders,validators,views,workers,elastic_integration}{,/**/}*_spec.rb")
+          .to eq("spec/{bin,channels,config,db,dependencies,factories,finders,frontend,graphql,haml_lint,helpers,initializers,javascripts,lib,models,policies,presenters,rack_servers,replicators,routing,rubocop,serializers,services,sidekiq,support_specs,tasks,uploaders,validators,views,workers,elastic_integration}{,/**/}*_spec.rb")
       end
     end
 
@@ -89,7 +89,7 @@
     context 'when level is unit' do
       it 'returns a regexp' do
         expect(subject.regexp(:unit))
-          .to eq(%r{spec/(bin|config|db|dependencies|factories|finders|frontend|graphql|haml_lint|helpers|initializers|javascripts|lib|models|policies|presenters|rack_servers|replicators|routing|rubocop|serializers|services|sidekiq|support_specs|tasks|uploaders|validators|views|workers|elastic_integration)})
+          .to eq(%r{spec/(bin|channels|config|db|dependencies|factories|finders|frontend|graphql|haml_lint|helpers|initializers|javascripts|lib|models|policies|presenters|rack_servers|replicators|routing|rubocop|serializers|services|sidekiq|support_specs|tasks|uploaders|validators|views|workers|elastic_integration)})
       end
     end
 
diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb
index c32bef5a1a578..556a0d605d59e 100644
--- a/spec/services/issues/update_service_spec.rb
+++ b/spec/services/issues/update_service_spec.rb
@@ -842,5 +842,33 @@ def update_issue(opts)
       let(:open_issuable) { issue }
       let(:closed_issuable) { create(:closed_issue, project: project) }
     end
+
+    context 'real-time updates' do
+      let(:update_params) { { assignee_ids: [user2.id] } }
+
+      context 'when broadcast_issue_updates is enabled' do
+        before do
+          stub_feature_flags(broadcast_issue_updates: true)
+        end
+
+        it 'broadcasts to the issues channel' do
+          expect(IssuesChannel).to receive(:broadcast_to).with(issue, event: 'updated')
+
+          update_issue(update_params)
+        end
+      end
+
+      context 'when broadcast_issue_updates is disabled' do
+        before do
+          stub_feature_flags(broadcast_issue_updates: false)
+        end
+
+        it 'does not broadcast to the issues channel' do
+          expect(IssuesChannel).not_to receive(:broadcast_to)
+
+          update_issue(update_params)
+        end
+      end
+    end
   end
 end
-- 
GitLab