Commit 4c23a95d authored by Patrick Bair's avatar Patrick Bair

Merge branch '26873-add-webhook-for-releases' into 'master'

Add Webhooks for Releases

See merge request gitlab-org/gitlab!44881
parents e4a49ec6 b2a98edc
......@@ -70,6 +70,10 @@ module GitlabRoutingHelper
project_commit_url(entity.project, entity.sha, *args)
end
def release_url(entity, *args)
project_release_url(entity.project, entity, *args)
end
def preview_markdown_path(parent, *args)
return group_preview_markdown_path(parent, *args) if parent.is_a?(Group)
......
......@@ -14,7 +14,8 @@ module TriggerableHooks
pipeline_hooks: :pipeline_events,
wiki_page_hooks: :wiki_page_events,
deployment_hooks: :deployment_events,
feature_flag_hooks: :feature_flag_events
feature_flag_hooks: :feature_flag_events,
release_hooks: :releases_events
}.freeze
extend ActiveSupport::Concern
......
......@@ -19,7 +19,8 @@ class ProjectHook < WebHook
:pipeline_hooks,
:wiki_page_hooks,
:deployment_hooks,
:feature_flag_hooks
:feature_flag_hooks,
:release_hooks
]
belongs_to :project
......
......@@ -83,6 +83,15 @@ class Release < ApplicationRecord
self.milestones.map {|m| m.title }.sort.join(", ")
end
def to_hook_data(action)
Gitlab::HookData::ReleaseBuilder.new(self).build(action)
end
def execute_hooks(action)
hook_data = to_hook_data(action)
project.execute_hooks(hook_data, :release_hooks)
end
private
def actual_sha
......
......@@ -30,5 +30,15 @@ module Releases
def external?
!internal?
end
def hook_attrs
{
id: id,
external: external?,
link_type: link_type,
name: name,
url: url
}
end
end
end
......@@ -24,6 +24,13 @@ module Releases
format: format)
end
def hook_attrs
{
format: format,
url: url
}
end
private
def archive_prefix
......
......@@ -58,5 +58,12 @@ module Integrations
Gitlab::DataBuilder::Deployment.build(deployment)
end
def releases_events_data
release = project.releases.first
return { error: s_('TestHooks|Ensure the project has releases.') } unless release.present?
release.to_hook_data('create')
end
end
end
......@@ -35,6 +35,8 @@ module Integrations
wiki_page_events_data
when 'deployment'
deployment_events_data
when 'release'
releases_events_data
else
push_events_data
end
......
......@@ -81,6 +81,10 @@ module Releases
params.key?(:milestones)
end
def execute_hooks(release, action = 'create')
release.execute_hooks(action)
end
# overridden in EE
def project_group_id; end
end
......
......@@ -52,6 +52,8 @@ module Releases
notify_create_release(release)
execute_hooks(release, 'create')
create_evidence!(release, evidence_pipeline)
success(tag: tag, release: release)
......
......@@ -3,11 +3,9 @@
module Releases
class UpdateService < Releases::BaseService
def execute
return error('Tag does not exist', 404) unless existing_tag
return error('Release does not exist', 404) unless release
return error('Access Denied', 403) unless allowed?
return error('params is empty', 400) if empty_params?
return error("Milestone(s) not found: #{inexistent_milestones.join(', ')}", 400) if inexistent_milestones.any?
if error = validate
return error
end
if param_for_milestone_titles_provided?
previous_milestones = release.milestones.map(&:title)
......@@ -20,6 +18,7 @@ module Releases
# see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43385
ActiveRecord::Base.transaction do
if release.update(params)
execute_hooks(release, 'update')
success(tag: existing_tag, release: release, milestones_updated: milestones_updated?(previous_milestones))
else
error(release.errors.messages || '400 Bad request', 400)
......@@ -31,6 +30,14 @@ module Releases
private
def validate
return error('Tag does not exist', 404) unless existing_tag
return error('Release does not exist', 404) unless release
return error('Access Denied', 403) unless allowed?
return error('params is empty', 400) if empty_params?
return error("Milestone(s) not found: #{inexistent_milestones.join(', ')}", 400) if inexistent_milestones.any?
end
def allowed?
Ability.allowed?(current_user, :update_release, release)
end
......
......@@ -30,6 +30,8 @@ module TestHooks
pipeline_events_data
when 'wiki_page_events'
wiki_page_events_data
when 'releases_events'
releases_events_data
end
end
end
......
......@@ -84,6 +84,12 @@
%strong= s_('Webhooks|Feature Flag events')
%p.text-muted.ml-1
= s_('Webhooks|This URL is triggered when a feature flag is turned on or off')
%li
= form.check_box :releases_events, class: 'form-check-input'
= form.label :releases_events, class: 'list-label form-check-label ml-1' do
%strong= s_('Webhooks|Releases events')
%p.text-muted.ml-1
= s_('Webhooks|This URL is triggered when a release is created/updated')
.form-group
= form.label :enable_ssl_verification, s_('Webhooks|SSL verification'), class: 'label-bold checkbox'
.form-check
......
---
title: Add webhooks for creating and updating a release
merge_request: 44881
author: David Barr @davebarr
type: added
# frozen_string_literal: true
class AddReleasesEventsToWebHooks < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
add_column :web_hooks, :releases_events, :boolean, null: false, default: false
end
end
f9bc943b61460b1a9a6db8189ab5b21eba46e14650c68658175299b14d48a030
\ No newline at end of file
......@@ -17364,6 +17364,7 @@ CREATE TABLE web_hooks (
encrypted_url character varying,
encrypted_url_iv character varying,
deployment_events boolean DEFAULT false NOT NULL,
releases_events boolean DEFAULT false NOT NULL,
feature_flag_events boolean DEFAULT false NOT NULL
);
......
......@@ -1043,6 +1043,7 @@ GET /groups/:id/hooks/:hook_id
"pipeline_events": true,
"wiki_page_events": true,
"deployment_events": true,
"releases_events": true,
"enable_ssl_verification": true,
"created_at": "2012-10-12T17:04:47Z"
}
......@@ -1071,6 +1072,7 @@ POST /groups/:id/hooks
| `pipeline_events` | boolean | no | Trigger hook on pipeline events |
| `wiki_page_events` | boolean | no | Trigger hook on wiki events |
| `deployment_events` | boolean | no | Trigger hook on deployment events |
| `releases_events` | boolean | no | Trigger hook on release events |
| `enable_ssl_verification` | boolean | no | Do SSL verification when triggering the hook |
| `token` | string | no | Secret token to validate received payloads; this will not be returned in the response |
......@@ -1098,6 +1100,7 @@ PUT /groups/:id/hooks/:hook_id
| `pipeline_events` | boolean | no | Trigger hook on pipeline events |
| `wiki_events` | boolean | no | Trigger hook on wiki events |
| `deployment_events` | boolean | no | Trigger hook on deployment events |
| `releases_events` | boolean | no | Trigger hook on release events |
| `enable_ssl_verification` | boolean | no | Do SSL verification when triggering the hook |
| `token` | string | no | Secret token to validate received payloads; this will not be returned in the response |
......
......@@ -2006,6 +2006,7 @@ GET /projects/:id/hooks/:hook_id
"pipeline_events": true,
"wiki_page_events": true,
"deployment_events": true,
"releases_events": true,
"enable_ssl_verification": true,
"created_at": "2012-10-12T17:04:47Z"
}
......@@ -2065,6 +2066,7 @@ PUT /projects/:id/hooks/:hook_id
| `token` | string | **{dotted-circle}** No | Secret token to validate received payloads; this isn't returned in the response. |
| `url` | string | **{check-circle}** Yes | The hook URL. |
| `wiki_events` | boolean | **{dotted-circle}** No | Trigger hook on wiki events. |
| `releases_events` | boolean | **{dotted-circle}** No | Trigger hook on release events. |
### Delete project hook
......
......@@ -1407,6 +1407,91 @@ X-Gitlab-Event: Feature Flag Hook
}
```
### Release events
Triggered when a release is created or updated.
**Request Header**:
```plaintext
X-Gitlab-Event: Release Hook
```
**Request Body**:
```json
{
"id": 1,
"created_at": "2020-11-02 12:55:12 UTC",
"description": "v1.0 has been released",
"name": "v1.1",
"released_at": "2020-11-02 12:55:12 UTC",
"tag": "v1.1",
"object_kind": "release",
"project": {
"id": 2,
"name": "release-webhook-example",
"description": "",
"web_url": "https://example.com/gitlab-org/release-webhook-example",
"avatar_url": null,
"git_ssh_url": "ssh://git@example.com/gitlab-org/release-webhook-example.git",
"git_http_url": "https://example.com/gitlab-org/release-webhook-example.git",
"namespace": "Gitlab",
"visibility_level": 0,
"path_with_namespace": "gitlab-org/release-webhook-example",
"default_branch": "master",
"ci_config_path": null,
"homepage": "https://example.com/gitlab-org/release-webhook-example",
"url": "ssh://git@example.com/gitlab-org/release-webhook-example.git",
"ssh_url": "ssh://git@example.com/gitlab-org/release-webhook-example.git",
"http_url": "https://example.com/gitlab-org/release-webhook-example.git"
},
"url": "https://example.com/gitlab-org/release-webhook-example/-/releases/v1.1",
"action": "create",
"assets": {
"count": 5,
"links": [
{
"id": 1,
"external": true,
"link_type": "other",
"name": "Changelog",
"url": "https://example.net/changelog"
}
],
"sources": [
{
"format": "zip",
"url": "https://example.com/gitlab-org/release-webhook-example/-/archive/v1.1/release-webhook-example-v1.1.zip"
},
{
"format": "tar.gz",
"url": "https://example.com/gitlab-org/release-webhook-example/-/archive/v1.1/release-webhook-example-v1.1.tar.gz"
},
{
"format": "tar.bz2",
"url": "https://example.com/gitlab-org/release-webhook-example/-/archive/v1.1/release-webhook-example-v1.1.tar.bz2"
},
{
"format": "tar",
"url": "https://example.com/gitlab-org/release-webhook-example/-/archive/v1.1/release-webhook-example-v1.1.tar"
}
]
},
"commit": {
"id": "ee0a3fb31ac16e11b9dbb596ad16d4af654d08f8",
"message": "Release v1.1",
"title": "Release v1.1",
"timestamp": "2020-10-31T14:58:32+11:00",
"url": "https://example.com/gitlab-org/release-webhook-example/-/commit/ee0a3fb31ac16e11b9dbb596ad16d4af654d08f8",
"author": {
"name": "Example User",
"email": "user@example.com"
}
}
}
```
## Image URL rewriting
From GitLab 11.2, simple image references are rewritten to use an absolute URL
......
......@@ -19,7 +19,8 @@ class GroupHook < WebHook
:job_hooks,
:pipeline_hooks,
:wiki_page_hooks,
:deployment_hooks
:deployment_hooks,
:release_hooks
]
belongs_to :group
......
......@@ -23,6 +23,7 @@ module API
optional :pipeline_events, type: Boolean, desc: "Trigger hook on pipeline events"
optional :wiki_page_events, type: Boolean, desc: "Trigger hook on wiki events"
optional :deployment_events, type: Boolean, desc: "Trigger hook on deployment events"
optional :releases_events, type: Boolean, desc: "Trigger hook on release events"
optional :enable_ssl_verification, type: Boolean, desc: "Do SSL verification when triggering the hook"
optional :token, type: String, desc: "Secret token to validate received payloads; this will not be returned in the response"
end
......
......@@ -6,7 +6,7 @@ module EE
class GroupHook < ::API::Entities::Hook
expose :group_id, :issues_events, :confidential_issues_events,
:note_events, :confidential_note_events, :pipeline_events, :wiki_page_events,
:job_events, :deployment_events
:job_events, :deployment_events, :releases_events
end
end
end
......
......@@ -80,7 +80,8 @@ RSpec.describe Groups::HooksController do
token: 'TEST TOKEN',
url: 'http://example.com',
wiki_page_events: true,
deployment_events: true
deployment_events: true,
releases_events: true
}
end
......
......@@ -16,6 +16,7 @@ FactoryBot.define do
job_events { true }
pipeline_events { true }
wiki_page_events { true }
releases_events { true }
end
end
end
......@@ -17,7 +17,8 @@
"pipeline_events",
"wiki_page_events",
"job_events",
"deployment_events"
"deployment_events",
"releases_events"
],
"properties": {
"id": { "type": "integer" },
......@@ -36,7 +37,8 @@
"pipeline_events": { "type": "boolean" },
"wiki_page_events": { "type": "boolean" },
"job_events": { "type": "boolean" },
"deployment_events": { "type": "boolean" }
"deployment_events": { "type": "boolean" },
"releases_events": { "type": "boolean" }
},
"additionalProperties": false
}
......@@ -119,7 +119,8 @@ RSpec.describe API::GroupHooks do
job_events: true,
pipeline_events: true,
wiki_page_events: true,
deployment_events: true
deployment_events: true,
releases_events: true
}
end
......@@ -144,6 +145,7 @@ RSpec.describe API::GroupHooks do
expect(json_response['pipeline_events']).to eq(true)
expect(json_response['wiki_page_events']).to eq(true)
expect(json_response['deployment_events']).to eq(true)
expect(json_response['releases_events']).to eq(true)
expect(json_response['enable_ssl_verification']).to eq(true)
end
......
......@@ -5,7 +5,7 @@ module API
class ProjectHook < Hook
expose :project_id, :issues_events, :confidential_issues_events
expose :note_events, :confidential_note_events, :pipeline_events, :wiki_page_events, :deployment_events
expose :job_events
expose :job_events, :releases_events
expose :push_events_branch_filter
end
end
......
......@@ -23,6 +23,7 @@ module API
optional :pipeline_events, type: Boolean, desc: "Trigger hook on pipeline events"
optional :wiki_page_events, type: Boolean, desc: "Trigger hook on wiki events"
optional :deployment_events, type: Boolean, desc: "Trigger hook on deployment events"
optional :releases_events, type: Boolean, desc: "Trigger hook on release events"
optional :enable_ssl_verification, type: Boolean, desc: "Do SSL verification when triggering the hook"
optional :token, type: String, desc: "Secret token to validate received payloads; this will not be returned in the response"
optional :push_events_branch_filter, type: String, desc: "Trigger hook on specified branch only"
......
# frozen_string_literal: true
module Gitlab
module HookData
class ReleaseBuilder < BaseBuilder
def self.safe_hook_attributes
%i[
id
created_at
description
name
released_at
tag
].freeze
end
alias_method :release, :object
def build(action)
attrs = {
object_kind: object_kind,
project: release.project.hook_attrs,
description: absolute_image_urls(release.description),
url: Gitlab::UrlBuilder.build(release),
action: action,
assets: {
count: release.assets_count,
links: release.links.map(&:hook_attrs),
sources: release.sources.map(&:hook_attrs)
},
commit: release.commit.hook_attrs
}
release.attributes.with_indifferent_access.slice(*self.class.safe_hook_attributes)
.merge!(attrs)
end
private
def object_kind
release.class.name.underscore
end
end
end
end
......@@ -32,6 +32,8 @@ module Gitlab
instance.milestone_url(object, **options)
when Note
note_url(object, **options)
when Release
instance.release_url(object, **options)
when Project
instance.project_url(object, **options)
when Snippet
......
......@@ -26495,6 +26495,9 @@ msgstr ""
msgid "TestHooks|Ensure the project has notes."
msgstr ""
msgid "TestHooks|Ensure the project has releases."
msgstr ""
msgid "TestHooks|Ensure the wiki is enabled and has pages."
msgstr ""
......@@ -30125,6 +30128,9 @@ msgstr ""
msgid "Webhooks|Push events"
msgstr ""
msgid "Webhooks|Releases events"
msgstr ""
msgid "Webhooks|SSL verification"
msgstr ""
......@@ -30140,6 +30146,9 @@ msgstr ""
msgid "Webhooks|This URL is triggered when a feature flag is turned on or off"
msgstr ""
msgid "Webhooks|This URL is triggered when a release is created/updated"
msgstr ""
msgid "Webhooks|This URL will be triggered by a push to the repository"
msgstr ""
......
......@@ -23,6 +23,7 @@ FactoryBot.define do
wiki_page_events { true }
deployment_events { true }
feature_flag_events { true }
releases_events { true }
end
end
end
......@@ -45,6 +45,7 @@ RSpec.describe 'Projects > Settings > Webhook Settings' do
expect(page).to have_content('Merge requests events')
expect(page).to have_content('Pipeline events')
expect(page).to have_content('Wiki page events')
expect(page).to have_content('Releases events')
end
it 'create webhook' do
......
......@@ -322,4 +322,14 @@ RSpec.describe GitlabRoutingHelper do
end
end
end
context 'releases' do
let(:release) { create(:release) }
describe '#release_url' do
it 'returns the url for the release page' do
expect(release_url(release)).to eq("http://test.host/#{release.project.full_path}/-/releases/#{release.tag}")
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::HookData::ReleaseBuilder do
let_it_be(:project) { create(:project, :public, :repository) }
let(:release) { create(:release, project: project) }
let(:builder) { described_class.new(release) }
describe '#build' do
let(:data) { builder.build('create') }
it 'includes safe attribute' do
%w[
id
created_at
description
name
released_at
tag
].each do |key|
expect(data).to include(key)
end
end
it 'includes additional attrs' do
expect(data[:object_kind]).to eq('release')
expect(data[:project]).to eq(builder.release.project.hook_attrs.with_indifferent_access)
expect(data[:action]).to eq('create')
expect(data).to include(:assets)
expect(data).to include(:commit)
end
context 'when the Release has an image in the description' do
let(:release_with_description) do
create(:release, project: project, description: 'test![Release_Image](/uploads/abc/Release_Image.png)')
end
let(:builder) { described_class.new(release_with_description) }
it 'sets the image to use an absolute URL' do
expected_path = "#{release_with_description.project.path_with_namespace}/uploads/abc/Release_Image.png"
expect(data[:description])
.to eq("test![Release_Image](#{Settings.gitlab.url}/#{expected_path})")
end
end
end
end
......@@ -61,6 +61,7 @@ RSpec.describe Gitlab::ImportExport::Project::RelationFactory do
'enable_ssl_verification' => true,
'job_events' => false,
'wiki_page_events' => true,
'releases_events' => false,
'token' => token
}
end
......
......@@ -489,6 +489,7 @@ ProjectHook:
- confidential_issues_events
- confidential_note_events
- repository_update_events
- releases_events
ProtectedBranch:
- id
- project_id
......
......@@ -24,6 +24,7 @@ RSpec.describe Gitlab::UrlBuilder do
:project_milestone | ->(milestone) { "/#{milestone.project.full_path}/-/milestones/#{milestone.iid}" }
:project_snippet | ->(snippet) { "/#{snippet.project.full_path}/-/snippets/#{snippet.id}" }
:project_wiki | ->(wiki) { "/#{wiki.container.full_path}/-/wikis/home" }
:release | ->(release) { "/#{release.project.full_path}/-/releases/#{release.tag}" }
:ci_build | ->(build) { "/#{build.project.full_path}/-/jobs/#{build.id}" }
:design | ->(design) { "/#{design.project.full_path}/-/design_management/designs/#{design.id}/raw_image" }
......
......@@ -41,6 +41,7 @@ RSpec.describe API::ProjectHooks, 'ProjectHooks' do
expect(json_response.first['pipeline_events']).to eq(true)
expect(json_response.first['wiki_page_events']).to eq(true)
expect(json_response.first['deployment_events']).to eq(true)
expect(json_response.first['releases_events']).to eq(true)
expect(json_response.first['enable_ssl_verification']).to eq(true)
expect(json_response.first['push_events_branch_filter']).to eq('master')
end
......@@ -72,6 +73,7 @@ RSpec.describe API::ProjectHooks, 'ProjectHooks' do
expect(json_response['job_events']).to eq(hook.job_events)
expect(json_response['pipeline_events']).to eq(hook.pipeline_events)
expect(json_response['wiki_page_events']).to eq(hook.wiki_page_events)
expect(json_response['releases_events']).to eq(hook.releases_events)
expect(json_response['deployment_events']).to eq(true)
expect(json_response['enable_ssl_verification']).to eq(hook.enable_ssl_verification)
end
......@@ -97,7 +99,7 @@ RSpec.describe API::ProjectHooks, 'ProjectHooks' do
post(api("/projects/#{project.id}/hooks", user),
params: { url: "http://example.com", issues_events: true,
confidential_issues_events: true, wiki_page_events: true,
job_events: true, deployment_events: true,
job_events: true, deployment_events: true, releases_events: true,
push_events_branch_filter: 'some-feature-branch' })
end.to change {project.hooks.count}.by(1)
......@@ -114,6 +116,7 @@ RSpec.describe API::ProjectHooks, 'ProjectHooks' do
expect(json_response['pipeline_events']).to eq(false)
expect(json_response['wiki_page_events']).to eq(true)
expect(json_response['deployment_events']).to eq(true)
expect(json_response['releases_events']).to eq(true)
expect(json_response['enable_ssl_verification']).to eq(true)
expect(json_response['push_events_branch_filter']).to eq('some-feature-branch')
expect(json_response).not_to include('token')
......@@ -169,6 +172,7 @@ RSpec.describe API::ProjectHooks, 'ProjectHooks' do
expect(json_response['job_events']).to eq(hook.job_events)
expect(json_response['pipeline_events']).to eq(hook.pipeline_events)
expect(json_response['wiki_page_events']).to eq(hook.wiki_page_events)
expect(json_response['releases_events']).to eq(hook.releases_events)
expect(json_response['enable_ssl_verification']).to eq(hook.enable_ssl_verification)
end
......
......@@ -22,6 +22,12 @@ RSpec.describe Releases::CreateService do
it 'creates a new release' do
expected_job_count = MailScheduler::NotificationServiceWorker.jobs.size + 1
expect_next_instance_of(Release) do |release|
expect(release)
.to receive(:execute_hooks)
.with('create')
end
result = service.execute
expect(project.releases.count).to eq(1)
......
......@@ -32,6 +32,12 @@ RSpec.describe Releases::UpdateService do
expect(result[:release].description).to eq(new_description)
end
it 'executes hooks' do
expect(service.release).to receive(:execute_hooks).with('update')
service.execute
end
context 'when the tag does not exists' do
let(:tag_name) { 'foobar' }
......
......@@ -186,5 +186,23 @@ RSpec.describe TestHooks::ProjectService do
expect(service.execute).to include(success_result)
end
end
context 'releases_events' do
let(:trigger) { 'releases_events' }
let(:trigger_key) { :release_hooks }
it 'returns error message if not enough data' do
expect(hook).not_to receive(:execute)
expect(service.execute).to include({ status: :error, message: 'Ensure the project has releases.' })
end
it 'executes hook' do
allow(project).to receive(:releases).and_return([Release.new])
allow_any_instance_of(Release).to receive(:to_hook_data).and_return(sample_data)
expect(hook).to receive(:execute).with(sample_data, trigger_key).and_return(success_result)
expect(service.execute).to include(success_result)
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