Commit 2e3eaaed authored by Sean McGivern's avatar Sean McGivern

Merge branch 'ajk-eco-jira-build-sync' into 'master'

Sync build information to Jira with JiraConnect

See merge request gitlab-org/gitlab!49348
parents 958afbb9 24fa3555
......@@ -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