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 ...@@ -27,29 +27,9 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
authentication: { authentication: {
type: 'jwt' type: 'jwt'
}, },
modules: modules,
scopes: %w(READ WRITE DELETE), scopes: %w(READ WRITE DELETE),
apiVersion: 1, 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: { apiMigrations: {
gdpr: true gdpr: true
} }
...@@ -58,6 +38,55 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController ...@@ -58,6 +38,55 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
private 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) def relative_to_base_path(full_path)
full_path.sub(/^#{jira_connect_base_path}/, '') full_path.sub(/^#{jira_connect_base_path}/, '')
end end
......
...@@ -259,6 +259,16 @@ module Ci ...@@ -259,6 +259,16 @@ module Ci
end end
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| after_transition any => [:success, :failed] do |pipeline|
ref_status = pipeline.ci_ref&.update_status_by!(pipeline) ref_status = pipeline.ci_ref&.update_status_by!(pipeline)
......
...@@ -6,13 +6,15 @@ module JiraConnect ...@@ -6,13 +6,15 @@ module JiraConnect
self.project = project self.project = project
end end
def execute(commits: nil, branches: nil, merge_requests: nil, update_sequence_id: nil) # Parameters: see Atlassian::JiraConnect::Client#send_info
JiraConnectInstallation.for_project(project).each do |installation| # 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) 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
end end
...@@ -29,7 +31,7 @@ module JiraConnect ...@@ -29,7 +31,7 @@ module JiraConnect
jira_response: response&.to_json jira_response: response&.to_json
} }
if response && response['errorMessages'] if response && (response['errorMessages'] || response['rejectedBuilds'].present?)
logger.error(message) logger.error(message)
else else
logger.info(message) logger.info(message)
......
...@@ -877,15 +877,23 @@ ...@@ -877,15 +877,23 @@
:tags: [] :tags: []
- :name: jira_connect:jira_connect_sync_branch - :name: jira_connect:jira_connect_sync_branch
:feature_category: :integrations :feature_category: :integrations
:has_external_dependencies: :has_external_dependencies: true
:urgency: :low :urgency: :low
:resource_boundary: :unknown :resource_boundary: :unknown
:weight: 1 :weight: 1
:idempotent: :idempotent:
:tags: [] :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 - :name: jira_connect:jira_connect_sync_merge_request
:feature_category: :integrations :feature_category: :integrations
:has_external_dependencies: :has_external_dependencies: true
:urgency: :low :urgency: :low
:resource_boundary: :unknown :resource_boundary: :unknown
:weight: 1 :weight: 1
......
...@@ -7,6 +7,7 @@ module JiraConnect ...@@ -7,6 +7,7 @@ module JiraConnect
queue_namespace :jira_connect queue_namespace :jira_connect
feature_category :integrations feature_category :integrations
loggable_arguments 1, 2 loggable_arguments 1, 2
worker_has_external_dependencies!
def perform(project_id, branch_name, commit_shas, update_sequence_id = nil) def perform(project_id, branch_name, commit_shas, update_sequence_id = nil)
project = Project.find_by_id(project_id) 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 ...@@ -7,6 +7,8 @@ module JiraConnect
queue_namespace :jira_connect queue_namespace :jira_connect
feature_category :integrations feature_category :integrations
worker_has_external_dependencies!
def perform(merge_request_id, update_sequence_id = nil) def perform(merge_request_id, update_sequence_id = nil)
merge_request = MergeRequest.find_by_id(merge_request_id) 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 ...@@ -12,31 +12,68 @@ module Atlassian
@shared_secret = shared_secret @shared_secret = shared_secret
end 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) def store_dev_info(project:, commits: nil, branches: nil, merge_requests: nil, update_sequence_id: nil)
dev_info_json = { repo = Serializers::RepositoryEntity.represent(
repositories: [ project,
Serializers::RepositoryEntity.represent( commits: commits,
project, branches: branches,
commits: commits, merge_requests: merge_requests,
branches: branches, user_notes_count: user_notes_count(merge_requests),
merge_requests: merge_requests, update_sequence_id: update_sequence_id
user_notes_count: user_notes_count(merge_requests), )
update_sequence_id: update_sequence_id
) post('/rest/devinfo/0.10/bulk', { repositories: [repo] })
] end
}.to_json
def post(path, payload)
uri = URI.join(@base_uri, '/rest/devinfo/0.10/bulk') uri = URI.join(@base_uri, path)
headers = { self.class.post(uri, headers: headers(uri), body: metadata.merge(payload).to_json)
end
def headers(uri)
{
'Authorization' => "JWT #{jwt_token('POST', uri)}", 'Authorization' => "JWT #{jwt_token('POST', uri)}",
'Content-Type' => 'application/json' 'Content-Type' => 'application/json'
} }
self.class.post(uri, headers: headers, body: dev_info_json)
end end
private def metadata
{ providerMetadata: { product: "GitLab #{Gitlab::VERSION}" } }
end
def user_notes_count(merge_requests) def user_notes_count(merge_requests)
return unless merge_requests return unless merge_requests
......
...@@ -11,6 +11,12 @@ module Atlassian ...@@ -11,6 +11,12 @@ module Atlassian
expose :update_sequence_id, as: :updateSequenceId expose :update_sequence_id, as: :updateSequenceId
def eql(other)
other.is_a?(self.class) && to_json == other.to_json
end
alias_method :==, :eql
private private
def update_sequence_id 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 ...@@ -24,6 +24,14 @@ FactoryBot.define do
trait :with_diffs do trait :with_diffs do
end 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 trait :with_image_diffs do
source_branch { "add_images_and_changes" } source_branch { "add_images_and_changes" }
target_branch { "master" } target_branch { "master" }
......
...@@ -15,4 +15,6 @@ FactoryBot.define do ...@@ -15,4 +15,6 @@ FactoryBot.define do
sequence(:sha) { |n| Digest::SHA1.hexdigest("commit-like-#{n}") } sequence(:sha) { |n| Digest::SHA1.hexdigest("commit-like-#{n}") }
sequence(:oid) { |n| Digest::SHA2.hexdigest("oid-like-#{n}") } sequence(:oid) { |n| Digest::SHA2.hexdigest("oid-like-#{n}") }
sequence(:variable) { |n| "var#{n}" } sequence(:variable) { |n| "var#{n}" }
sequence(:jira_title) { |n| "[PROJ-#{n}]: fix bug" }
sequence(:jira_branch) { |n| "feature/PROJ-#{n}" }
end end
...@@ -7,6 +7,8 @@ RSpec.describe Atlassian::JiraConnect::Client do ...@@ -7,6 +7,8 @@ RSpec.describe Atlassian::JiraConnect::Client do
subject { described_class.new('https://gitlab-test.atlassian.net', 'sample_secret') } subject { described_class.new('https://gitlab-test.atlassian.net', 'sample_secret') }
let_it_be(:project) { create_default(:project, :repository) }
around do |example| around do |example|
freeze_time { example.run } freeze_time { example.run }
end end
...@@ -19,41 +21,158 @@ RSpec.describe Atlassian::JiraConnect::Client do ...@@ -19,41 +21,158 @@ RSpec.describe Atlassian::JiraConnect::Client do
end end
end end
describe '#store_dev_info' do describe '#send_info' do
let_it_be(:project) { create_default(:project, :repository) } it 'calls store_build_info and store_dev_info as appropriate' do
let_it_be(:merge_requests) { create_list(:merge_request, 2, :unique_branches) } 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 it 'only calls methods that we need to call' do
Atlassian::Jwt.encode( expect(subject).to receive(:store_dev_info).with(
Atlassian::Jwt.build_claims( project: project,
Atlassian::JiraConnect.app_key, update_sequence_id: :x,
'/rest/devinfo/0.10/bulk', commits: :a
'POST' ).and_return(:dev_stored)
),
'sample_secret' 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 end
before do before do
stub_full_request('https://gitlab-test.atlassian.net/rest/devinfo/0.10/bulk', method: :post) path = '/rest/builds/0.1/bulk'
.with( stub_full_request('https://gitlab-test.atlassian.net' + path, method: :post)
headers: { .with(body: body, headers: expected_headers(path))
'Authorization' => "JWT #{expected_jwt}", end
'Content-Type' => 'application/json'
} 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 end
it "calls the API with auth headers" do it "calls the API with auth headers" do
subject.store_dev_info(project: project) subject.send(:store_dev_info, project: project)
end end
it 'avoids N+1 database queries' do 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) 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 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 ...@@ -1206,6 +1206,40 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end end
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 describe '#duration', :sidekiq_inline do
context 'when multiple builds are finished' do context 'when multiple builds are finished' do
before do before do
......
...@@ -3,30 +3,23 @@ ...@@ -3,30 +3,23 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe JiraConnect::SyncService do RSpec.describe JiraConnect::SyncService do
include AfterNextHelpers
describe '#execute' do describe '#execute' do
let_it_be(:project) { create(:project, :repository) } let_it_be(:project) { create(:project, :repository) }
let(:branches) { [project.repository.find_branch('master')] } let(:client) { Atlassian::JiraConnect::Client }
let(:commits) { project.commits_by(oids: %w[b83d6e3 5a62481]) } let(:info) { { a: 'Some', b: 'Info' } }
let(:merge_requests) { [create(:merge_request, source_project: project, target_project: project)] }
subject do subject do
described_class.new(project).execute(commits: commits, branches: branches, merge_requests: merge_requests) described_class.new(project).execute(**info)
end end
before do before do
create(:jira_connect_subscription, namespace: project.namespace) create(:jira_connect_subscription, namespace: project.namespace)
end end
def expect_jira_client_call(return_value = { 'status': 'success' }) def store_info(return_values = [{ 'status': 'success' }])
expect_next_instance_of(Atlassian::JiraConnect::Client) do |instance| receive(:send_info).with(project: project, **info).and_return(return_values)
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
end end
def expect_log(type, message) def expect_log(type, message)
...@@ -41,20 +34,22 @@ RSpec.describe JiraConnect::SyncService do ...@@ -41,20 +34,22 @@ RSpec.describe JiraConnect::SyncService do
end end
it 'calls Atlassian::JiraConnect::Client#store_dev_info and logs the response' do 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' }) expect_log(:info, { 'status': 'success' })
subject subject
end end
context 'when request returns an error' do context 'when a request returns an error' do
it 'logs the response as an error' do it 'logs the response as an error' do
expect_jira_client_call({ expect_next(client).to store_info([
'errorMessages' => ['some error message'] { 'errorMessages' => ['some error message'] },
}) { 'rejectedBuilds' => ['x'] }
])
expect_log(:error, { 'errorMessages' => ['some error message'] }) expect_log(:error, { 'errorMessages' => ['some error message'] })
expect_log(:error, { 'rejectedBuilds' => ['x'] })
subject subject
end 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 ...@@ -30,7 +30,11 @@ module AfterNextHelpers
msg = asserted ? :to : :not_to msg = asserted ? :to : :not_to
case level case level
when :expect 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 when :allow
allow_next_instance_of(klass, *args) { |instance| allow(instance).send(msg, condition) } allow_next_instance_of(klass, *args) { |instance| allow(instance).send(msg, condition) }
else else
......
...@@ -2,17 +2,26 @@ ...@@ -2,17 +2,26 @@
module NextInstanceOf module NextInstanceOf
def expect_next_instance_of(klass, *new_args, &blk) 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 end
def allow_next_instance_of(klass, *new_args, &blk) 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 end
private private
def stub_new(target, *new_args, &blk) def stub_new(target, number, *new_args, &blk)
receive_new = receive(:new) receive_new = receive(:new)
receive_new.exactly(number).times if number
receive_new.with(*new_args) if new_args.any? receive_new.with(*new_args) if new_args.any?
target.to receive_new.and_wrap_original do |method, *original_args| 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 @@ ...@@ -3,6 +3,8 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe JiraConnect::SyncBranchWorker do RSpec.describe JiraConnect::SyncBranchWorker do
include AfterNextHelpers
describe '#perform' do describe '#perform' do
let_it_be(:group) { create(:group) } let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, group: group) } let_it_be(:project) { create(:project, :repository, group: group) }
...@@ -67,7 +69,7 @@ RSpec.describe JiraConnect::SyncBranchWorker do ...@@ -67,7 +69,7 @@ RSpec.describe JiraConnect::SyncBranchWorker do
context 'with update_sequence_id' do context 'with update_sequence_id' do
let(:update_sequence_id) { 1 } 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 let(:request_body) do
{ {
repositories: [ repositories: [
...@@ -78,14 +80,13 @@ RSpec.describe JiraConnect::SyncBranchWorker do ...@@ -78,14 +80,13 @@ RSpec.describe JiraConnect::SyncBranchWorker do
update_sequence_id: update_sequence_id update_sequence_id: update_sequence_id
) )
] ]
}.to_json }
end end
subject { described_class.new.perform(project_id, branch_name, commit_shas, update_sequence_id) } subject { described_class.new.perform(project_id, branch_name, commit_shas, update_sequence_id) }
it 'sends the reqeust with custom update_sequence_id' do it 'sends the reqeust with custom update_sequence_id' do
expect(Atlassian::JiraConnect::Client).to receive(:post) expect_next(Atlassian::JiraConnect::Client).to receive(:post).with(request_path, request_body)
.with(URI(request_url), headers: anything, body: request_body)
subject subject
end 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 @@ ...@@ -3,6 +3,8 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe JiraConnect::SyncMergeRequestWorker do RSpec.describe JiraConnect::SyncMergeRequestWorker do
include AfterNextHelpers
describe '#perform' do describe '#perform' do
let_it_be(:group) { create(:group) } let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, group: group) } let_it_be(:project) { create(:project, :repository, group: group) }
...@@ -33,7 +35,7 @@ RSpec.describe JiraConnect::SyncMergeRequestWorker do ...@@ -33,7 +35,7 @@ RSpec.describe JiraConnect::SyncMergeRequestWorker do
context 'with update_sequence_id' do context 'with update_sequence_id' do
let(:update_sequence_id) { 1 } 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 let(:request_body) do
{ {
repositories: [ repositories: [
...@@ -43,14 +45,13 @@ RSpec.describe JiraConnect::SyncMergeRequestWorker do ...@@ -43,14 +45,13 @@ RSpec.describe JiraConnect::SyncMergeRequestWorker do
update_sequence_id: update_sequence_id update_sequence_id: update_sequence_id
) )
] ]
}.to_json }
end end
subject { described_class.new.perform(merge_request_id, update_sequence_id) } subject { described_class.new.perform(merge_request_id, update_sequence_id) }
it 'sends the request with custom update_sequence_id' do it 'sends the request with custom update_sequence_id' do
expect(Atlassian::JiraConnect::Client).to receive(:post) expect_next(Atlassian::JiraConnect::Client).to receive(:post).with(request_path, request_body)
.with(URI(request_url), headers: anything, body: request_body)
subject subject
end end
......
...@@ -36,7 +36,7 @@ RSpec.describe JiraConnect::SyncProjectWorker, factory_default: :keep do ...@@ -36,7 +36,7 @@ RSpec.describe JiraConnect::SyncProjectWorker, factory_default: :keep do
end end
it_behaves_like 'an idempotent worker' do 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 let(:request_body) do
{ {
repositories: [ repositories: [
...@@ -46,13 +46,13 @@ RSpec.describe JiraConnect::SyncProjectWorker, factory_default: :keep do ...@@ -46,13 +46,13 @@ RSpec.describe JiraConnect::SyncProjectWorker, factory_default: :keep do
update_sequence_id: update_sequence_id update_sequence_id: update_sequence_id
) )
] ]
}.to_json }
end end
it 'sends the request with custom update_sequence_id' do it 'sends the request with custom update_sequence_id' do
expect(Atlassian::JiraConnect::Client).to receive(:post) allow_next_instances_of(Atlassian::JiraConnect::Client, IdempotentWorkerHelper::WORKER_EXEC_TIMES) do |client|
.exactly(IdempotentWorkerHelper::WORKER_EXEC_TIMES).times expect(client).to receive(:post).with(request_path, request_body)
.with(URI(request_url), headers: anything, body: request_body) end
subject subject
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