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 ...@@ -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