Commit 00b0e8de authored by Alex Kalderimis's avatar Alex Kalderimis Committed by Shinya Maeda

JiraConnect: Send deployment information

This adds the deployment module, with support for sending deployment
information on state changes.
parent a01d8a60
......@@ -65,6 +65,7 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
}
modules.merge!(build_information_module)
modules.merge!(deployment_information_module)
modules
end
......@@ -73,17 +74,33 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
view_context.image_url('gitlab_logo.png')
end
# See: https://developer.atlassian.com/cloud/jira/software/modules/deployment/
def deployment_information_module
{
jiraDeploymentInfoProvider: common_module_properties.merge(
actions: {}, # TODO: list deployments
name: { value: "GitLab Deployments" },
key: "gitlab-deployments"
)
}
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,
jiraBuildInfoProvider: common_module_properties.merge(
actions: {},
name: { value: "GitLab CI" },
key: "gitlab-ci"
}
)
}
end
def common_module_properties
{
homeUrl: HOME_URL,
logoUrl: logo_url,
documentationUrl: DOC_URL
}
end
......
......@@ -109,6 +109,23 @@ class Deployment < ApplicationRecord
Deployments::ExecuteHooksWorker.perform_async(id)
end
end
after_transition any => any - [:skipped] do |deployment, transition|
next if transition.loopback?
next unless Feature.enabled?(:jira_sync_deployments, deployment.project)
deployment.run_after_commit do
::JiraConnect::SyncDeploymentsWorker.perform_async(id)
end
end
end
after_create unless: :importing? do |deployment|
next unless Feature.enabled?(:jira_sync_deployments, deployment.project)
run_after_commit do
::JiraConnect::SyncDeploymentsWorker.perform_async(deployment.id)
end
end
enum status: {
......
......@@ -891,6 +891,14 @@
:weight: 1
:idempotent: true
:tags: []
- :name: jira_connect:jira_connect_sync_deployments
: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: true
......
# frozen_string_literal: true
module JiraConnect
class SyncDeploymentsWorker
include ApplicationWorker
idempotent!
worker_has_external_dependencies!
queue_namespace :jira_connect
feature_category :integrations
def perform(deployment_id, sequence_id)
deployment = Deployment.find_by_id(deployment_id)
return unless deployment
return unless Feature.enabled?(:jira_sync_deployments, deployment.project)
::JiraConnect::SyncService
.new(deployment.project)
.execute(deployments: [deployment], update_sequence_id: sequence_id)
end
def self.perform_async(id)
seq_id = ::Atlassian::JiraConnect::Client.generate_update_sequence_id
super(id, seq_id)
end
end
end
---
name: jira_sync_deployments
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49757
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/294034
milestone: '13.7'
type: development
group: group::ecosystem
default_enabled: false
......@@ -16,11 +16,13 @@ module Atlassian
common = { project: project, update_sequence_id: update_sequence_id }
dev_info = args.slice(:commits, :branches, :merge_requests)
build_info = args.slice(:pipelines)
deploy_info = args.slice(:deployments)
responses = []
responses << store_dev_info(**common, **dev_info) if dev_info.present?
responses << store_build_info(**common, **build_info) if build_info.present?
responses << store_deploy_info(**common, **deploy_info) if deploy_info.present?
raise ArgumentError, 'Invalid arguments' if responses.empty?
responses.compact
......@@ -28,6 +30,17 @@ module Atlassian
private
def store_deploy_info(project:, deployments:, **opts)
return unless Feature.enabled?(:jira_sync_deployments, project)
items = deployments.map { |d| Serializers::DeploymentEntity.represent(d, opts) }
items.reject! { |d| d.issue_keys.empty? }
return if items.empty?
post('/rest/deployments/0.1/bulk', { deployments: items })
end
def store_build_info(project:, pipelines:, update_sequence_id: nil)
return unless Feature.enabled?(:jira_sync_builds, project)
......
......@@ -25,8 +25,10 @@ module Atlassian
# 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
pipeline.all_merge_requests.flat_map do |mr|
src = "#{mr.source_branch} #{mr.title}"
JiraIssueKeyExtractor.new(src).issue_keys
end.uniq
end
end
......
# frozen_string_literal: true
module Atlassian
module JiraConnect
module Serializers
class DeploymentEntity < Grape::Entity
include Gitlab::Routing
format_with(:iso8601, &:iso8601)
expose :schema_version, as: :schemaVersion
expose :iid, as: :deploymentSequenceNumber
expose :update_sequence_id, as: :updateSequenceNumber
expose :display_name, as: :displayName
expose :description
expose :associations
expose :url
expose :label
expose :state
expose :updated_at, as: :lastUpdated, format_with: :iso8601
expose :pipeline_entity, as: :pipeline
expose :environment_entity, as: :environment
def issue_keys
return [] unless build&.pipeline.present?
@issue_keys ||= BuildEntity.new(build.pipeline).issue_keys
end
private
delegate :project, :deployable, :environment, :iid, :ref, :short_sha, to: :object
alias_method :deployment, :object
alias_method :build, :deployable
def associations
keys = issue_keys
[{ associationType: :issueKeys, values: keys }] if keys.present?
end
def display_name
"Deployment #{iid} (#{ref}@#{short_sha}) to #{environment.name}"
end
def label
"#{project.full_path}-#{environment.name}-#{iid}-#{short_sha}"
end
def description
"Deployment #{deployment.iid} of #{project.name} at #{short_sha} (#{build&.name}) to #{environment.name}"
end
def url
# There is no controller action to show a single deployment, so we
# link to the build instead
project_job_url(project, build) if build
end
def state
case deployment.status
when 'created' 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 schema_version
'1.0'
end
def pipeline_entity
PipelineEntity.new(build.pipeline) if build&.pipeline.present?
end
def environment_entity
EnvironmentEntity.new(environment)
end
def update_sequence_id
options[:update_sequence_id] || Client.generate_update_sequence_id
end
end
end
end
end
# frozen_string_literal: true
module Atlassian
module JiraConnect
module Serializers
class EnvironmentEntity < Grape::Entity
format_with(:string, &:to_s)
expose :id, format_with: :string
expose :display_name, as: :displayName
expose :type
private
alias_method :environment, :object
delegate :project, to: :object
def display_name
"#{project.name}/#{environment.name}"
end
def type
case environment.name
when /prod/i
'production'
when /test/i
'testing'
when /staging/i
'staging'
when /(dev|review)/i
'development'
else
'unmapped'
end
end
end
end
end
end
# frozen_string_literal: true
module Atlassian
module JiraConnect
module Serializers
# Both this an BuildEntity represent a Ci::Pipeline
class PipelineEntity < Grape::Entity
include Gitlab::Routing
format_with(:string, &:to_s)
expose :id, format_with: :string
expose :display_name, as: :displayName
expose :url
private
alias_method :pipeline, :object
delegate :project, to: :object
def display_name
"#{project.name} pipeline #{pipeline.iid}"
end
def url
project_pipeline_url(project, pipeline)
end
end
end
end
end
......@@ -8,6 +8,15 @@ 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) }
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
around do |example|
freeze_time { example.run }
......@@ -22,13 +31,19 @@ RSpec.describe Atlassian::JiraConnect::Client do
end
describe '#send_info' do
it 'calls store_build_info and store_dev_info as appropriate' do
it 'calls store_deploy_info, 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_deploy_info).with(
project: project,
update_sequence_id: :x,
deployments: :q
).and_return(:deploys_stored)
expect(subject).to receive(:store_dev_info).with(
project: project,
update_sequence_id: :x,
......@@ -43,10 +58,12 @@ RSpec.describe Atlassian::JiraConnect::Client do
commits: :a,
branches: :b,
merge_requests: :c,
pipelines: :y
pipelines: :y,
deployments: :q
}
expect(subject.send_info(**args)).to contain_exactly(:dev_stored, :build_stored)
expect(subject.send_info(**args))
.to contain_exactly(:dev_stored, :build_stored, :deploys_stored)
end
it 'only calls methods that we need to call' do
......@@ -83,17 +100,65 @@ RSpec.describe Atlassian::JiraConnect::Client do
}
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)
describe '#store_deploy_info' do
let_it_be(:environment) { create(:environment, name: 'DEV', project: project) }
let_it_be(:deployments) do
pipelines.map do |p|
build = create(:ci_build, environment: environment.name, pipeline: p, project: project)
create(:deployment, deployable: build, environment: environment)
end
end
let(:schema) do
Atlassian::Schemata.deploy_info_payload
end
let(:body) do
matcher = be_valid_json.according_to_schema(schema)
->(text) { matcher.matches?(text) }
end
before do
path = '/rest/deployments/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_deploy_info, project: project, deployments: deployments)
end
it 'only sends information about relevant MRs' do
expect(subject).to receive(:post).with('/rest/deployments/0.1/bulk', { deployments: have_attributes(size: 6) })
subject.send(:store_deploy_info, project: project, deployments: deployments)
end
it 'does not call the API if there is nothing to report' do
expect(subject).not_to receive(:post)
subject.send(:store_deploy_info, project: project, deployments: deployments.take(1))
end
it 'does not call the API if the feature flag is not enabled' do
stub_feature_flags(jira_sync_deployments: false)
expect(subject).not_to receive(:post)
subject.send(:store_deploy_info, project: project, deployments: deployments)
end
it 'does call the API if the feature flag enabled for the project' do
stub_feature_flags(jira_sync_deployments: project)
expect(subject).to receive(:post).with('/rest/deployments/0.1/bulk', { deployments: Array })
subject.send(:store_deploy_info, project: project, deployments: deployments)
end
end
describe '#store_build_info' do
let(:build_info_payload_schema) do
Atlassian::Schemata.build_info_payload
end
......@@ -143,6 +208,8 @@ RSpec.describe Atlassian::JiraConnect::Client do
end
it 'avoids N+1 database queries' do
pending 'https://gitlab.com/gitlab-org/gitlab/-/issues/292818'
baseline = ActiveRecord::QueryRecorder.new do
subject.send(:store_build_info, project: project, pipelines: pipelines)
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Atlassian::JiraConnect::Serializers::DeploymentEntity do
let_it_be(:user) { create_default(:user) }
let_it_be(:project) { create_default(:project, :repository) }
let_it_be(:environment) { create(:environment, name: 'prod', project: project) }
let_it_be_with_reload(:deployment) { create(:deployment, environment: environment) }
subject { described_class.represent(deployment) }
context 'when the deployment does not belong to any Jira issue' do
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.deployment_info)
end
end
end
context 'this is an external deployment' do
before do
deployment.update!(deployable: nil)
end
it 'does not raise errors when serializing' do
expect { subject.to_json }.not_to raise_error
end
it 'returns an empty list of issue keys' do
expect(subject.issue_keys).to be_empty
end
end
describe 'environment type' do
using RSpec::Parameterized::TableSyntax
where(:env_name, :env_type) do
'prod' | 'production'
'test' | 'testing'
'staging' | 'staging'
'dev' | 'development'
'review/app' | 'development'
'something-else' | 'unmapped'
end
with_them do
before do
environment.update!(name: env_name)
end
let(:exposed_type) { subject.send(:environment_entity).send(:type) }
it 'has the correct environment type' do
expect(exposed_type).to eq(env_type)
end
end
end
context 'when the deployment can be linked to a Jira issue' do
let(:pipeline) { create(:ci_pipeline, merge_request: merge_request) }
before do
subject.deployable.update!(pipeline: pipeline)
end
%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 deployment info schema' do
expect(subject.to_json).to be_valid_json.according_to_schema(Atlassian::Schemata.deployment_info)
end
end
end
end
end
end
......@@ -227,6 +227,56 @@ RSpec.describe Deployment do
deployment.skip!
end
end
describe 'synching status to Jira' do
let(:deployment) { create(:deployment) }
let(:worker) { ::JiraConnect::SyncDeploymentsWorker }
it 'calls the worker on creation' do
expect(worker).to receive(:perform_async).with(Integer)
deployment
end
it 'does not call the worker for skipped deployments' do
expect(deployment).to be_present # warm-up, ignore the creation trigger
expect(worker).not_to receive(:perform_async)
deployment.skip!
end
%i[run! succeed! drop! cancel!].each do |event|
context "when we call pipeline.#{event}" do
it 'triggers a Jira synch worker' do
expect(worker).to receive(:perform_async).with(deployment.id)
deployment.send(event)
end
context 'the feature is disabled' do
it 'does not trigger a worker' do
stub_feature_flags(jira_sync_deployments: false)
expect(worker).not_to receive(:perform_async)
deployment.send(event)
end
end
context 'the feature is enabled for this project' do
it 'does trigger a worker' do
stub_feature_flags(jira_sync_deployments: deployment.project)
expect(worker).to receive(:perform_async)
deployment.send(event)
end
end
end
end
end
end
describe '#success?' do
......
......@@ -2,82 +2,185 @@
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' => {
class << self
def build_info
{
'type' => 'object',
'additionalProperties' => false,
'required' => %w(
schemaVersion pipelineId buildNumber updateSequenceNumber
displayName url state issueKeys testInfo references
lastUpdated
),
'properties' => {
'schemaVersion' => schema_version_type,
'pipelineId' => { 'type' => 'string' },
'buildNumber' => { 'type' => 'integer' },
'updateSequenceNumber' => { 'type' => 'integer' },
'displayName' => { 'type' => 'string' },
'lastUpdated' => { 'type' => 'string' },
'url' => { 'type' => 'string' },
'state' => state_type,
'issueKeys' => issue_keys_type,
'testInfo' => {
'type' => 'object',
'required' => %w(commit ref),
'required' => %w(totalNumber numberPassed numberFailed numberSkipped),
'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' }
'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
end
def self.build_info_payload
{
'type' => 'object',
'required' => %w(providerMetadata builds),
'properties' => {
'providerMetadata' => provider_metadata,
'builds' => { 'type' => 'array', 'items' => build_info }
def deployment_info
{
'type' => 'object',
'additionalProperties' => false,
'required' => %w(
deploymentSequenceNumber updateSequenceNumber
associations displayName url description lastUpdated
state pipeline environment
),
'properties' => {
'deploymentSequenceNumber' => { 'type' => 'integer' },
'updateSequenceNumber' => { 'type' => 'integer' },
'associations' => {
'type' => 'array',
'items' => association_type,
'minItems' => 1
},
'displayName' => { 'type' => 'string' },
'description' => { 'type' => 'string' },
'label' => { 'type' => 'string' },
'url' => { 'type' => 'string' },
'lastUpdated' => { 'type' => 'string' },
'state' => state_type,
'pipeline' => pipeline_type,
'environment' => environment_type,
'schemaVersion' => schema_version_type
}
}
}
end
end
def environment_type
{
'type' => 'object',
'additionalProperties' => false,
'required' => %w(id displayName type),
'properties' => {
'id' => { 'type' => 'string', 'maxLength' => 255 },
'displayName' => { 'type' => 'string', 'maxLength' => 255 },
'type' => {
'type' => 'string',
'pattern' => '(unmapped|development|testing|staging|production)'
}
}
}
end
def pipeline_type
{
'type' => 'object',
'additionalProperties' => false,
'required' => %w(id displayName url),
'properties' => {
'id' => { 'type' => 'string', 'maxLength' => 255 },
'displayName' => { 'type' => 'string', 'maxLength' => 255 },
'url' => { 'type' => 'string', 'maxLength' => 2000 }
}
}
end
def schema_version_type
{ 'type' => 'string', 'pattern' => '1.0' }
end
def self.provider_metadata
{
'type' => 'object',
'required' => %w(product),
'properties' => { 'product' => { 'type' => 'string' } }
}
def state_type
{
'type' => 'string',
'pattern' => '(pending|in_progress|successful|failed|cancelled)'
}
end
def association_type
{
'type' => 'object',
'additionalProperties' => false,
'required' => %w(associationType values),
'properties' => {
'associationType' => {
'type' => 'string',
'pattern' => '(issueKeys|issueIdOrKeys)'
},
'values' => issue_keys_type
}
}
end
def issue_keys_type
{
'type' => 'array',
'items' => { 'type' => 'string' },
'minItems' => 1,
'maxItems' => 100
}
end
def deploy_info_payload
payload('deployments', deployment_info)
end
def build_info_payload
payload('builds', build_info)
end
def payload(key, schema)
{
'type' => 'object',
'required' => ['providerMetadata', key],
'properties' => {
'providerMetadata' => provider_metadata,
key => { 'type' => 'array', 'items' => schema }
}
}
end
def provider_metadata
{
'type' => 'object',
'required' => %w(product),
'properties' => { 'product' => { 'type' => 'string' } }
}
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::JiraConnect::SyncDeploymentsWorker do
include AfterNextHelpers
include ServicesHelper
describe '#perform' do
let_it_be(:deployment) { create(:deployment) }
let(:sequence_id) { Random.random_number(1..10_000) }
let(:object_id) { deployment.id }
subject { described_class.new.perform(object_id, sequence_id) }
context 'when the object exists' do
it 'calls the Jira sync service' do
expect_next(::JiraConnect::SyncService, deployment.project)
.to receive(:execute).with(deployments: contain_exactly(deployment), update_sequence_id: sequence_id)
subject
end
end
context 'when the object does not exist' do
let(:object_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_deployments: 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_deployments: deployment.project)
end
it 'calls the sync service' do
expect_next(::JiraConnect::SyncService).to receive(:execute)
subject
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment