Project

General

Profile

Feature #29664 » 0001-Introduce-acts_as_webhookable-to-centralize-webhook-.patch

Marius BĂLTEANU, 2026-01-19 18:27

View differences:

app/models/concerns/issue/webhookable.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
module Issue::Webhookable
21
  extend ActiveSupport::Concern
22

  
23
  def webhook_payload(user, action)
24
    h = super
25
    if action == 'updated' && current_journal.present?
26
      journal = journals.visible(user).find_by_id(current_journal.id)
27
      if journal.present?
28
        h[:data][:journal] = journal_payload(journal, user)
29
        h[:timestamp] = journal.created_on.iso8601
30
      end
31
    end
32
    h
33
  end
34

  
35
  private
36

  
37
  def journal_payload(journal, user)
38
    {
39
      id: journal.id,
40
      created_on: journal.created_on.iso8601,
41
      notes: journal.notes,
42
      user: {
43
        id: journal.user.id,
44
        name: journal.user.name,
45
      },
46
      details: journal.visible_details(user).map do |d|
47
        {
48
          property: d.property,
49
          prop_key: d.prop_key,
50
          old_value: d.old_value,
51
          value: d.value,
52
        }
53
      end
54
    }
55
  end
56
end
app/models/concerns/news/webhookable.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
module News::Webhookable
21
  extend ActiveSupport::Concern
22

  
23
  # TODO: remove this method once news have the updated_on column
24
  def webhook_payload_timestamp(action)
25
    ts = action == 'created' ? created_on : Time.now
26

  
27
    ts.iso8601
28
  end
29
end
app/models/concerns/wiki_page/webhookable.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
module WikiPage::Webhookable
21
  extend ActiveSupport::Concern
22

  
23
  def webhook_payload_ivars
24
    { page: self, content: content }
25
  end
26

  
27
  def webhook_payload_api_template
28
    "app/views/wiki/show.api.rsb"
29
  end
30
end
app/models/issue.rb
21 21
  include Redmine::SafeAttributes
22 22
  include Redmine::Utils::DateCalculation
23 23
  include Redmine::I18n
24

  
24 25
  before_validation :default_assign, on: :create
25 26
  before_validation :force_default_value_on_noneditable_custom_fields, on: :create
26 27
  before_validation :clear_disabled_fields
......
59 60
                            :author_key => :author_id
60 61

  
61 62
  acts_as_mentionable :attributes => ['description']
63
  acts_as_webhookable
64
  include Issue::Webhookable
62 65

  
63 66
  DONE_RATIO_OPTIONS = %w(issue_field issue_status)
64 67

  
......
130 133
  after_create_commit :add_auto_watcher
131 134
  after_commit :create_parent_issue_journal
132 135

  
133
  after_create_commit  ->{ Webhook.trigger('issue.created', self) }
134
  after_update_commit  ->{ Webhook.trigger('issue.updated', self) }
135
  after_destroy_commit ->{ Webhook.trigger('issue.deleted', self) }
136

  
137 136
  # Returns a SQL conditions string used to find all issues visible by the specified user
138 137
  def self.visible_condition(user, options={})
139 138
    Project.allowed_to_condition(user, :view_issues, options) do |role, user|
app/models/news.rb
37 37
  acts_as_activity_provider :scope => proc {preload(:project, :author)},
38 38
                            :author_key => :author_id
39 39
  acts_as_watchable
40
  acts_as_webhookable
41
  include News::Webhookable
40 42

  
41 43
  after_create :add_author_as_watcher
42 44
  after_create_commit :send_notification
43 45

  
44
  after_create_commit ->{ Webhook.trigger('news.created', self) }
45
  after_update_commit ->{ Webhook.trigger('news.updated', self) }
46
  after_destroy_commit ->{ Webhook.trigger('news.deleted', self) }
