Project

General

Profile

Feature #29664 » 0001-wip.patch

Marius BĂLTEANU, 2025-12-06 15:07

View differences:

app/models/issue.rb
59 59
                            :author_key => :author_id
60 60

  
61 61
  acts_as_mentionable :attributes => ['description']
62
  acts_as_webhookable
62 63

  
63 64
  DONE_RATIO_OPTIONS = %w(issue_field issue_status)
64 65

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

  
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) }
134
  def webhook_payload(user, action)
135
    h = super
136
    if action == 'updated' && current_journal.present?
137
      journal = journals.visible(user).find_by_id(current_journal.id)
138
      if journal.present?
139
        h[:data][:journal] = journal_payload(journal, user)
140
        h[:timestamp] = journal.created_on.iso8601
141
      end
142
    end
143
    h
144
  end
136 145

  
137 146
  # Returns a SQL conditions string used to find all issues visible by the specified user
138 147
  def self.visible_condition(user, options={})
......
1709 1718

  
1710 1719
  private
1711 1720

  
1721
  def journal_payload(journal, user)
1722
    {
1723
      id: journal.id,
1724
      created_on: journal.created_on.iso8601,
1725
      notes: journal.notes,
1726
      user: {
1727
        id: journal.user.id,
1728
        name: journal.user.name,
1729
      },
1730
      details: journal.visible_details(user).map do |d|
1731
        {
1732
          property: d.property,
1733
          prop_key: d.prop_key,
1734
          old_value: d.old_value,
1735
          value: d.value,
1736
        }
1737
      end
1738
    }
1739
  end
1740

  
1712 1741
  def user_tracker_permission?(user, permission)
1713 1742
    if project && !project.active?
1714 1743
      perm = Redmine::AccessControl.permission(permission)
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
40 41

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

  
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 45
  scope :visible, (lambda do |*args|
49 46
    joins(:project).
50 47
    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)
......
416 413
    @default_project_version = (arg == '1' || arg == true)
417 414
  end
418 415

  
419
  def created_on
420
    created_at
421
  end
422

  
423
  def updated_on
424
    updated_at
425
  end
426

  
427 416
  private
428 417

  
429 418
  # Update the issue's fixed versions. Used if a version's sharing changes.
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.events
31
    @events ||= ApplicationRecord.descendants.each_with_object({}) do |model, hash|
32
      if model.respond_to?(:webhook_options) && model.webhook_options
33
        hash[model.model_name.singular.to_sym] = %w[created updated deleted]
34
      end
35
    end
36
  end
37 37

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

  
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
143
           news.updated_on
144
         end
145
    {
146
      type: event,
147
      timestamp: ts.iso8601,
148
      data: {
149
        news: ApiRenderer.new("app/views/news/show.api.rsb", user).to_h(news: news)
150
      }
151
    }
152
  end
153

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

  
173 47
  # given a path to an API template (relative to RAILS_ROOT), renders it and returns the resulting hash
174 48
  class ApiRenderer
175 49
    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
50 51

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

  
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 66
  # eager load information about last updates, without loading text
70 67
  scope :with_updated_on, lambda {preload(:content_without_text)}
71 68

  
......
81 78
  safe_attributes 'deleted_attachment_ids',
82 79
                  :if => lambda {|page, user| page.attachments_deletable?(user)}
83 80

  
81
  def webhook_payload_ivars
82
    { page: self, content: content }
83
  end
84

  
85
  def webhook_payload_api_template
86
    "app/views/wiki/show.api.rsb"
87
  end
88

  
84 89
  def initialize(attributes=nil, *args)
85 90
    super
86 91
    if new_record? && DEFAULT_PROTECTED_PAGES.include?(title.to_s.downcase)
config/locales/en.yml
1185 1185
  label_alert_caution: Caution
1186 1186
  label_alert_important: Important
1187 1187

  
1188
  webhook_events_issue: Issues
1189
  webhook_events_issue_created: Issue created
1190
  webhook_events_issue_updated: Issue updated
1191
  webhook_events_issue_deleted: Issue deleted
1192
  webhook_events_wiki_page: Wiki pages
1193
  webhook_events_wiki_page_created: Wiki page created
1194
  webhook_events_wiki_page_updated: Wiki page updated
1195
  webhook_events_wiki_page_deleted: Wiki page deleted
