Commit b71200f4 authored by Nick Thomas's avatar Nick Thomas

Merge branch 'ajk-229347-feature-flag-in-jira' into 'master'

JiraConnect: Send feature-flag information

See merge request gitlab-org/gitlab!50390
parents 4709d3c9 d36f365c
......@@ -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