Commit d36f365c authored by Alex Kalderimis's avatar Alex Kalderimis

Add Feature Flag synchronization

This adds JiraConnect Feature Flag synchronization.

Updates and creations result in background worker jobs that sync the new
data to Jira.

This feature is guarded by a feature flag: `jira_sync_feature_flags`
parent e0d2a010
......@@ -66,6 +66,7 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
modules.merge!(build_information_module)
modules.merge!(deployment_information_module)
modules.merge!(feature_flag_module)
modules
end
......@@ -85,6 +86,19 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
}
end
# see: https://developer.atlassian.com/cloud/jira/software/modules/feature-flag/
def feature_flag_module
{
jiraFeatureFlagInfoProvider: common_module_properties.merge(
actions: {}, # TODO: create, link and list feature flags https://gitlab.com/gitlab-org/gitlab/-/issues/297386
name: {
value: 'GitLab Feature Flags'
},
key: 'gitlab-feature-flags'
)
}
end
# See: https://developer.atlassian.com/cloud/jira/software/modules/build/
def build_information_module
{
......
......@@ -6,6 +6,11 @@ module FeatureFlags
AUDITABLE_ATTRIBUTES = %w(name description active).freeze
def success(**args)
sync_to_jira(args[:feature_flag])
super
end
protected
def audit_event(feature_flag)
......@@ -34,6 +39,16 @@ module FeatureFlags
audit_event.security_event
end
def sync_to_jira(feature_flag)
return unless feature_flag.present?
return unless Feature.enabled?(:jira_sync_feature_flags, feature_flag.project)
seq_id = ::Atlassian::JiraConnect::Client.generate_update_sequence_id
feature_flag.run_after_commit do
::JiraConnect::SyncFeatureFlagsWorker.perform_async(feature_flag.id, seq_id)
end
end
def created_scope_message(scope)
"Created rule <strong>#{scope.environment_scope}</strong> "\
"and set it as <strong>#{scope.active ? "active" : "inactive"}</strong> "\
......
......@@ -907,6 +907,14 @@
:weight: 1
:idempotent: true
:tags: []
- :name: jira_connect:jira_connect_sync_feature_flags
: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 SyncFeatureFlagsWorker
include ApplicationWorker
idempotent!
worker_has_external_dependencies!
queue_namespace :jira_connect
feature_category :integrations
def perform(feature_flag_id, sequence_id)
feature_flag = ::Operations::FeatureFlag.find_by_id(feature_flag_id)
return unless feature_flag
return unless Feature.enabled?(:jira_sync_feature_flags, feature_flag.project)
::JiraConnect::SyncService
.new(feature_flag.project)
.execute(feature_flags: [feature_flag], update_sequence_id: sequence_id)
end
end
end
---
name: jira_sync_feature_flags
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50390
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/296990
milestone: '13.8'
type: development
group: group::ecosystem
default_enabled: false
......@@ -17,12 +17,14 @@ module Atlassian
dev_info = args.slice(:commits, :branches, :merge_requests)
build_info = args.slice(:pipelines)
deploy_info = args.slice(:deployments)
ff_info = args.slice(:feature_flags)
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?
responses << store_ff_info(**common, **ff_info) if ff_info.present?
raise ArgumentError, 'Invalid arguments' if responses.empty?
responses.compact
......@@ -30,6 +32,20 @@ module Atlassian
private
def store_ff_info(project:, feature_flags:, **opts)
return unless Feature.enabled?(:jira_sync_feature_flags, project)
items = feature_flags.map { |flag| Serializers::FeatureFlagEntity.represent(flag, opts) }
items.reject! { |item| item.issue_keys.empty? }
return if items.empty?
post('/rest/featureflags/0.1/bulk', {
flags: items,
properties: { projectId: "project-#{project.id}" }
})
end
def store_deploy_info(project:, deployments:, **opts)
return unless Feature.enabled?(:jira_sync_deployments, project)
......
# frozen_string_literal: true
module Atlassian
module JiraConnect
module Serializers
class FeatureFlagEntity < Grape::Entity
include Gitlab::Routing
alias_method :flag, :object
format_with(:string, &:to_s)
expose :schema_version, as: :schemaVersion
expose :id, format_with: :string
expose :name, as: :key
expose :update_sequence_id, as: :updateSequenceId
expose :name, as: :displayName
expose :summary
expose :details
expose :issue_keys, as: :issueKeys
def issue_keys
@issue_keys ||= JiraIssueKeyExtractor.new(flag.description).issue_keys
end
def schema_version
'1.0'
end
def update_sequence_id
options[:update_sequence_id] || Client.generate_update_sequence_id
end
STRATEGY_NAMES = {
::Operations::FeatureFlags::Strategy::STRATEGY_DEFAULT => 'All users',
::Operations::FeatureFlags::Strategy::STRATEGY_GITLABUSERLIST => 'User List',
::Operations::FeatureFlags::Strategy::STRATEGY_GRADUALROLLOUTUSERID => 'Percent of users',
::Operations::FeatureFlags::Strategy::STRATEGY_FLEXIBLEROLLOUT => 'Percent rollout',
::Operations::FeatureFlags::Strategy::STRATEGY_USERWITHID => 'User IDs'
}.freeze
private
# The summary does not map very well to our FeatureFlag model.
#
# We allow feature flags to have multiple strategies, depending
# on the environment. Jira expects a single rollout strategy.
#
# Also, we don't actually support showing a single flag, so we use the
# edit path as an interim solution.
def summary(strategies = flag.strategies)
{
url: project_url(flag.project) + "/-/feature_flags/#{flag.id}/edit",
lastUpdated: flag.updated_at.iso8601,
status: {
enabled: flag.active,
defaultValue: '',
rollout: {
percentage: strategies.map do |s|
s.parameters['rollout'] || s.parameters['percentage']
end.compact.first&.to_f,
text: strategies.map { |s| STRATEGY_NAMES[s.name] }.compact.join(', ')
}.compact
}
}
end
def details
envs = flag.strategies.flat_map do |s|
s.scopes.map do |es|
env_type = es.environment_scope.scan(/development|testing|staging|production/).first
[es.environment_scope, env_type, s]
end
end
envs.map do |env_name, env_type, strat|
summary([strat]).merge(environment: { name: env_name, type: env_type }.compact)
end
end
end
end
end
end
......@@ -31,7 +31,13 @@ RSpec.describe Atlassian::JiraConnect::Client do
end
describe '#send_info' do
it 'calls store_deploy_info, store_build_info and store_dev_info as appropriate' do
it 'calls more specific methods as appropriate' do
expect(subject).to receive(:store_ff_info).with(
project: project,
update_sequence_id: :x,
feature_flags: :r
).and_return(:ff_stored)
expect(subject).to receive(:store_build_info).with(
project: project,
update_sequence_id: :x,
......@@ -59,11 +65,12 @@ RSpec.describe Atlassian::JiraConnect::Client do
branches: :b,
merge_requests: :c,
pipelines: :y,
deployments: :q
deployments: :q,
feature_flags: :r
}
expect(subject.send_info(**args))
.to contain_exactly(:dev_stored, :build_stored, :deploys_stored)
.to contain_exactly(:dev_stored, :build_stored, :deploys_stored, :ff_stored)
end
it 'only calls methods that we need to call' do
......@@ -158,6 +165,64 @@ RSpec.describe Atlassian::JiraConnect::Client do
end
end
describe '#store_ff_info' do
let_it_be(:feature_flags) { create_list(:operations_feature_flag, 3, project: project) }
let(:schema) do
Atlassian::Schemata.ff_info_payload
end
let(:body) do
matcher = be_valid_json.and match_schema(schema)
->(text) { matcher.matches?(text) }
end
before do
feature_flags.first.update!(description: 'RELEVANT-123')
feature_flags.second.update!(description: 'RELEVANT-123')
path = '/rest/featureflags/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_ff_info, project: project, feature_flags: feature_flags)
end
it 'only sends information about relevant MRs' do
expect(subject).to receive(:post).with('/rest/featureflags/0.1/bulk', {
flags: have_attributes(size: 2), properties: Hash
})
subject.send(:store_ff_info, project: project, feature_flags: feature_flags)
end
it 'does not call the API if there is nothing to report' do
expect(subject).not_to receive(:post)
subject.send(:store_ff_info, project: project, feature_flags: [feature_flags.last])
end
it 'does not call the API if the feature flag is not enabled' do
stub_feature_flags(jira_sync_feature_flags: false)
expect(subject).not_to receive(:post)
subject.send(:store_ff_info, project: project, feature_flags: feature_flags)
end
it 'does call the API if the feature flag enabled for the project' do
stub_feature_flags(jira_sync_feature_flags: project)
expect(subject).to receive(:post).with('/rest/featureflags/0.1/bulk', {
flags: Array, properties: Hash
})
subject.send(:store_ff_info, project: project, feature_flags: feature_flags)
end
end
describe '#store_build_info' do
let(:build_info_payload_schema) do
Atlassian::Schemata.build_info_payload
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Atlassian::JiraConnect::Serializers::FeatureFlagEntity do
let_it_be(:user) { create_default(:user) }
let_it_be(:project) { create_default(:project) }
subject { described_class.represent(feature_flag) }
context 'when the feature flag does not belong to any Jira issue' do
let_it_be(:feature_flag) { create(:operations_feature_flag) }
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 match_schema(Atlassian::Schemata.feature_flag_info)
end
end
end
context 'when the feature flag does belong to a Jira issue' do
let(:feature_flag) do
create(:operations_feature_flag, description: 'THING-123')
end
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 feature flag info schema' do
expect(subject.to_json).to be_valid_json.and match_schema(Atlassian::Schemata.feature_flag_info)
end
end
context 'it has a percentage strategy' do
let!(:scopes) do
strat = create(:operations_strategy,
feature_flag: feature_flag,
name: ::Operations::FeatureFlags::Strategy::STRATEGY_GRADUALROLLOUTUSERID,
parameters: { 'percentage' => '50', 'groupId' => 'abcde' })
[
create(:operations_scope, strategy: strat, environment_scope: 'production in live'),
create(:operations_scope, strategy: strat, environment_scope: 'staging'),
create(:operations_scope, strategy: strat)
]
end
let(:entity) { Gitlab::Json.parse(subject.to_json) }
it 'is valid according to the feature flag info schema' do
expect(subject.to_json).to be_valid_json.and match_schema(Atlassian::Schemata.feature_flag_info)
end
it 'has the correct summary' do
expect(entity.dig('summary', 'status')).to eq(
'enabled' => true,
'defaultValue' => '',
'rollout' => { 'percentage' => 50.0, 'text' => 'Percent of users' }
)
end
it 'includes the correct environments' do
expect(entity['details']).to contain_exactly(
include('environment' => { 'name' => 'production in live', 'type' => 'production' }),
include('environment' => { 'name' => 'staging', 'type' => 'staging' }),
include('environment' => { 'name' => scopes.last.environment_scope })
)
end
end
end
end
......@@ -34,6 +34,12 @@ RSpec.describe FeatureFlags::CreateService do
it 'does not create audit log' do
expect { subject }.not_to change { AuditEvent.count }
end
it 'does not sync the feature flag to Jira' do
expect(::JiraConnect::SyncFeatureFlagsWorker).not_to receive(:perform_async)
subject
end
end
context 'when feature flag is saved correctly' do
......@@ -54,6 +60,24 @@ RSpec.describe FeatureFlags::CreateService do
expect { subject }.to change { Operations::FeatureFlag.count }.by(1)
end
it 'syncs the feature flag to Jira' do
expect(::JiraConnect::SyncFeatureFlagsWorker).to receive(:perform_async).with(Integer, Integer)
subject
end
context 'the feature flag is disabled' do
before do
stub_feature_flags(jira_sync_feature_flags: false)
end
it 'does not sync the feature flag to Jira' do
expect(::JiraConnect::SyncFeatureFlagsWorker).not_to receive(:perform_async)
subject
end
end
it 'creates audit event' do
expected_message = 'Created feature flag <strong>feature_flag</strong> '\
'with description <strong>"description"</strong>. '\
......
......@@ -26,6 +26,24 @@ RSpec.describe FeatureFlags::UpdateService do
expect(subject[:status]).to eq(:success)
end
context 'the feature flag is disabled' do
before do
stub_feature_flags(jira_sync_feature_flags: false)
end
it 'does not sync the feature flag to Jira' do
expect(::JiraConnect::SyncFeatureFlagsWorker).not_to receive(:perform_async)
subject
end
end
it 'syncs the feature flag to Jira' do
expect(::JiraConnect::SyncFeatureFlagsWorker).to receive(:perform_async).with(Integer, Integer)
subject
end
it 'creates audit event with correct message' do
name_was = feature_flag.name
......@@ -52,6 +70,12 @@ RSpec.describe FeatureFlags::UpdateService do
it 'does not create audit event' do
expect { subject }.not_to change { AuditEvent.count }
end
it 'does not sync the feature flag to Jira' do
expect(::JiraConnect::SyncFeatureFlagsWorker).not_to receive(:perform_async)
subject
end
end
context 'when user is reporter' do
......
......@@ -18,7 +18,7 @@ module Atlassian
'buildNumber' => { 'type' => 'integer' },
'updateSequenceNumber' => { 'type' => 'integer' },
'displayName' => { 'type' => 'string' },
'lastUpdated' => { 'type' => 'string' },
'lastUpdated' => iso8601_type,
'url' => { 'type' => 'string' },
'state' => state_type,
'issueKeys' => issue_keys_type,
......@@ -82,7 +82,7 @@ module Atlassian
'description' => { 'type' => 'string' },
'label' => { 'type' => 'string' },
'url' => { 'type' => 'string' },
'lastUpdated' => { 'type' => 'string' },
'lastUpdated' => iso8601_type,
'state' => state_type,
'pipeline' => pipeline_type,
'environment' => environment_type,
......@@ -91,6 +91,93 @@ module Atlassian
}
end
def feature_flag_info
{
'type' => 'object',
'additionalProperties' => false,
'required' => %w(
updateSequenceId id key issueKeys summary details
),
'properties' => {
'id' => { 'type' => 'string' },
'key' => { 'type' => 'string' },
'displayName' => { 'type' => 'string' },
'issueKeys' => issue_keys_type,
'summary' => summary_type,
'details' => details_type,
'updateSequenceId' => { 'type' => 'integer' },
'schemaVersion' => schema_version_type
}
}
end
def details_type
{
'type' => 'array',
'items' => combine(summary_type, {
'required' => ['environment'],
'properties' => {
'environment' => {
'type' => 'object',
'additionalProperties' => false,
'required' => %w(name),
'properties' => {
'name' => { 'type' => 'string' },
'type' => {
'type' => 'string',
'pattern' => '^(development|testing|staging|production)$'
}
}
}
}
})
}
end
def combine(map_a, map_b)
map_a.merge(map_b) do |k, a, b|
a.respond_to?(:merge) ? a.merge(b) : a + b
end
end
def summary_type
{
'type' => 'object',
'additionalProperties' => false,
'required' => %w(url status lastUpdated),
'properties' => {
'lastUpdated' => iso8601_type,
'url' => { 'type' => 'string' },
'status' => feature_status_type
}
}
end
def feature_status_type
{
'type' => 'object',
'additionalProperties' => false,
'required' => %w(enabled),
'properties' => {
'enabled' => { 'type' => 'boolean' },
'defaultValue' => { 'type' => 'string' },
'rollout' => rollout_type
}
}
end
def rollout_type
{
'type' => 'object',
'additionalProperties' => false,
'properties' => {
'percentage' => { 'type' => 'number' },
'text' => { 'type' => 'string' },
'rules' => { 'type' => 'number' }
}
}
end
def environment_type
{
'type' => 'object',
......@@ -163,9 +250,21 @@ module Atlassian
payload('builds', build_info)
end
def ff_info_payload
pl = payload('flags', feature_flag_info)
pl['properties']['properties'] = {
'type' => 'object',
'additionalProperties' => { 'type' => 'string' },
'maxProperties' => 5,
'propertyNames' => { 'pattern' => '^[^_][^:]+$' }
}
pl
end
def payload(key, schema)
{
'type' => 'object',
'additionalProperties' => false,
'required' => ['providerMetadata', key],
'properties' => {
'providerMetadata' => provider_metadata,
......@@ -181,6 +280,13 @@ module Atlassian
'properties' => { 'product' => { 'type' => 'string' } }
}
end
def iso8601_type
{
'type' => 'string',
'pattern' => '^-?([1-9][0-9]*)?[0-9]{4}-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(\.[0-9]+)?(Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])?$'
}
end
end
end
end
......@@ -37,7 +37,13 @@ RSpec::Matchers.define :match_schema do |schema, dir: nil, **options|
end
failure_message do |response|
"didn't match the schema defined by #{SchemaPath.expand(schema, dir)}" \
"didn't match the schema defined by #{schema_name(schema, dir)}" \
" The validation errors were:\n#{@errors.join("\n")}"
end
def schema_name(schema, dir)
return 'provided schema' unless schema.is_a?(String)
SchemaPath.expand(schema, dir)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::JiraConnect::SyncFeatureFlagsWorker do
include AfterNextHelpers
include ServicesHelper
describe '#perform' do
let_it_be(:feature_flag) { create(:operations_feature_flag) }
let(:sequence_id) { Random.random_number(1..10_000) }
let(:feature_flag_id) { feature_flag.id }
subject { described_class.new.perform(feature_flag_id, sequence_id) }
context 'when object exists' do
it 'calls the Jira sync service' do
expect_next(::JiraConnect::SyncService, feature_flag.project)
.to receive(:execute).with(feature_flags: contain_exactly(feature_flag), update_sequence_id: sequence_id)
subject
end
end
context 'when object does not exist' do
let(:feature_flag_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_feature_flags: 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_feature_flags: feature_flag.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