Commit 72567d06 authored by Mayra Cabrera's avatar Mayra Cabrera

Merge branch '9643-hooks-to-sync-to-jira-connect' into 'master'

Add hooks to sync dev info to Jira using Connect App

See merge request gitlab-org/gitlab-ee!14886
parents f33bb5fc aae86bd2
...@@ -113,3 +113,4 @@ ...@@ -113,3 +113,4 @@
- [elastic_namespace_indexer, 1] - [elastic_namespace_indexer, 1]
- [export_csv, 1] - [export_csv, 1]
- [incident_management, 2] - [incident_management, 2]
- [jira_connect, 1]
...@@ -11,4 +11,12 @@ class JiraConnectInstallation < ApplicationRecord ...@@ -11,4 +11,12 @@ class JiraConnectInstallation < ApplicationRecord
validates :client_key, presence: true, uniqueness: true validates :client_key, presence: true, uniqueness: true
validates :shared_secret, presence: true validates :shared_secret, presence: true
validates :base_url, presence: true, public_url: true validates :base_url, presence: true, public_url: true
scope :for_project, -> (project) {
distinct
.joins(:subscriptions)
.where(jira_connect_subscriptions: {
id: JiraConnectSubscription.for_project(project)
})
}
end end
...@@ -8,4 +8,5 @@ class JiraConnectSubscription < ApplicationRecord ...@@ -8,4 +8,5 @@ class JiraConnectSubscription < ApplicationRecord
validates :namespace, presence: true, uniqueness: { scope: :jira_connect_installation_id, message: 'has already been added' } validates :namespace, presence: true, uniqueness: { scope: :jira_connect_installation_id, message: 'has already been added' }
scope :preload_namespace_route, -> { preload(namespace: :route) } scope :preload_namespace_route, -> { preload(namespace: :route) }
scope :for_project, -> (project) { where(namespace_id: project.namespace.self_and_ancestors) }
end end
...@@ -7,6 +7,26 @@ module EE ...@@ -7,6 +7,26 @@ module EE
private private
override :branch_change_hooks
def branch_change_hooks
super
return unless jira_subscription_exists?
branch_to_sync = branch_name if Atlassian::JiraIssueKeyExtractor.has_keys?(branch_name)
commits_to_sync = limited_commits.select { |commit| Atlassian::JiraIssueKeyExtractor.has_keys?(commit.safe_message) }.map(&:hash)
if branch_to_sync || commits_to_sync.any?
JiraConnect::SyncBranchWorker.perform_async(project.id, branch_to_sync, commits_to_sync)
end
end
def jira_subscription_exists?
::Feature.enabled?(:jira_connect_app) &&
project.feature_available?(:jira_dev_panel_integration) &&
JiraConnectSubscription.for_project(project).exists?
end
override :pipeline_options override :pipeline_options
def pipeline_options def pipeline_options
mirror_update = project.mirror? && mirror_update = project.mirror? &&
......
...@@ -3,10 +3,30 @@ ...@@ -3,10 +3,30 @@
module EE module EE
module MergeRequests module MergeRequests
module BaseService module BaseService
extend ::Gitlab::Utils::Override
override :execute_hooks
def execute_hooks(merge_request, action = 'open', old_rev: nil, old_associations: {})
super
return unless merge_request.project && jira_subscription_exists?
if Atlassian::JiraIssueKeyExtractor.has_keys?(merge_request.title, merge_request.description)
JiraConnect::SyncMergeRequestWorker.perform_async(merge_request.id)
end
end
private private
attr_accessor :blocking_merge_requests_params attr_accessor :blocking_merge_requests_params
def jira_subscription_exists?
::Feature.enabled?(:jira_connect_app) &&
project.feature_available?(:jira_dev_panel_integration) &&
JiraConnectSubscription.for_project(project).exists?
end
override :filter_params
def filter_params(merge_request) def filter_params(merge_request)
unless current_user.can?(:update_approvers, merge_request) unless current_user.can?(:update_approvers, merge_request)
params.delete(:approvals_before_merge) params.delete(:approvals_before_merge)
......
# frozen_string_literal: true
module JiraConnect
class SyncService
def initialize(project)
self.project = project
end
def execute(commits: nil, branches: nil, merge_requests: nil)
JiraConnectInstallation.for_project(project).each do |installation|
client = Atlassian::JiraConnect::Client.new(installation.base_url, installation.shared_secret)
response = client.store_dev_info(project: project, commits: commits, branches: branches, merge_requests: merge_requests)
log_response(response)
end
end
private
attr_accessor :project
def log_response(response)
message = {
integration: 'JiraConnect',
project_id: project.id,
project_path: project.full_path,
response: response
}
if response && response['errorMessages']
logger.error(message)
else
logger.info(message)
end
end
def logger
Gitlab::ProjectServiceLogger
end
end
end
...@@ -51,6 +51,9 @@ ...@@ -51,6 +51,9 @@
- incident_management:incident_management_process_alert - incident_management:incident_management_process_alert
- jira_connect:jira_connect_sync_branch
- jira_connect:jira_connect_sync_merge_request
- admin_emails - admin_emails
- create_github_webhook - create_github_webhook
- elastic_batch_project_indexer - elastic_batch_project_indexer
......
# frozen_string_literal: true
module JiraConnect
class SyncBranchWorker
include ApplicationWorker
queue_namespace :jira_connect
def perform(project_id, branch_name, commit_shas)
project = Project.find_by_id(project_id)
return unless project
branches = [project.repository.find_branch(branch_name)] if branch_name.present?
commits = project.commits_by(oids: commit_shas) if commit_shas.present?
JiraConnect::SyncService.new(project).execute(commits: commits, branches: branches)
end
end
end
# frozen_string_literal: true
module JiraConnect
class SyncMergeRequestWorker
include ApplicationWorker
queue_namespace :jira_connect
def perform(merge_request_id)
merge_request = MergeRequest.find_by_id(merge_request_id)
return unless merge_request && merge_request.project
JiraConnect::SyncService.new(merge_request.project).execute(merge_requests: [merge_request])
end
end
end
...@@ -4,7 +4,7 @@ module Atlassian ...@@ -4,7 +4,7 @@ module Atlassian
module JiraConnect module JiraConnect
module Serializers module Serializers
class BranchEntity < BaseEntity class BranchEntity < BaseEntity
expose :target, as: :id expose :name, as: :id
expose :issueKeys do |branch| expose :issueKeys do |branch|
JiraIssueKeyExtractor.new(branch.name).issue_keys JiraIssueKeyExtractor.new(branch.name).issue_keys
end end
......
...@@ -2,6 +2,10 @@ ...@@ -2,6 +2,10 @@
module Atlassian module Atlassian
class JiraIssueKeyExtractor class JiraIssueKeyExtractor
def self.has_keys?(*text)
new(*text).issue_keys.any?
end
def initialize(*text) def initialize(*text)
@text = text.join(' ') @text = text.join(' ')
end end
......
...@@ -3,6 +3,22 @@ ...@@ -3,6 +3,22 @@
require 'fast_spec_helper' require 'fast_spec_helper'
describe Atlassian::JiraIssueKeyExtractor do describe Atlassian::JiraIssueKeyExtractor do
describe '.has_keys?' do
subject { described_class.has_keys?(string) }
context 'when string contains Jira issue keys' do
let(:string) { 'Test some string TEST-01 with keys' }
it { is_expected.to eq(true) }
end
context 'when string does not contain Jira issue keys' do
let(:string) { 'string with no jira issue keys' }
it { is_expected.to eq(false) }
end
end
describe '#issue_keys' do describe '#issue_keys' do
subject { described_class.new('TEST-01 Some A-100 issue title OTHER-02 ABC!-1 that mentions Jira issue').issue_keys } subject { described_class.new('TEST-01 Some A-100 issue title OTHER-02 ABC!-1 that mentions Jira issue').issue_keys }
......
...@@ -16,4 +16,30 @@ describe JiraConnectInstallation do ...@@ -16,4 +16,30 @@ describe JiraConnectInstallation do
it { is_expected.to allow_value('https://test.atlassian.net').for(:base_url) } it { is_expected.to allow_value('https://test.atlassian.net').for(:base_url) }
it { is_expected.not_to allow_value('not/a/url').for(:base_url) } it { is_expected.not_to allow_value('not/a/url').for(:base_url) }
end end
describe '.for_project' do
let(:other_group) { create(:group) }
let(:parent_group) { create(:group) }
let(:group) { create(:group, parent: parent_group) }
let(:project) { create(:project, group: group) }
subject { described_class.for_project(project) }
it 'returns installations with subscriptions for project' do
sub_on_project_namespace = create(:jira_connect_subscription, namespace: group)
sub_on_ancestor_namespace = create(:jira_connect_subscription, namespace: parent_group)
# Subscription on other group that shouldn't be returned
create(:jira_connect_subscription, namespace: other_group)
expect(subject).to contain_exactly(sub_on_project_namespace.installation, sub_on_ancestor_namespace.installation)
end
it 'returns distinct installations' do
subscription = create(:jira_connect_subscription, namespace: group)
create(:jira_connect_subscription, namespace: parent_group, installation: subscription.installation)
expect(subject).to contain_exactly(subscription.installation)
end
end
end end
...@@ -9,13 +9,13 @@ describe Git::BranchPushService do ...@@ -9,13 +9,13 @@ describe Git::BranchPushService do
let(:newrev) { sample_commit.id } let(:newrev) { sample_commit.id }
let(:ref) { 'refs/heads/master' } let(:ref) { 'refs/heads/master' }
subject do
described_class.new(project, user, oldrev: oldrev, newrev: newrev, ref: ref)
end
context 'with pull project' do context 'with pull project' do
set(:project) { create(:project, :repository, :mirror) } set(:project) { create(:project, :repository, :mirror) }
subject do
described_class.new(project, user, oldrev: oldrev, newrev: newrev, ref: ref)
end
before do before do
allow(project.repository).to receive(:commit).and_call_original allow(project.repository).to receive(:commit).and_call_original
allow(project.repository).to receive(:commit).with("master").and_return(nil) allow(project.repository).to receive(:commit).with("master").and_return(nil)
...@@ -109,6 +109,82 @@ describe Git::BranchPushService do ...@@ -109,6 +109,82 @@ describe Git::BranchPushService do
end end
end end
context 'Jira Connect hooks' do
set(:project) { create(:project, :repository) }
shared_examples 'enqueues Jira sync worker' do
it do
Sidekiq::Testing.fake! do
expect { subject.execute }.to change(JiraConnect::SyncBranchWorker.jobs, :size).by(1)
end
end
end
shared_examples 'does not enqueue Jira sync worker' do
it do
Sidekiq::Testing.fake! do
expect { subject.execute }.not_to change(JiraConnect::SyncBranchWorker.jobs, :size)
end
end
end
context 'when feature is enabled' do
before do
stub_feature_flags(jira_connect_app: true)
end
context 'has Jira dev panel integration license' do
before do
stub_licensed_features(jira_dev_panel_integration: true)
end
context 'with a Jira subscription' do
before do
create(:jira_connect_subscription, namespace: project.namespace)
end
context 'branch name contains Jira issue key' do
let(:ref) { 'refs/heads/branch-JIRA-123' }
it_behaves_like 'enqueues Jira sync worker'
end
context 'commit message contains Jira issue key' do
before do
allow_any_instance_of(Commit).to receive(:safe_message).and_return('Commit with key JIRA-123')
end
it_behaves_like 'enqueues Jira sync worker'
end
context 'branch name and commit message does not contain Jira issue key' do
it_behaves_like 'does not enqueue Jira sync worker'
end
end
context 'without a Jira subscription' do
it_behaves_like 'does not enqueue Jira sync worker'
end
end
context 'does not have Jira dev panel integration license' do
before do
stub_licensed_features(jira_dev_panel_integration: false)
end
it_behaves_like 'does not enqueue Jira sync worker'
end
end
context 'when feature is disabled' do
before do
stub_feature_flags(jira_connect_app: false)
end
it_behaves_like 'does not enqueue Jira sync worker'
end
end
def execute_service(project, user, oldrev, newrev, ref) def execute_service(project, user, oldrev, newrev, ref)
service = described_class.new(project, user, oldrev: oldrev, newrev: newrev, ref: ref) service = described_class.new(project, user, oldrev: oldrev, newrev: newrev, ref: ref)
service.execute service.execute
......
...@@ -5,20 +5,88 @@ require 'spec_helper' ...@@ -5,20 +5,88 @@ require 'spec_helper'
describe MergeRequests::BaseService do describe MergeRequests::BaseService do
include ProjectForksHelper include ProjectForksHelper
subject { MergeRequests::CreateService.new(project, project.owner, params) } set(:project) { create(:project, :repository) }
let(:title) { 'Awesome merge_request' }
let(:project) { create(:project, :repository) }
let(:params_filtering_service) { double(:params_filtering_service) }
let(:params) do let(:params) do
{ {
title: 'Awesome merge_request', title: title,
description: 'please fix', description: 'please fix',
source_branch: 'feature', source_branch: 'feature',
target_branch: 'master' target_branch: 'master'
} }
end end
subject { MergeRequests::CreateService.new(project, project.owner, params) }
describe '#execute_hooks' do
shared_examples 'enqueues Jira sync worker' do
it do
Sidekiq::Testing.fake! do
expect { subject.execute }.to change(JiraConnect::SyncMergeRequestWorker.jobs, :size).by(1)
end
end
end
shared_examples 'does not enqueue Jira sync worker' do
it do
Sidekiq::Testing.fake! do
expect { subject.execute }.not_to change(JiraConnect::SyncMergeRequestWorker.jobs, :size)
end
end
end
context 'when feature is enabled' do
before do
stub_feature_flags(jira_connect_app: true)
end
context 'has Jira dev panel integration license' do
before do
stub_licensed_features(jira_dev_panel_integration: true)
end
context 'with a Jira subscription' do
before do
create(:jira_connect_subscription, namespace: project.namespace)
end
context 'MR contains Jira issue key' do
let(:title) { 'Awesome merge_request with issue JIRA-123' }
it_behaves_like 'enqueues Jira sync worker'
end
context 'MR does not contain Jira issue key' do
it_behaves_like 'does not enqueue Jira sync worker'
end
end
context 'without a Jira subscription' do
it_behaves_like 'does not enqueue Jira sync worker'
end
end
context 'does not have Jira dev panel integration license' do
before do
stub_licensed_features(jira_dev_panel_integration: false)
end
it_behaves_like 'does not enqueue Jira sync worker'
end
end
context 'when feature is disabled' do
before do
stub_feature_flags(jira_connect_app: false)
end
it_behaves_like 'does not enqueue Jira sync worker'
end
end
describe '#filter_params' do describe '#filter_params' do
let(:params_filtering_service) { double(:params_filtering_service) }
context 'filter users and groups' do context 'filter users and groups' do
before do before do
allow(subject).to receive(:execute_hooks) allow(subject).to receive(:execute_hooks)
......
# frozen_string_literal: true
require 'spec_helper'
describe JiraConnect::SyncService do
describe '#execute' do
set(:project) { create(:project, :repository) }
let(:branches) { [project.repository.find_branch('master')] }
let(:commits) { project.commits_by(oids: %w[b83d6e3 5a62481]) }
let(:merge_requests) { [create(:merge_request, source_project: project, target_project: project)] }
subject do
described_class.new(project).execute(commits: commits, branches: branches, merge_requests: merge_requests)
end
before do
create(:jira_connect_subscription, namespace: project.namespace)
end
def expect_jira_client_call(return_value = { 'status': 'success' })
expect_any_instance_of(Atlassian::JiraConnect::Client)
.to receive(:store_dev_info).with(
project: project,
commits: commits,
branches: [instance_of(Gitlab::Git::Branch)],
merge_requests: merge_requests
).and_return(return_value)
end
def expect_log(type, message)
expect(Gitlab::ProjectServiceLogger)
.to receive(type).with(
integration: 'JiraConnect',
project_id: project.id,
project_path: project.full_path,
response: message
)
end
it 'calls Atlassian::JiraConnect::Client#store_dev_info and logs the response' do
expect_jira_client_call
expect_log(:info, { 'status': 'success' })
subject
end
context 'when request returns an error' do
it 'logs the response as an error' do
expect_jira_client_call({
'errorMessages' => ['some error message']
})
expect_log(:error, { 'errorMessages' => ['some error message'] })
subject
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe JiraConnect::SyncBranchWorker do
describe '#perform' do
set(:project) { create(:project, :repository) }
let(:project_id) { project.id }
let(:branch_name) { 'master' }
let(:commit_shas) { %w(b83d6e3 5a62481) }
subject { described_class.new.perform(project_id, branch_name, commit_shas) }
def expect_jira_sync_service_execute(args)
expect_any_instance_of(JiraConnect::SyncService)
.to receive(:execute).with(args)
end
it 'calls JiraConnect::SyncService#execute' do
expect_jira_sync_service_execute(
branches: [instance_of(Gitlab::Git::Branch)],
commits: project.commits_by(oids: commit_shas)
)
subject
end
context 'without branch name' do
let(:branch_name) { nil }
it 'calls JiraConnect::SyncService#execute' do
expect_jira_sync_service_execute(
branches: nil,
commits: project.commits_by(oids: commit_shas)
)
subject
end
end
context 'without commits' do
let(:commit_shas) { nil }
it 'calls JiraConnect::SyncService#execute' do
expect_jira_sync_service_execute(
branches: [instance_of(Gitlab::Git::Branch)],
commits: nil
)
subject
end
end
context 'when project no longer exists' do
let(:project_id) { Project.maximum(:id).to_i + 1 }
it 'does not call JiraConnect::SyncService' do
expect(JiraConnect::SyncService).not_to receive(:new)
subject
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe JiraConnect::SyncMergeRequestWorker do
describe '#perform' do
let(:merge_request) { create(:merge_request) }
let(:merge_request_id) { merge_request.id }
subject { described_class.new.perform(merge_request_id) }
it 'calls JiraConnect::SyncService#execute' do
expect_next_instance_of(JiraConnect::SyncService) do |service|
expect(service).to receive(:execute).with(merge_requests: [merge_request])
end
subject
end
context 'when MR no longer exists' do
let(:merge_request_id) { MergeRequest.maximum(:id).to_i + 1 }
it 'does not call JiraConnect::SyncService' do
expect(JiraConnect::SyncService).not_to receive(:new)
subject
end
end
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