Commit 87d84df7 authored by Max Woolf's avatar Max Woolf

Fire webhook payload for ExternalApprovalRules

For each external approval rule in a project,
send a standard webhook payload to the endpoint.

This is fire-and-forget and does not provide a
mechanism for the 3rd party to feed-back directly
to the MR in question.
parent d18efebe
...@@ -36,6 +36,8 @@ ...@@ -36,6 +36,8 @@
- 1 - 1
- - analytics_usage_trends_counter_job - - analytics_usage_trends_counter_job
- 1 - 1
- - approval_rules_external_approval_rule_payload
- 1
- - approve_blocked_pending_approval_users - - approve_blocked_pending_approval_users
- 1 - 1
- - authorized_keys - - authorized_keys
......
...@@ -23,6 +23,39 @@ This provides a consistent mechanism for reviewers to approve merge requests, an ...@@ -23,6 +23,39 @@ This provides a consistent mechanism for reviewers to approve merge requests, an
maintainers know a change is ready to merge. Approvals in Free are optional, and do maintainers know a change is ready to merge. Approvals in Free are optional, and do
not prevent a merge request from being merged when there is no approval. not prevent a merge request from being merged when there is no approval.
## External approvals **(ULTIMATE)**
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3869) in GitLab Ultimate 13.10.
> - It's [deployed behind a feature flag](../../feature_flags.md), disabled by default.
> - It's disabled on GitLab.com.
> - It's not recommended for production use.
> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](../../../api/merge_request_approvals.md#enable-or-disable-external-project-level-mr-approvals). **(ULTIMATE SELF)**
WARNING:
This feature might not be available to you. Check the **version history** note above for details.
When you create an external approval rule, the following merge request actions sends information
about a merge request to a third party service:
- Create
- Change
- Close
This action enables use-cases such as:
- Integration with 3rd party workflow tools, such as [ServiceNow](https://www.servicenow.co.uk/).
- Integration with custom tools designed to approve merge requests from outside of GitLab.
You can find more information about use-cases, development timelines and the feature discovery in
the [External API approval rules epic](https://gitlab.com/groups/gitlab-org/-/epics/3869).
The intention for this feature is to allow those 3rd party tools to approve a merge request similarly to how users current do.
NOTE:
The lack of an external approval does not block the merging of a merge request.
You can modify external approval rules through the [REST API](../../../api/merge_request_approvals.md#external-project-level-mr-approvals).
## Required Approvals **(PREMIUM)** ## Required Approvals **(PREMIUM)**
> - [Introduced](https://about.gitlab.com/releases/2015/06/22/gitlab-7-12-released/#merge-request-approvers-ee-only) in GitLab Enterprise Edition 7.12. > - [Introduced](https://about.gitlab.com/releases/2015/06/22/gitlab-7-12-released/#merge-request-approvers-ee-only) in GitLab Enterprise Edition 7.12.
......
...@@ -10,5 +10,23 @@ module ApprovalRules ...@@ -10,5 +10,23 @@ module ApprovalRules
validates :external_url, presence: true, uniqueness: { scope: :project_id }, addressable_url: true validates :external_url, presence: true, uniqueness: { scope: :project_id }, addressable_url: true
validates :name, uniqueness: { scope: :project_id }, presence: true validates :name, uniqueness: { scope: :project_id }, presence: true
def async_execute(data)
ApprovalRules::ExternalApprovalRulePayloadWorker.perform_async(self.id, payload_data(data))
end
def to_h
{
id: self.id,
name: self.name,
external_url: self.external_url
}
end
private
def payload_data(merge_request_hook_data)
merge_request_hook_data.merge(external_approval_rule: self.to_h)
end
end end
end end
...@@ -439,6 +439,12 @@ module EE ...@@ -439,6 +439,12 @@ module EE
group_hooks.hooks_for(hooks_scope).any? group_hooks.hooks_for(hooks_scope).any?
end end
def execute_external_compliance_hooks(data)
external_approval_rules.each do |approval_rule|
approval_rule.async_execute(data)
end
end
def execute_hooks(data, hooks_scope = :push_hooks) def execute_hooks(data, hooks_scope = :push_hooks)
super super
......
...@@ -9,6 +9,13 @@ module EE ...@@ -9,6 +9,13 @@ module EE
attr_accessor :blocking_merge_requests_params attr_accessor :blocking_merge_requests_params
override :execute_hooks
def execute_hooks(merge_request, action = 'open', old_rev: nil, old_associations: {})
super do
merge_request.project.execute_external_compliance_hooks(merge_data)
end
end
override :filter_params override :filter_params
def filter_params(merge_request) def filter_params(merge_request)
unless current_user.can?(:update_approvers, merge_request) unless current_user.can?(:update_approvers, merge_request)
......
# frozen_string_literal: true
module ExternalApprovalRules
class DispatchService
REQUEST_BODY_SIZE_LIMIT = 25.megabytes
attr_reader :rule, :data
def initialize(rule, data)
@rule = rule
@data = data
end
def execute
response = Gitlab::HTTP.post(rule.external_url, body: Gitlab::Json::LimitedEncoder.encode(data, limit: REQUEST_BODY_SIZE_LIMIT))
if response.success?
ServiceResponse.success(payload: { rule: rule }, http_status: response.code)
else
ServiceResponse.error(message: 'Service responded with an error', http_status: response.code)
end
end
end
end
...@@ -691,6 +691,14 @@ ...@@ -691,6 +691,14 @@
:weight: 1 :weight: 1
:idempotent: true :idempotent: true
:tags: [] :tags: []
- :name: approval_rules_external_approval_rule_payload
:feature_category: :source_code_management
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
:idempotent: true
:tags: []
- :name: ci_batch_reset_minutes - :name: ci_batch_reset_minutes
:feature_category: :continuous_integration :feature_category: :continuous_integration
:has_external_dependencies: :has_external_dependencies:
......
# frozen_string_literal: true
module ApprovalRules
class ExternalApprovalRulePayloadWorker
include ApplicationWorker
idempotent!
feature_category :source_code_management
def perform(rule_id, data)
rule = ApprovalRules::ExternalApprovalRule.find(rule_id)
ExternalApprovalRules::DispatchService.new(rule, data).execute
end
end
end
...@@ -15,4 +15,10 @@ RSpec.describe ApprovalRules::ExternalApprovalRule, type: :model do ...@@ -15,4 +15,10 @@ RSpec.describe ApprovalRules::ExternalApprovalRule, type: :model do
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) } it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) }
it { is_expected.to validate_uniqueness_of(:external_url).scoped_to(:project_id) } it { is_expected.to validate_uniqueness_of(:external_url).scoped_to(:project_id) }
end end
describe 'to_h' do
it 'returns the correct information' do
expect(subject.to_h).to eq({ id: subject.id, name: subject.name, external_url: subject.external_url })
end
end
end end
...@@ -874,6 +874,16 @@ RSpec.describe Project do ...@@ -874,6 +874,16 @@ RSpec.describe Project do
end end
end end
describe '#execute_external_compliance_hooks' do
let_it_be(:rule) { create(:external_approval_rule) }
it 'enqueues the correct number of workers' do
allow(rule).to receive(:async_execute).once
rule.project.execute_external_compliance_hooks({})
end
end
describe "#execute_hooks" do describe "#execute_hooks" do
context "group hooks" do context "group hooks" do
let(:group) { create(:group) } let(:group) { create(:group) }
...@@ -889,7 +899,7 @@ RSpec.describe Project do ...@@ -889,7 +899,7 @@ RSpec.describe Project do
project.execute_hooks(some: 'info') project.execute_hooks(some: 'info')
end end
context 'when group_webhooks frature is enabled' do context 'when group_webhooks feature is enabled' do
before do before do
stub_licensed_features(group_webhooks: true) stub_licensed_features(group_webhooks: true)
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ExternalApprovalRules::DispatchService do
let_it_be(:rule) { build_stubbed(:external_approval_rule, external_url: 'https://test.example.com/callback') }
subject { described_class.new(rule, {}).execute }
describe '#execute' do
context 'service responds with success' do
before do
stub_success
end
it 'is successful' do
expect(subject.success?).to be true
end
it 'passes back the http status code' do
expect(subject.http_status).to eq(200)
end
end
context 'service responds with error' do
before do
stub_failure
end
it 'is unsuccessful' do
expect(subject.success?).to be false
end
it 'passes back the http status code' do
expect(subject.http_status).to eq(500)
end
end
end
private
def stub_success
stub_request(:post, 'https://test.example.com/callback').to_return(status: 200, body: "", headers: {})
end
def stub_failure
stub_request(:post, 'https://test.example.com/callback').to_return(status: 500, body: "", headers: {})
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ApprovalRules::ExternalApprovalRulePayloadWorker do
let_it_be(:rule) { create(:external_approval_rule, external_url: 'https://example.com/callback') }
subject { described_class.new.perform(rule.id, {}) }
describe "#perform" do
before do
stub_outbound_request
end
it_behaves_like 'an idempotent worker' do
let(:job_args) { [rule.id, {}] }
end
it 'executes a WebHookService' do
expect(subject.success?).to be true
end
end
private
def stub_outbound_request
stub_request(:post, "https://example.com/callback").to_return(status: 200, body: "", headers: {})
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