1196
  webhook_events_time_entry: Time entries
1197
  webhook_events_time_entry_created: Time entry created
1198
  webhook_events_time_entry_updated: Time entry updated
1199
  webhook_events_time_entry_deleted: Time entry deleted
1200
  webhook_events_news: News
1201
  webhook_events_news_created: News created
1202
  webhook_events_news_updated: News updated
1203
  webhook_events_news_deleted: News deleted
1188
  webhook_event_created: "%{type} created"
1189
  webhook_event_updated: "%{type} updated"
1190
  webhook_event_deleted: "%{type} deleted"
1204 1191
  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.
1205 1192
  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.
1206 1193

  
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(options = {})
29
          cattr_accessor :webhook_options
30
          self.webhook_options = options
31

  
32
          after_create_commit  ->{ Webhook.trigger(event_name('created'), self) }
33
          after_update_commit  ->{ Webhook.trigger(event_name('updated'), self) }
34
          after_destroy_commit ->{ Webhook.trigger(event_name('deleted'), self) }
35

  
36
          include Redmine::Acts::Webhookable::InstanceMethods
37
        end
38
      end
39

  
40
      module InstanceMethods
41
        def event_name(action)
42
          "#{self.class.model_name.singular}.#{action}"
43
        end
44

  
45
        def webhook_payload(user, action)
46
          {
47
            type: event_name(action),
48
            timestamp: webhook_payload_timestamp(action),
49
            data: {
50
              self.class.model_name.singular.to_sym =>
51
                WebhookPayload::ApiRenderer.new(webhook_payload_api_template, user).to_h(**webhook_payload_ivars)
52
            }
53
          }
54
        end
55

  
56
        def webhook_payload_ivars
57
          { self.class.model_name.singular.to_sym => self }
58
        end
59

  
60
        def webhook_payload_api_template
61
          "app/views/#{self.class.model_name.plural}/show.api.rsb"
62
        end
63

  
64
        def webhook_payload_timestamp(action)
65
          ts = case action
66
               when 'created'
67
                 created_on
68
               when 'deleted'
69
                 Time.now
70
               else
71
                 updated_on
72
               end
73
          ts.iso8601
74
        end
75
      end
76
    end
77
  end
78
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 h.dig(:data, type)
40
      end
41
    end
42
  end
43

  
31 44
  test "issue update payload should contain journal" do
32 45
    @issue.init_journal(@dlopper)
33 46
    @issue.subject = "new subject"
......
40 53
    assert i = h.dig(:data, :issue)
41 54
    assert_equal 'new subject', i[:subject], i.inspect
42 55
  end
43

  
44
  test "should compute payload of deleted issue" do
45
    @issue.destroy
46
    p = WebhookPayload.new('issue.deleted', @issue, @dlopper)
47
    assert h = p.to_h
48
    assert_equal 'issue.deleted', h[:type]
49
    assert_nil h.dig(:data, :journal)
50
    assert i = h.dig(:data, :issue)
51
    assert_equal @issue.subject, i[:subject], i.inspect
52
  end
53

  
54
  test "wiki page created payload should contain page details" do
55
    wiki = @project.wiki
56
    page = WikiPage.new(:title => 'Test Page', :wiki => wiki)
57
    page.content = WikiContent.new(text: 'Test content', author: @dlopper)
58
    page.save!
59

  
60
    p = WebhookPayload.new('wiki_page.created', page, @dlopper)
61
    assert h = p.to_h
62
    assert_equal 'wiki_page.created', h[:type]
63
    assert_equal 'Test_Page', h.dig(:data, :wiki_page, :title)
64
    assert_equal 'Test content', h.dig(:data, :wiki_page, :text)
65
    assert_equal @dlopper.name, h.dig(:data, :wiki_page, :author, :name)
66
  end
67

  
68
  test "wiki page updated payload should contain updated timestamp" do
69
    wiki = @project.wiki
70
    page = WikiPage.new(wiki: wiki, title: 'Test Page')
71
    page.content = WikiContent.new(text: 'Initial content', author: @dlopper)
72
    page.save!
73

  
74
    page.content.text = 'Updated content'
75
    page.content.save!
76
    page.reload
77

  
78
    p = WebhookPayload.new('wiki_page.updated', page, @dlopper)
79
    h = p.to_h