47

  
48 46
  scope :visible, (lambda do |*args|
49 47
    joins(:project).
50 48
    where(Project.allowed_to_condition(args.shift || User.current, :view_news, *args))
app/models/time_entry.rb
46 46
  acts_as_activity_provider :timestamp => "#{table_name}.created_on",
47 47
                            :author_key => :user_id,
48 48
                            :scope => proc {joins(:project).preload(:project)}
49
  acts_as_webhookable
49 50

  
50 51
  validates_presence_of :author_id, :user_id, :activity_id, :project_id, :hours, :spent_on
51 52
  validates_presence_of :issue_id, :if => lambda {Setting.timelog_required_fields.include?('issue_id')}
......
58 59
  before_validation :set_author_if_nil
59 60
  validate :validate_time_entry
60 61

  
61
  after_create_commit  ->{ Webhook.trigger('time_entry.created', self) }
62
  after_update_commit  ->{ Webhook.trigger('time_entry.updated', self) }
63
  after_destroy_commit ->{ Webhook.trigger('time_entry.deleted', self) }
64

  
65 62
  scope :visible, (lambda do |*args|
66 63
    joins(:project).
67 64
    where(TimeEntry.visible_condition(args.shift || User.current, *args))
......
82 79
                  'issue_id', 'activity_id', 'spent_on',
83 80
                  'custom_field_values', 'custom_fields'
84 81

  
82
  def webhook_payload_api_template
83
    "app/views/timelog/show.api.rsb"
84
  end
85

  
85 86
  # Returns a SQL conditions string used to find all time entries visible by the specified user
86 87
  def self.visible_condition(user, options={})
87 88
    Project.allowed_to_condition(user, :view_time_entries, options) do |role, user|
app/models/version.rb
123 123
  before_destroy :nullify_projects_default_version
124 124
  after_save :update_default_project_version
125 125

  
126
  after_create_commit  ->{ Webhook.trigger('version.created', self) }
127
  after_update_commit  ->{ Webhook.trigger('version.updated', self) }
128
  after_destroy_commit ->{ Webhook.trigger('version.deleted', self) }
129

  
130 126
  belongs_to :project
131 127
  has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify, :extend => FixedIssuesExtension
132 128

  
......
134 130
  acts_as_attachable :view_permission => :view_files,
135 131
                     :edit_permission => :manage_files,
136 132
                     :delete_permission => :manage_files
133
  acts_as_webhookable
137 134

  
138 135
  VERSION_STATUSES = %w(open locked closed)
139 136
  VERSION_SHARINGS = %w(none descendants hierarchy tree system)
app/models/webhook.rb
94 94
  end
95 95

  
96 96
  def setable_events
97
    WebhookPayload::EVENTS
97
    WebhookPayload.events
98 98
  end
99 99

  
100 100
  def setable_event_names
app/models/webhook_payload.rb
27 27
    self.user = user
28 28
  end
29 29

  
30
  EVENTS = {
31
    issue: %w[created updated deleted],
32
    wiki_page: %w[created updated deleted],
33
    time_entry: %w[created updated deleted],
34
    news: %w[created updated deleted],
35
    version: %w[created updated deleted],
36
  }
30
  def self.register_model(model, model_events)
31
    raise ArgumentError, "model_events must be Array" unless model_events.is_a?(Array)
32

  
33
    @events ||= {}
34
    @events[model.model_name.singular.to_sym] = model_events
35
  end
36

  
37
  def self.events
38
    @events ||= {}
39
  end
37 40

  
38 41
  def to_h
39 42
    type, action = event.split('.')
40
    if EVENTS[type.to_sym].include?(action)
41
      send("#{type}_payload", action)
43
    if self.class.events[type.to_sym]&.include?(action)
44
      object.webhook_payload(user, action)
42 45
    else
43 46
      raise ArgumentError, "invalid event: #{event}"
44 47
    end
45 48
  end
46 49

  
47
  private
48

  
49
  def issue_payload(action)
50
    issue = object
51
    if issue.current_journal.present?
52
      journal = issue.journals.visible(user).find_by_id(issue.current_journal.id)
53
    end
54
    ts = case action
55
         when 'created'
56
           issue.created_on
57
         when 'deleted'
58
           Time.now
59
         else
60
           journal&.created_on || issue.updated_on
61
         end
62
    h = {
63
      type: event,
64
      timestamp: ts.iso8601,
65
      data: {
66
        issue: ApiRenderer.new("app/views/issues/show.api.rsb", user).to_h(issue: issue)
67
      }
68
    }
69
    if action == 'updated' && journal.present?
70
      h[:data][:journal] = journal_payload(journal)
71
    end
72
    h
73
  end
74

  
75
  def journal_payload(journal)
76
    {
77
      id: journal.id,
78
      created_on: journal.created_on.iso8601,
79
      notes: journal.notes,
80
      user: {
81
        id: journal.user.id,
82
        name: journal.user.name,
83
      },
84
      details: journal.visible_details(user).map do |d|
85
        {
86
          property: d.property,
87
          prop_key: d.prop_key,
88
          old_value: d.old_value,
89
          value: d.value,
90
        }
91
      end
92
    }
93
  end
94

  
95
  def wiki_page_payload(action)
96
    wiki_page = object
97

  
98
    ts = case action
99
         when 'created'
100
           wiki_page.created_on
101
         when 'deleted'
102
           Time.now
103
         else
104
           wiki_page.updated_on
105
         end
106

  
107
    {
108
      type: event,
109
      timestamp: ts.iso8601,
110
      data: {
111
        wiki_page: ApiRenderer.new("app/views/wiki/show.api.rsb", user).to_h(page: wiki_page, content: wiki_page.content)
112
      }
113
    }
114
  end
115

  
116
  def time_entry_payload(action)
117
    time_entry = object
118
    ts = case action
119
         when 'created'
120
           time_entry.created_on
121
         when 'deleted'
122
           Time.now
123
         else
124
           time_entry.updated_on
125
         end
126
    {
127
      type: event,
128
      timestamp: ts.iso8601,
129
      data: {
130
        time_entry: ApiRenderer.new("app/views/timelog/show.api.rsb", user).to_h(time_entry: time_entry)
131
      }
132
    }
133
  end
134

  
135
  def news_payload(action)
136
    news = object
137
    ts = case action
138
         when 'created'
139
           news.created_on
140
         when 'deleted'
141
           Time.now
142
         else # rubocop:disable Lint/DuplicateBranch
143
           # TODO: fix this by adding a update_on column for news.
144
           Time.now
145
         end
146
    {
147
      type: event,
148
      timestamp: ts.iso8601,
149
      data: {
150
        news: ApiRenderer.new("app/views/news/show.api.rsb", user).to_h(news: news)
151
      }
152
    }
153
  end
154

  
155
  def version_payload(action)
156
    version = object
157
    ts = case action
158
         when 'created'
159
           version.created_on
160
         when 'deleted'
161
           Time.now
162
         else
163
           version.updated_on
164
         end
165
    {
166
      type: event,
167
      timestamp: ts.iso8601,
168
      data: {
169
        version: ApiRenderer.new("app/views/versions/show.api.rsb", user).to_h(version: version)
170
      }
171
    }
172
  end
173

  
174 50
  # given a path to an API template (relative to RAILS_ROOT), renders it and returns the resulting hash
175 51
  class ApiRenderer
176 52
    include ApplicationHelper
app/models/wiki_page.rb
47 47
                     :preload => [:content, {:wiki => :project}],
48 48
                     :permission => :view_wiki_pages,
49 49
                     :project_key => "#{Wiki.table_name}.project_id"
50
  acts_as_webhookable
51
  include WikiPage::Webhookable
50 52

  
51 53
  attr_accessor :redirect_existing_links
52 54
  attr_writer   :deleted_attachment_ids
......
62 64
  before_destroy :delete_redirects
63 65
  after_save :handle_children_move, :delete_selected_attachments
64 66

  
65
  after_create_commit  ->{ Webhook.trigger('wiki_page.created', self) }
66
  after_update_commit  ->{ Webhook.trigger('wiki_page.updated', self) }
67
  after_destroy_commit ->{ Webhook.trigger('wiki_page.deleted', self) }
68

  
69 67
  # eager load information about last updates, without loading text
70 68
  scope :with_updated_on, lambda {preload(:content_without_text)}
71 69

  
config/locales/en.yml
1189 1189
  webhook_event_created: "%{object_name} created"
1190 1190
  webhook_event_updated: "%{object_name} updated"
1191 1191
  webhook_event_deleted: "%{object_name} deleted"
1192

  
1192 1193
  webhook_url_info: Redmine will send a POST request to this URL whenever one of the selected events occurs in one of the selected projects.
1193 1194
  webhook_secret_info_html: If provided, Redmine will use this to create a hash signature that is sent with each delivery as the value of the X-Redmine-Signature-256 header.
1194 1195

  
lib/redmine/acts/webhookable.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
module Redmine
21
  module Acts
22
    module Webhookable
23
      def self.included(base)
24
        base.extend ClassMethods
25
      end
26

  
27
      module ClassMethods
28
        def acts_as_webhookable(events = %w(created updated deleted))
29
          events = Array(events).map(&:to_s)
30
          WebhookPayload.register_model(self, events)
31

  
32
          events.each do |event|
33
            case event
34
            when 'created'
35
              after_create_commit ->{ Webhook.trigger(event_name('created'), self) }
36
            when 'updated'
37
              after_update_commit ->{ Webhook.trigger(event_name('updated'), self) }
38
            when 'deleted'
39
              after_destroy_commit ->{ Webhook.trigger(event_name('deleted'), self) }
40
            end
41
          end
42

  
43
          include Redmine::Acts::Webhookable::InstanceMethods
44
        end
45
      end
46

  
47
      module InstanceMethods
48
        def event_name(action)
49
          "#{self.class.model_name.singular}.#{action}"
50
        end
51

  
52
        def webhook_payload(user, action)
53
          {
54
            type: event_name(action),
55
            timestamp: webhook_payload_timestamp(action),
56
            data: {
57
              self.class.model_name.singular.to_sym =>
58
                WebhookPayload::ApiRenderer.new(webhook_payload_api_template, user).to_h(**webhook_payload_ivars)
59
            }
60
          }
61
        end
62

  
63
        def webhook_payload_ivars
64
          { self.class.model_name.singular.to_sym => self }
65
        end
66

  
67
        def webhook_payload_api_template
68
          "app/views/#{self.class.model_name.plural}/show.api.rsb"
69
        end
70

  
71
        def webhook_payload_timestamp(action)
72
          ts = case action
73
               when 'created'
74
                 created_on
75
               when 'updated'
76
                 updated_on
77
               else
78
                 Time.now
79
               end
80

  
81
          ts.iso8601
82
        end
83
      end
84
    end
85
  end
86
end
lib/redmine/preparation.rb
22 22
    def self.prepare
23 23
      ApplicationRecord.include Redmine::Acts::Positioned
24 24
      ApplicationRecord.include Redmine::Acts::Mentionable
25
      ApplicationRecord.include Redmine::Acts::Webhookable
25 26
      ApplicationRecord.include Redmine::I18n
26 27

  
27 28
      Scm::Base.add "Subversion"
test/unit/webhook_payload_test.rb
28 28
    @issue = @project.issues.first
29 29
  end
30 30

  
31
  WebhookPayload.events.each do |type, actions|
32
    actions.each do |action|
33
      test "#{type} #{action} payload should be correct" do
34
        model_class = type.to_s.classify.constantize
35
        obj = model_class.first || model_class.generate!
36
        p = WebhookPayload.new("#{type}.#{action}", obj, @dlopper)
37
        assert h = p.to_h
38
        assert_equal "#{type}.#{action}", h[:type]
39
        assert Time.iso8601(h[:timestamp])
40
        assert h.dig(:data, type)
41
      end
42
    end
43
  end
44

  
31 45
  test "issue update payload should contain journal" do
32 46
    @issue.init_journal(@dlopper)
33 47
    @issue.subject = "new subject"
......
35 49
    p = WebhookPayload.new('issue.updated', @issue, @dlopper)
36 50
    assert h = p.to_h
37 51
    assert_equal 'issue.updated', h[:type]
52
    assert Time.iso8601(h[:timestamp])
38 53
    assert j = h.dig(:data, :journal)
39 54
    assert_equal 'Dave Lopper', j[:user][:name]
40 55
    assert i = h.dig(:data, :issue)
......
46 61
    p = WebhookPayload.new('issue.deleted', @issue, @dlopper)
47 62
    assert h = p.to_h
48 63
    assert_equal 'issue.deleted', h[:type]
64
    assert Time.iso8601(h[:timestamp])
49 65
    assert_nil h.dig(:data, :journal)
50 66
    assert i = h.dig(:data, :issue)
51 67
    assert_equal @issue.subject, i[:subject], i.inspect
......
60 76
    p = WebhookPayload.new('wiki_page.created', page, @dlopper)
61 77
    assert h = p.to_h
62 78
    assert_equal 'wiki_page.created', h[:type]
79
    assert Time.iso8601(h[:timestamp])
63 80
    assert_equal 'Test_Page', h.dig(:data, :wiki_page, :title)
64 81
    assert_equal 'Test content', h.dig(:data, :wiki_page, :text)
65 82
    assert_equal @dlopper.name, h.dig(:data, :wiki_page, :author, :name)
......
78 95
    p = WebhookPayload.new('wiki_page.updated', page, @dlopper)
79 96
    h = p.to_h
80 97
    assert_equal 'wiki_page.updated', h[:type]
98
    assert Time.iso8601(h[:timestamp])
81 99
    assert_equal 'Updated content', h.dig(:data, :wiki_page, :text)
82 100
  end
83 101

  
......
92 110
    p = WebhookPayload.new('wiki_page.deleted', page, @dlopper)
93 111
    h = p.to_h
94 112
    assert_equal 'wiki_page.deleted', h[:type]
113
    assert Time.iso8601(h[:timestamp])
95 114
    assert_equal 'Test_Page', h.dig(:data, :wiki_page, :title)
96 115
  end
97 116

  
......
101 120
    p = WebhookPayload.new('time_entry.created', time_entry, @dlopper)
102 121
    assert h = p.to_h
103 122
    assert_equal 'time_entry.created', h[:type]
123
    assert Time.iso8601(h[:timestamp])
104 124
    assert_equal time_entry.hours, h.dig(:data, :time_entry, :hours)
105 125
  end
106 126

  
......
113 133
    p = WebhookPayload.new('time_entry.updated', time_entry, @dlopper)
114 134
    h = p.to_h
115 135
    assert_equal 'time_entry.updated', h[:type]
136
    assert Time.iso8601(h[:timestamp])
116 137
    assert_equal 2.5, h.dig(:data, :time_entry, :hours)
117 138
  end
118 139

  
......
123 144
    p = WebhookPayload.new('time_entry.deleted', time_entry, @dlopper)
124 145
    h = p.to_h
125 146
    assert_equal 'time_entry.deleted', h[:type]
147
    assert Time.iso8601(h[:timestamp])
126 148
    assert_equal 4.25, h.dig(:data, :time_entry, :hours)
127 149
  end
128 150

  
......
133 155
    p = WebhookPayload.new('news.created', news, @dlopper)
134 156
    assert h = p.to_h
135 157
    assert_equal 'news.created', h[:type]
158
    assert_equal news.created_on.iso8601, h[:timestamp]
136 159
    assert_equal news.title, h.dig(:data, :news, :title)
137 160
  end
138 161

  
......
145 168
    p = WebhookPayload.new('news.updated', news, @dlopper)
146 169
    h = p.to_h
147 170
    assert_equal 'news.updated', h[:type]
171
    assert Time.iso8601(h[:timestamp])
148 172
    assert_equal 'Updated title', h.dig(:data, :news, :title)
149 173
  end
150 174

  
......
155 179
    p = WebhookPayload.new('news.deleted', news, @dlopper)
156 180
    h = p.to_h
157 181
    assert_equal 'news.deleted', h[:type]
182
    assert Time.iso8601(h[:timestamp])
158 183
    assert_equal 'eCookbook first release !', h.dig(:data, :news, :title)
159 184
  end
160 185

  
......
164 189
    p = WebhookPayload.new('version.created', version, @dlopper)
165 190
    assert h = p.to_h
166 191
    assert_equal 'version.created', h[:type]
192
    assert Time.iso8601(h[:timestamp])
167 193
    assert_equal version.name, h.dig(:data, :version, :name)
168 194
  end
169 195

  
......
176 202
    p = WebhookPayload.new('version.updated', version, @dlopper)
177 203
    h = p.to_h
178 204
    assert_equal 'version.updated', h[:type]
205
    assert Time.iso8601(h[:timestamp])
179 206
    assert_equal 'Updated name', h.dig(:data, :version, :name)
180 207
  end
181 208

  
......
186 213
    p = WebhookPayload.new('version.deleted', version, @dlopper)
187 214
    h = p.to_h
188 215
    assert_equal 'version.deleted', h[:type]
216
    assert Time.iso8601(h[:timestamp])
189 217
    assert_equal '0.1', h.dig(:data, :version, :name)
190 218
  end
219

  
220
  test "should generate payload for custom event" do
221
    # Register a custom event for News
222
    News.acts_as_webhookable %w(created updated deleted commented)
223

  
224
    news = News.first
225
    p = WebhookPayload.new('news.commented', news, @dlopper)
226
    assert h = p.to_h
227
    assert_equal 'news.commented', h[:type]
228
    assert Time.iso8601(h[:timestamp])
229
  end
191 230
end
(17-17/17)