Commit 24fa3555 authored by Alex Kalderimis's avatar Alex Kalderimis

Sync build information to Jira with JiraConnect

This adds support for the `build` module for JiraConnect, and
synchronizes pipeline status for linked Jira issues.

Specifically:

- we add a new section to the application descriptor (requiring an
  update of our installed JiraConnect app)
- we add a new worker triggered by changes in status of pipelines
- for every pipeline that is the head-pipeline of a relevant MR
  (determined by the presence of Jira issue keys in either the branch
  name or the title) we send that status information to Jira.

Some notes:

- The concept of a build seems to map most closely to the GitLab concept
  of a pipeline, so that is the unit of synchronization.
- The Jira API allows the number of 'tests' in a build to be reported,
  with a breakdown by pass/fail/skip. Currently this is implemented to
  be the number of Ci::Builds in a pipeline - since we cannot guarantee
  that exact test numbers are available. This may be subject to change,
  or at least upgrade for test frameworks for which we have breakdown
  stats.
parent bdb2a9d1
......@@ -27,29 +27,9 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
authentication: {
type: 'jwt'
},
modules: modules,
scopes: %w(READ WRITE DELETE),
apiVersion: 1,
modules: {
jiraDevelopmentTool: {
key: 'gitlab-development-tool',
application: {
value: 'GitLab'
},
name: {
value: 'GitLab'
},
url: 'https://gitlab.com',
logoUrl: view_context.image_url('gitlab_logo.png'),
capabilities: %w(branch commit pull_request)
},
postInstallPage: {
key: 'gitlab-configuration',
name: {
value: 'GitLab Configuration'
},
url: relative_to_base_path(jira_connect_subscriptions_path)
}
},
apiMigrations: {
gdpr: true
}
......@@ -58,6 +38,55 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
private
HOME_URL = 'https://gitlab.com'
DOC_URL = 'https://docs.gitlab.com/ee/user/project/integrations/jira.html#gitlab-jira-integration'
def modules
modules = {
jiraDevelopmentTool: {
key: 'gitlab-development-tool',
application: {
value: 'GitLab'
},
name: {
value: 'GitLab'
},
url: HOME_URL,
logoUrl: logo_url,
capabilities: %w(branch commit pull_request)
},
postInstallPage: {
key: 'gitlab-configuration',
name: {
value: 'GitLab Configuration'
},
url: relative_to_base_path(jira_connect_subscriptions_path)
}
}
modules.merge!(build_information_module)
modules
end
def logo_url
view_context.image_url('gitlab_logo.png')
end
# See: https://developer.atlassian.com/cloud/jira/software/modules/build/
def build_information_module
{
jiraBuildInfoProvider: {
homeUrl: HOME_URL,
logoUrl: logo_url,
documentationUrl: DOC_URL,
actions: {},
name: { value: "GitLab CI" },
key: "gitlab-ci"
}
}
end
def relative_to_base_path(full_path)
full_path.sub(/^#{jira_connect_base_path}/, '')
end
......
......@@ -259,6 +259,16 @@ module Ci
end
end
after_transition any => any do |pipeline|
next unless Feature.enabled?(:jira_sync_builds, pipeline.project)
pipeline.run_after_commit do
# Passing the seq-id ensures this is idempotent
seq_id = ::Atlassian::JiraConnect::Client.generate_update_sequence_id
::JiraConnect::SyncBuildsWorker.perform_async(pipeline.id, seq_id)
end
end
after_transition any => [:success, :failed] do |pipeline|
ref_status = pipeline.ci_ref&.update_status_by!(pipeline)
......
......@@ -6,13 +6,15 @@ module JiraConnect
self.project = project
end
def execute(commits: nil, branches: nil, merge_requests: nil, update_sequence_id: nil)
JiraConnectInstallation.for_project(project).each do |installation|
# Parameters: see Atlassian::JiraConnect::Client#send_info
# Includes: update_sequence_id, commits, branches, merge_requests, pipelines
def execute(**args)
JiraConnectInstallation.for_project(project).flat_map 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, update_sequence_id: update_sequence_id)
responses = client.send_info(project: project, **args)
log_response(response)
responses.each { |r| log_response(r) }
end
end
......@@ -29,7 +31,7 @@ module JiraConnect
jira_response: response&.to_json
}
if response && response['errorMessages']
if response && (response['errorMessages'] || response['rejectedBuilds'].present?)
logger.error(message)
else
logger.info(message)
......
......@@ -877,15 +877,23 @@
:tags: []
- :name: jira_connect:jira_connect_sync_branch
:feature_category: :integrations
:has_external_dependencies:
:has_external_dependencies: true
:urgency: :low
:resource_boundary: :unknown
:weight: 1
:idempotent:
:tags: []
- :name: jira_connect:jira_connect_sync_builds
:feature_category: :integrations
:has_external_dependencies: true
:urgency: :low
:resource_boundary: :unknown
:weight: 1
:idempotent: true
:tags: []
- :name: jira_connect:jira_connect_sync_merge_request
:feature_category: :integrations
:has_external_dependencies:
:has_external_dependencies: true
:urgency: :low
:resource_boundary: :unknown
:weight: 1
......
......@@ -7,6 +7,7 @@ module JiraConnect
queue_namespace :jira_connect
feature_category :integrations
loggable_arguments 1, 2
worker_has_external_dependencies!
def perform(project_id, branch_name, commit_shas, update_sequence_id = nil)
project = Project.find_by_id(project_id)
......
# frozen_string_literal: true
module JiraConnect
class SyncBuildsWorker
include ApplicationWorker
idempotent!
worker_has_external_dependencies!
queue_namespace :jira_connect
feature_category :integrations
def perform(pipeline_id, sequence_id)
pipeline = Ci::Pipeline.find_by_id(pipeline_id)
return unless pipeline
return unless Feature.enabled?(:jira_sync_builds, pipeline.project)
::JiraConnect::SyncService
.new(pipeline.project)
.execute(pipelines: [pipeline], update_sequence_id: sequence_id)
end
end
end
......@@ -7,6 +7,8 @@ module JiraConnect
queue_namespace :jira_connect
feature_category :integrations
worker_has_external_dependencies!
def perform(merge_request_id, update_sequence_id = nil)
merge_request = MergeRequest.find_by_id(merge_request_id)
......
---
name: jira_sync_builds
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49348
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/292013
milestone: '13.7'
type: development
group: group::ecosystem
default_enabled: false
......@@ -12,31 +12,68 @@ module Atlassian
@shared_secret = shared_secret
end
def send_info(project:, update_sequence_id: nil, **args)
common = { project: project, update_sequence_id: update_sequence_id }
dev_info = args.slice(:commits, :branches, :merge_requests)
build_info = args.slice(:pipelines)
responses = []
responses << store_dev_info(**common, **dev_info) if dev_info.present?
responses << store_build_info(**common, **build_info) if build_info.present?
raise ArgumentError, 'Invalid arguments' if responses.empty?
responses.compact
end
private
def store_build_info(project:, pipelines:, update_sequence_id: nil)
return unless Feature.enabled?(:jira_sync_builds, project)
builds = pipelines.map do |pipeline|
build = Serializers::BuildEntity.represent(
pipeline,
update_sequence_id: update_sequence_id
)
next if build.issue_keys.empty?
build
end.compact
return if builds.empty?
post('/rest/builds/0.1/bulk', { builds: builds })
end
def store_dev_info(project:, commits: nil, branches: nil, merge_requests: nil, update_sequence_id: nil)
dev_info_json = {
repositories: [
Serializers::RepositoryEntity.represent(
project,
commits: commits,
branches: branches,
merge_requests: merge_requests,
user_notes_count: user_notes_count(merge_requests),
update_sequence_id: update_sequence_id
)
]
}.to_json
uri = URI.join(@base_uri, '/rest/devinfo/0.10/bulk')
headers = {
repo = Serializers::RepositoryEntity.represent(
project,
commits: commits,
branches: branches,
merge_requests: merge_requests,
user_notes_count: user_notes_count(merge_requests),
update_sequence_id: update_sequence_id
)
post('/rest/devinfo/0.10/bulk', { repositories: [repo] })
end
def post(path, payload)
uri = URI.join(@base_uri, path)
self.class.post(uri, headers: headers(uri), body: metadata.merge(payload).to_json)
end
def headers(uri)
{
'Authorization' => "JWT #{jwt_token('POST', uri)}",
'Content-Type' => 'application/json'
}
self.class.post(uri, headers: headers, body: dev_info_json)
end
private
def metadata
{ providerMetadata: { product: "GitLab #{Gitlab::VERSION}" } }
end
def user_notes_count(merge_requests)
return unless merge_requests
......
......@@ -11,6 +11,12 @@ module Atlassian
expose :update_sequence_id, as: :updateSequenceId
def eql(other)
other.is_a?(self.class) && to_json == other.to_json
end
alias_method :==, :eql
private
def update_sequence_id
......
# frozen_string_literal: true
module Atlassian
module JiraConnect
module Serializers
# A Jira 'build' represents what we call a 'pipeline'
class BuildEntity < Grape::Entity
include Gitlab::Routing
format_with(:iso8601, &:iso8601)
expose :schema_version, as: :schemaVersion
expose :pipeline_id, as: :pipelineId
expose :iid, as: :buildNumber
expose :update_sequence_id, as: :updateSequenceNumber
expose :source_ref, as: :displayName
expose :url
expose :state
expose :updated_at, as: :lastUpdated, format_with: :iso8601
expose :issue_keys, as: :issueKeys
expose :test_info, as: :testInfo
expose :references
def issue_keys
# extract Jira issue keys from either the source branch/ref or the
# merge request title.
@issue_keys ||= begin
src = "#{pipeline.source_ref} #{pipeline.merge_request&.title}"
JiraIssueKeyExtractor.new(src).issue_keys
end
end
private
alias_method :pipeline, :object
delegate :project, to: :object
def url
project_pipeline_url(project, pipeline)
end
# translate to Jira status
def state
case pipeline.status
when 'scheduled', 'created', 'pending', 'preparing', 'waiting_for_resource' then 'pending'
when 'running' then 'in_progress'
when 'success' then 'successful'
when 'failed' then 'failed'
when 'canceled', 'skipped' then 'cancelled'
else
'unknown'
end
end
def pipeline_id
pipeline.ensure_ci_ref!
pipeline.ci_ref.id.to_s
end
def schema_version
'1.0'
end
def test_info
builds = pipeline.builds.pluck(:status) # rubocop: disable CodeReuse/ActiveRecord
n = builds.size
passed = builds.count { |s| s == 'success' }
failed = builds.count { |s| s == 'failed' }
{
totalNumber: n,
numberPassed: passed,
numberFailed: failed,
numberSkipped: n - (passed + failed)
}
end
def references
ref = pipeline.source_ref
[{
commit: { id: pipeline.sha, repositoryUri: project_url(project) },
ref: { name: ref, uri: project_commits_url(project, ref) }
}]
end
def update_sequence_id
options[:update_sequence_id] || Client.generate_update_sequence_id
end
end
end
end
end
......@@ -24,6 +24,14 @@ FactoryBot.define do
trait :with_diffs do
end
trait :jira_title do
title { generate(:jira_title) }
end
trait :jira_branch do
source_branch { generate(:jira_branch) }
end
trait :with_image_diffs do
source_branch { "add_images_and_changes" }
target_branch { "master" }
......
......@@ -15,4 +15,6 @@ FactoryBot.define do
sequence(:sha) { |n| Digest::SHA1.hexdigest("commit-like-#{n}") }
sequence(:oid) { |n| Digest::SHA2.hexdigest("oid-like-#{n}") }
sequence(:variable) { |n| "var#{n}" }
sequence(:jira_title) { |n| "[PROJ-#{n}]: fix bug" }
sequence(:jira_branch) { |n| "feature/PROJ-#{n}" }
end
......@@ -7,6 +7,8 @@ RSpec.describe Atlassian::JiraConnect::Client do
subject { described_class.new('https://gitlab-test.atlassian.net', 'sample_secret') }
let_it_be(:project) { create_default(:project, :repository) }
around do |example|
freeze_time { example.run }
end
......@@ -19,41 +21,158 @@ RSpec.describe Atlassian::JiraConnect::Client do
end
end
describe '#store_dev_info' do
let_it_be(:project) { create_default(:project, :repository) }
let_it_be(:merge_requests) { create_list(:merge_request, 2, :unique_branches) }
describe '#send_info' do
it 'calls store_build_info and store_dev_info as appropriate' do
expect(subject).to receive(:store_build_info).with(
project: project,
update_sequence_id: :x,
pipelines: :y
).and_return(:build_stored)
expect(subject).to receive(:store_dev_info).with(
project: project,
update_sequence_id: :x,
commits: :a,
branches: :b,
merge_requests: :c
).and_return(:dev_stored)
args = {
project: project,
update_sequence_id: :x,
commits: :a,
branches: :b,
merge_requests: :c,
pipelines: :y
}
expect(subject.send_info(**args)).to contain_exactly(:dev_stored, :build_stored)
end
let(:expected_jwt) do
Atlassian::Jwt.encode(
Atlassian::Jwt.build_claims(
Atlassian::JiraConnect.app_key,
'/rest/devinfo/0.10/bulk',
'POST'
),
'sample_secret'
)
it 'only calls methods that we need to call' do
expect(subject).to receive(:store_dev_info).with(
project: project,
update_sequence_id: :x,
commits: :a
).and_return(:dev_stored)
args = {
project: project,
update_sequence_id: :x,
commits: :a
}
expect(subject.send_info(**args)).to contain_exactly(:dev_stored)
end
it 'raises an argument error if there is nothing to send (probably a typo?)' do
expect { subject.send_info(project: project, builds: :x) }
.to raise_error(ArgumentError)
end
end
def expected_headers(path)
expected_jwt = Atlassian::Jwt.encode(
Atlassian::Jwt.build_claims(Atlassian::JiraConnect.app_key, path, 'POST'),
'sample_secret'
)
{
'Authorization' => "JWT #{expected_jwt}",
'Content-Type' => 'application/json'
}
end
describe '#store_build_info' do
let_it_be(:mrs_by_title) { create_list(:merge_request, 4, :unique_branches, :jira_title) }
let_it_be(:mrs_by_branch) { create_list(:merge_request, 2, :jira_branch) }
let_it_be(:red_herrings) { create_list(:merge_request, 1, :unique_branches) }
let_it_be(:pipelines) do
(red_herrings + mrs_by_branch + mrs_by_title).map do |mr|
create(:ci_pipeline, merge_request: mr)
end
end
let(:build_info_payload_schema) do
Atlassian::Schemata.build_info_payload
end
let(:body) do
matcher = be_valid_json.according_to_schema(build_info_payload_schema)
->(text) { matcher.matches?(text) }
end
before do
stub_full_request('https://gitlab-test.atlassian.net/rest/devinfo/0.10/bulk', method: :post)
.with(
headers: {
'Authorization' => "JWT #{expected_jwt}",
'Content-Type' => 'application/json'
}
)
path = '/rest/builds/0.1/bulk'
stub_full_request('https://gitlab-test.atlassian.net' + path, method: :post)
.with(body: body, headers: expected_headers(path))
end
it "calls the API with auth headers" do
subject.send(:store_build_info, project: project, pipelines: pipelines)
end
it 'only sends information about relevant MRs' do
expect(subject).to receive(:post).with('/rest/builds/0.1/bulk', { builds: have_attributes(size: 6) })
subject.send(:store_build_info, project: project, pipelines: pipelines)
end
it 'does not call the API if there is nothing to report' do
expect(subject).not_to receive(:post)
subject.send(:store_build_info, project: project, pipelines: pipelines.take(1))
end
it 'does not call the API if the feature flag is not enabled' do
stub_feature_flags(jira_sync_builds: false)
expect(subject).not_to receive(:post)
subject.send(:store_build_info, project: project, pipelines: pipelines)
end
it 'does call the API if the feature flag enabled for the project' do
stub_feature_flags(jira_sync_builds: project)
expect(subject).to receive(:post).with('/rest/builds/0.1/bulk', { builds: Array })
subject.send(:store_build_info, project: project, pipelines: pipelines)
end
it 'avoids N+1 database queries' do
baseline = ActiveRecord::QueryRecorder.new do
subject.send(:store_build_info, project: project, pipelines: pipelines)
end
pipelines << create(:ci_pipeline, head_pipeline_of: create(:merge_request, :jira_branch))
expect { subject.send(:store_build_info, project: project, pipelines: pipelines) }.not_to exceed_query_limit(baseline)
end
end
describe '#store_dev_info' do
let_it_be(:merge_requests) { create_list(:merge_request, 2, :unique_branches) }
before do
path = '/rest/devinfo/0.10/bulk'
stub_full_request('https://gitlab-test.atlassian.net' + path, method: :post)
.with(headers: expected_headers(path))
end
it "calls the API with auth headers" do
subject.store_dev_info(project: project)
subject.send(:store_dev_info, project: project)
end
it 'avoids N+1 database queries' do
control_count = ActiveRecord::QueryRecorder.new { subject.store_dev_info(project: project, merge_requests: merge_requests) }.count
control_count = ActiveRecord::QueryRecorder.new { subject.send(:store_dev_info, project: project, merge_requests: merge_requests) }.count
merge_requests << create(:merge_request, :unique_branches)
expect { subject.store_dev_info(project: project, merge_requests: merge_requests) }.not_to exceed_query_limit(control_count)
expect { subject.send(:store_dev_info, project: project, merge_requests: merge_requests) }.not_to exceed_query_limit(control_count)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Atlassian::JiraConnect::Serializers::BuildEntity do
let_it_be(:user) { create_default(:user) }
let_it_be(:project) { create_default(:project) }
subject { described_class.represent(pipeline) }
context 'when the pipeline does not belong to any Jira issue' do
let_it_be(:pipeline) { create(:ci_pipeline) }
describe '#issue_keys' do
it 'is empty' do
expect(subject.issue_keys).to be_empty
end
end
describe '#to_json' do
it 'can encode the object' do
expect(subject.to_json).to be_valid_json
end
it 'is invalid, since it has no issue keys' do
expect(subject.to_json).not_to be_valid_json.according_to_schema(Atlassian::Schemata.build_info)
end
end
end
context 'when the pipeline does belong to a Jira issue' do
let(:pipeline) { create(:ci_pipeline, merge_request: merge_request) }
%i[jira_branch jira_title].each do |trait|
context "because it belongs to an MR with a #{trait}" do
let(:merge_request) { create(:merge_request, trait) }
describe '#issue_keys' do
it 'is not empty' do
expect(subject.issue_keys).not_to be_empty
end
end
describe '#to_json' do
it 'is valid according to the build info schema' do
expect(subject.to_json).to be_valid_json.according_to_schema(Atlassian::Schemata.build_info)
end
end
end
end
end
end
......@@ -1206,6 +1206,40 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
end
describe 'synching status to Jira' do
let(:worker) { ::JiraConnect::SyncBuildsWorker }
%i[prepare! run! skip! drop! succeed! cancel! block! delay!].each do |event|
context "when we call pipeline.#{event}" do
it 'triggers a Jira synch worker' do
expect(worker).to receive(:perform_async).with(pipeline.id, Integer)
pipeline.send(event)
end
context 'the feature is disabled' do
it 'does not trigger a worker' do
stub_feature_flags(jira_sync_builds: false)
expect(worker).not_to receive(:perform_async)
pipeline.send(event)
end
end
context 'the feature is enabled for this project' do
it 'does trigger a worker' do
stub_feature_flags(jira_sync_builds: pipeline.project)
expect(worker).to receive(:perform_async)
pipeline.send(event)
end
end
end
end
end
describe '#duration', :sidekiq_inline do
context 'when multiple builds are finished' do
before do
......
......@@ -3,30 +3,23 @@
require 'spec_helper'
RSpec.describe JiraConnect::SyncService do
include AfterNextHelpers
describe '#execute' do
let_it_be(: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)] }
let(:client) { Atlassian::JiraConnect::Client }
let(:info) { { a: 'Some', b: 'Info' } }
subject do
described_class.new(project).execute(commits: commits, branches: branches, merge_requests: merge_requests)
described_class.new(project).execute(**info)
end
before do
create(:jira_connect_subscription, namespace: project.namespace)
end
def expect_jira_client_call(return_value = { 'status': 'success' })
expect_next_instance_of(Atlassian::JiraConnect::Client) do |instance|
expect(instance).to receive(:store_dev_info).with(
project: project,
commits: commits,
branches: [instance_of(Gitlab::Git::Branch)],
merge_requests: merge_requests,
update_sequence_id: anything
).and_return(return_value)
end
def store_info(return_values = [{ 'status': 'success' }])
receive(:send_info).with(project: project, **info).and_return(return_values)
end
def expect_log(type, message)
......@@ -41,20 +34,22 @@ RSpec.describe JiraConnect::SyncService do
end
it 'calls Atlassian::JiraConnect::Client#store_dev_info and logs the response' do
expect_jira_client_call
expect_next(client).to store_info
expect_log(:info, { 'status': 'success' })
subject
end
context 'when request returns an error' do
context 'when a request returns an error' do
it 'logs the response as an error' do
expect_jira_client_call({
'errorMessages' => ['some error message']
})
expect_next(client).to store_info([
{ 'errorMessages' => ['some error message'] },
{ 'rejectedBuilds' => ['x'] }
])
expect_log(:error, { 'errorMessages' => ['some error message'] })
expect_log(:error, { 'rejectedBuilds' => ['x'] })
subject
end
......
# frozen_string_literal: true
module Atlassian
module Schemata
def self.build_info
{
'type' => 'object',
'required' => %w(schemaVersion pipelineId buildNumber updateSequenceNumber displayName url state issueKeys testInfo references),
'properties' => {
'schemaVersion' => { 'type' => 'string', 'pattern' => '1.0' },
'pipelineId' => { 'type' => 'string' },
'buildNumber' => { 'type' => 'integer' },
'updateSequenceNumber' => { 'type' => 'integer' },
'displayName' => { 'type' => 'string' },
'url' => { 'type' => 'string' },
'state' => {
'type' => 'string',
'pattern' => '(pending|in_progress|successful|failed|cancelled)'
},
'issueKeys' => {
'type' => 'array',
'items' => { 'type' => 'string' },
'minItems' => 1
},
'testInfo' => {
'type' => 'object',
'required' => %w(totalNumber numberPassed numberFailed numberSkipped),
'properties' => {
'totalNumber' => { 'type' => 'integer' },
'numberFailed' => { 'type' => 'integer' },
'numberPassed' => { 'type' => 'integer' },
'numberSkipped' => { 'type' => 'integer' }
}
},
'references' => {
'type' => 'array',
'items' => {
'type' => 'object',
'required' => %w(commit ref),
'properties' => {
'commit' => {
'type' => 'object',
'required' => %w(id repositoryUri),
'properties' => {
'id' => { 'type' => 'string' },
'repositoryUri' => { 'type' => 'string' }
}
},
'ref' => {
'type' => 'object',
'required' => %w(name uri),
'properties' => {
'name' => { 'type' => 'string' },
'uri' => { 'type' => 'string' }
}
}
}
}
}
}
}
end
def self.build_info_payload
{
'type' => 'object',
'required' => %w(providerMetadata builds),
'properties' => {
'providerMetadata' => provider_metadata,
'builds' => { 'type' => 'array', 'items' => build_info }
}
}
end
def self.provider_metadata
{
'type' => 'object',
'required' => %w(product),
'properties' => { 'product' => { 'type' => 'string' } }
}
end
end
end
......@@ -30,7 +30,11 @@ module AfterNextHelpers
msg = asserted ? :to : :not_to
case level
when :expect
expect_next_instance_of(klass, *args) { |instance| expect(instance).send(msg, condition) }
if asserted
expect_next_instance_of(klass, *args) { |instance| expect(instance).send(msg, condition) }
else
allow_next_instance_of(klass, *args) { |instance| expect(instance).send(msg, condition) }
end
when :allow
allow_next_instance_of(klass, *args) { |instance| allow(instance).send(msg, condition) }
else
......
......@@ -2,17 +2,26 @@
module NextInstanceOf
def expect_next_instance_of(klass, *new_args, &blk)
stub_new(expect(klass), *new_args, &blk)
stub_new(expect(klass), nil, *new_args, &blk)
end
def expect_next_instances_of(klass, number, *new_args, &blk)
stub_new(expect(klass), number, *new_args, &blk)
end
def allow_next_instance_of(klass, *new_args, &blk)
stub_new(allow(klass), *new_args, &blk)
stub_new(allow(klass), nil, *new_args, &blk)
end
def allow_next_instances_of(klass, number, *new_args, &blk)
stub_new(allow(klass), number, *new_args, &blk)
end
private
def stub_new(target, *new_args, &blk)
def stub_new(target, number, *new_args, &blk)
receive_new = receive(:new)
receive_new.exactly(number).times if number
receive_new.with(*new_args) if new_args.any?
target.to receive_new.and_wrap_original do |method, *original_args|
......
# frozen_string_literal: true
RSpec::Matchers.define :be_valid_json do
def according_to_schema(schema)
@schema = schema
self
end
match do |actual|
data = Gitlab::Json.parse(actual)
if @schema.present?
@validation_errors = JSON::Validator.fully_validate(@schema, data)
@validation_errors.empty?
else
data.present?
end
rescue JSON::ParserError => e
@error = e
false
end
def failure_message
if @error
"Parse failed with error: #{@error}"
elsif @validation_errors.present?
"Validation failed because #{@validation_errors.join(', and ')}"
else
"Parsing did not return any data"
end
end
end
......@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe JiraConnect::SyncBranchWorker do
include AfterNextHelpers
describe '#perform' do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, group: group) }
......@@ -67,7 +69,7 @@ RSpec.describe JiraConnect::SyncBranchWorker do
context 'with update_sequence_id' do
let(:update_sequence_id) { 1 }
let(:request_url) { 'https://sample.atlassian.net/rest/devinfo/0.10/bulk' }
let(:request_path) { '/rest/devinfo/0.10/bulk' }
let(:request_body) do
{
repositories: [
......@@ -78,14 +80,13 @@ RSpec.describe JiraConnect::SyncBranchWorker do
update_sequence_id: update_sequence_id
)
]
}.to_json
}
end
subject { described_class.new.perform(project_id, branch_name, commit_shas, update_sequence_id) }
it 'sends the reqeust with custom update_sequence_id' do
expect(Atlassian::JiraConnect::Client).to receive(:post)
.with(URI(request_url), headers: anything, body: request_body)
expect_next(Atlassian::JiraConnect::Client).to receive(:post).with(request_path, request_body)
subject
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::JiraConnect::SyncBuildsWorker do
include AfterNextHelpers
include ServicesHelper
describe '#perform' do
let_it_be(:pipeline) { create(:ci_pipeline) }
let(:sequence_id) { Random.random_number(1..10_000) }
let(:pipeline_id) { pipeline.id }
subject { described_class.new.perform(pipeline_id, sequence_id) }
context 'when pipeline exists' do
it 'calls the Jira sync service' do
expect_next(::JiraConnect::SyncService, pipeline.project)
.to receive(:execute).with(pipelines: contain_exactly(pipeline), update_sequence_id: sequence_id)
subject
end
end
context 'when pipeline does not exist' do
let(:pipeline_id) { non_existing_record_id }
it 'does not call the sync service' do
expect_next(::JiraConnect::SyncService).not_to receive(:execute)
subject
end
end
context 'when the feature flag is disabled' do
before do
stub_feature_flags(jira_sync_builds: false)
end
it 'does not call the sync service' do
expect_next(::JiraConnect::SyncService).not_to receive(:execute)
subject
end
end
context 'when the feature flag is enabled for this project' do
before do
stub_feature_flags(jira_sync_builds: pipeline.project)
end
it 'calls the sync service' do
expect_next(::JiraConnect::SyncService).to receive(:execute)
subject
end
end
end
end
......@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe JiraConnect::SyncMergeRequestWorker do
include AfterNextHelpers
describe '#perform' do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, group: group) }
......@@ -33,7 +35,7 @@ RSpec.describe JiraConnect::SyncMergeRequestWorker do
context 'with update_sequence_id' do
let(:update_sequence_id) { 1 }
let(:request_url) { 'https://sample.atlassian.net/rest/devinfo/0.10/bulk' }
let(:request_path) { '/rest/devinfo/0.10/bulk' }
let(:request_body) do
{
repositories: [
......@@ -43,14 +45,13 @@ RSpec.describe JiraConnect::SyncMergeRequestWorker do
update_sequence_id: update_sequence_id
)
]
}.to_json
}
end
subject { described_class.new.perform(merge_request_id, update_sequence_id) }
it 'sends the request with custom update_sequence_id' do
expect(Atlassian::JiraConnect::Client).to receive(:post)
.with(URI(request_url), headers: anything, body: request_body)
expect_next(Atlassian::JiraConnect::Client).to receive(:post).with(request_path, request_body)
subject
end
......
......@@ -36,7 +36,7 @@ RSpec.describe JiraConnect::SyncProjectWorker, factory_default: :keep do
end
it_behaves_like 'an idempotent worker' do
let(:request_url) { 'https://sample.atlassian.net/rest/devinfo/0.10/bulk' }
let(:request_path) { '/rest/devinfo/0.10/bulk' }
let(:request_body) do
{
repositories: [
......@@ -46,13 +46,13 @@ RSpec.describe JiraConnect::SyncProjectWorker, factory_default: :keep do
update_sequence_id: update_sequence_id
)
]
}.to_json
}
end
it 'sends the request with custom update_sequence_id' do
expect(Atlassian::JiraConnect::Client).to receive(:post)
.exactly(IdempotentWorkerHelper::WORKER_EXEC_TIMES).times
.with(URI(request_url), headers: anything, body: request_body)
allow_next_instances_of(Atlassian::JiraConnect::Client, IdempotentWorkerHelper::WORKER_EXEC_TIMES) do |client|
expect(client).to receive(:post).with(request_path, request_body)
end
subject
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