80
    assert_equal 'wiki_page.updated', h[:type]
81
    assert_equal 'Updated content', h.dig(:data, :wiki_page, :text)
82
  end
83

  
84
  test "wiki page deleted payload should contain basic info" do
85
    wiki = @project.wiki
86
    page = WikiPage.new(wiki: wiki, title: 'Test Page')
87
    page.content = WikiContent.new(text: 'Test content', author: @dlopper)
88
    page.save!
89

  
90
    page.destroy
91

  
92
    p = WebhookPayload.new('wiki_page.deleted', page, @dlopper)
93
    h = p.to_h
94
    assert_equal 'wiki_page.deleted', h[:type]
95
    assert_equal 'Test_Page', h.dig(:data, :wiki_page, :title)
96
  end
97

  
98
  test "time entry created payload should contain time entry details" do
99
    time_entry = TimeEntry.generate!
100

  
101
    p = WebhookPayload.new('time_entry.created', time_entry, @dlopper)
102
    assert h = p.to_h
103
    assert_equal 'time_entry.created', h[:type]
104
    assert_equal time_entry.hours, h.dig(:data, :time_entry, :hours)
105
  end
106

  
107
  test "time entry updated payload should contain updated timestamp" do
108
    time_entry = TimeEntry.first
109

  
110
    time_entry.hours = 2.5
111
    time_entry.save!
112

  
113
    p = WebhookPayload.new('time_entry.updated', time_entry, @dlopper)
114
    h = p.to_h
115
    assert_equal 'time_entry.updated', h[:type]
116
    assert_equal 2.5, h.dig(:data, :time_entry, :hours)
117
  end
118

  
119
  test "time entry deleted payload should contain basic info" do
120
    time_entry = TimeEntry.first
121
    time_entry.destroy
122

  
123
    p = WebhookPayload.new('time_entry.deleted', time_entry, @dlopper)
124
    h = p.to_h
125
    assert_equal 'time_entry.deleted', h[:type]
126
    assert_equal 4.25, h.dig(:data, :time_entry, :hours)
127
  end
128

  
129
  test "news created payload should contain news details" do
130
    news = News.generate!
131

  
132
    p = WebhookPayload.new('news.created', news, @dlopper)
133
    assert h = p.to_h
134
    assert_equal 'news.created', h[:type]
135
    assert_equal news.title, h.dig(:data, :news, :title)
136
  end
137

  
138
  test "news updated payload should contain updated timestamp" do
139
    news = News.first
140

  
141
    news.title = 'Updated title'
142
    news.save!
143

  
144
    p = WebhookPayload.new('news.updated', news, @dlopper)
145
    h = p.to_h
146
    assert_equal 'news.updated', h[:type]
147
    assert_equal 'Updated title', h.dig(:data, :news, :title)
148
  end
149

  
150
  test "news deleted payload should contain basic info" do
151
    news = News.first
152
    news.destroy
153

  
154
    p = WebhookPayload.new('news.deleted', news, @dlopper)
155
    h = p.to_h
156
    assert_equal 'news.deleted', h[:type]
157
    assert_equal 'Updated title', h.dig(:data, :news, :title)
158
  end
159

  
160
  test "version created payload should contain version details" do
161
    version = Version.generate!
162

  
163
    p = WebhookPayload.new('version.created', version, @dlopper)
164
    assert h = p.to_h
165
    assert_equal 'version.created', h[:type]
166
    assert_equal version.name, h.dig(:data, :version, :name)
167
  end
168

  
169
  test "version updated payload should contain updated timestamp" do
170
    version = Version.first
171

  
172
    version.name = 'Updated name'
173
    version.save!
174

  
175
    p = WebhookPayload.new('version.updated', version, @dlopper)
176
    h = p.to_h
177
    assert_equal 'version.updated', h[:type]
178
    assert_equal 'Updated name', h.dig(:data, :version, :name)
179
  end
180

  
181
  test "version deleted payload should contain basic info" do
182
    version = Version.first
183
    version.destroy
184

  
185
    p = WebhookPayload.new('version.deleted', version, @dlopper)
186
    h = p.to_h
187
    assert_equal 'version.deleted', h[:type]
188
    assert_equal 'Updated name', h.dig(:data, :version, :name)
189
  end
190 56
end
(16-16/16)