From 3f47a5e798fe1c052608c5913bf18998a43311c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20B=C4=82LTEANU?= Date: Sat, 6 Dec 2025 15:42:42 +0200 Subject: [PATCH 1/2] wip diff --git a/app/models/issue.rb b/app/models/issue.rb index ee317b006..c0f41a73a 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -59,6 +59,7 @@ class Issue < ApplicationRecord :author_key => :author_id acts_as_mentionable :attributes => ['description'] + acts_as_webhookable DONE_RATIO_OPTIONS = %w(issue_field issue_status) @@ -130,9 +131,17 @@ class Issue < ApplicationRecord after_create_commit :add_auto_watcher after_commit :create_parent_issue_journal - after_create_commit ->{ Webhook.trigger('issue.created', self) } - after_update_commit ->{ Webhook.trigger('issue.updated', self) } - after_destroy_commit ->{ Webhook.trigger('issue.deleted', self) } + def webhook_payload(user, action) + h = super + if action == 'updated' && current_journal.present? + journal = journals.visible(user).find_by_id(current_journal.id) + if journal.present? + h[:data][:journal] = journal_payload(journal, user) + h[:timestamp] = journal.created_on.iso8601 + end + end + h + end # Returns a SQL conditions string used to find all issues visible by the specified user def self.visible_condition(user, options={}) @@ -1709,6 +1718,26 @@ class Issue < ApplicationRecord private + def journal_payload(journal, user) + { + id: journal.id, + created_on: journal.created_on.iso8601, + notes: journal.notes, + user: { + id: journal.user.id, + name: journal.user.name, + }, + details: journal.visible_details(user).map do |d| + { + property: d.property, + prop_key: d.prop_key, + old_value: d.old_value, + value: d.value, + } + end + } + end + def user_tracker_permission?(user, permission) if project && !project.active? perm = Redmine::AccessControl.permission(permission) diff --git a/app/models/news.rb b/app/models/news.rb index 8834e473e..93db6eb45 100644 --- a/app/models/news.rb +++ b/app/models/news.rb @@ -37,14 +37,11 @@ class News < ApplicationRecord acts_as_activity_provider :scope => proc {preload(:project, :author)}, :author_key => :author_id acts_as_watchable + acts_as_webhookable after_create :add_author_as_watcher after_create_commit :send_notification - after_create_commit ->{ Webhook.trigger('news.created', self) } - after_update_commit ->{ Webhook.trigger('news.updated', self) } - after_destroy_commit ->{ Webhook.trigger('news.deleted', self) } - scope :visible, (lambda do |*args| joins(:project). where(Project.allowed_to_condition(args.shift || User.current, :view_news, *args)) diff --git a/app/models/time_entry.rb b/app/models/time_entry.rb index ed46137ea..cae3c7a1b 100644 --- a/app/models/time_entry.rb +++ b/app/models/time_entry.rb @@ -46,6 +46,7 @@ class TimeEntry < ApplicationRecord acts_as_activity_provider :timestamp => "#{table_name}.created_on", :author_key => :user_id, :scope => proc {joins(:project).preload(:project)} + acts_as_webhookable validates_presence_of :author_id, :user_id, :activity_id, :project_id, :hours, :spent_on validates_presence_of :issue_id, :if => lambda {Setting.timelog_required_fields.include?('issue_id')} @@ -58,10 +59,6 @@ class TimeEntry < ApplicationRecord before_validation :set_author_if_nil validate :validate_time_entry - after_create_commit ->{ Webhook.trigger('time_entry.created', self) } - after_update_commit ->{ Webhook.trigger('time_entry.updated', self) } - after_destroy_commit ->{ Webhook.trigger('time_entry.deleted', self) } - scope :visible, (lambda do |*args| joins(:project). where(TimeEntry.visible_condition(args.shift || User.current, *args)) @@ -82,6 +79,10 @@ class TimeEntry < ApplicationRecord 'issue_id', 'activity_id', 'spent_on', 'custom_field_values', 'custom_fields' + def webhook_payload_api_template + "app/views/timelog/show.api.rsb" + end + # Returns a SQL conditions string used to find all time entries visible by the specified user def self.visible_condition(user, options={}) Project.allowed_to_condition(user, :view_time_entries, options) do |role, user| diff --git a/app/models/version.rb b/app/models/version.rb index 68ff4cc31..0f50d1ebd 100644 --- a/app/models/version.rb +++ b/app/models/version.rb @@ -123,10 +123,6 @@ class Version < ApplicationRecord before_destroy :nullify_projects_default_version after_save :update_default_project_version - after_create_commit ->{ Webhook.trigger('version.created', self) } - after_update_commit ->{ Webhook.trigger('version.updated', self) } - after_destroy_commit ->{ Webhook.trigger('version.deleted', self) } - belongs_to :project has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify, :extend => FixedIssuesExtension @@ -134,6 +130,7 @@ class Version < ApplicationRecord acts_as_attachable :view_permission => :view_files, :edit_permission => :manage_files, :delete_permission => :manage_files + acts_as_webhookable VERSION_STATUSES = %w(open locked closed) VERSION_SHARINGS = %w(none descendants hierarchy tree system) @@ -416,14 +413,6 @@ class Version < ApplicationRecord @default_project_version = (arg == '1' || arg == true) end - def created_on - created_at - end - - def updated_on - updated_at - end - private # Update the issue's fixed versions. Used if a version's sharing changes. diff --git a/app/models/webhook.rb b/app/models/webhook.rb index ea1c3aa9c..8cc8b9f9e 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -94,7 +94,7 @@ class Webhook < ApplicationRecord end def setable_events - WebhookPayload::EVENTS + WebhookPayload.events end def setable_event_names diff --git a/app/models/webhook_payload.rb b/app/models/webhook_payload.rb index 0a080d827..c1571344d 100644 --- a/app/models/webhook_payload.rb +++ b/app/models/webhook_payload.rb @@ -27,149 +27,23 @@ class WebhookPayload self.user = user end - EVENTS = { - issue: %w[created updated deleted], - wiki_page: %w[created updated deleted], - time_entry: %w[created updated deleted], - news: %w[created updated deleted], - version: %w[created updated deleted], - } + def self.events + @events ||= ApplicationRecord.descendants.each_with_object({}) do |model, hash| + if model.respond_to?(:webhook_options) && model.webhook_options + hash[model.model_name.singular.to_sym] = %w[created updated deleted] + end + end + end def to_h type, action = event.split('.') - if EVENTS[type.to_sym].include?(action) - send("#{type}_payload", action) + if self.class.events[type.to_sym]&.include?(action) + object.webhook_payload(user, action) else raise ArgumentError, "invalid event: #{event}" end end - private - - def issue_payload(action) - issue = object - if issue.current_journal.present? - journal = issue.journals.visible(user).find_by_id(issue.current_journal.id) - end - ts = case action - when 'created' - issue.created_on - when 'deleted' - Time.now - else - journal&.created_on || issue.updated_on - end - h = { - type: event, - timestamp: ts.iso8601, - data: { - issue: ApiRenderer.new("app/views/issues/show.api.rsb", user).to_h(issue: issue) - } - } - if action == 'updated' && journal.present? - h[:data][:journal] = journal_payload(journal) - end - h - end - - def journal_payload(journal) - { - id: journal.id, - created_on: journal.created_on.iso8601, - notes: journal.notes, - user: { - id: journal.user.id, - name: journal.user.name, - }, - details: journal.visible_details(user).map do |d| - { - property: d.property, - prop_key: d.prop_key, - old_value: d.old_value, - value: d.value, - } - end - } - end - - def wiki_page_payload(action) - wiki_page = object - - ts = case action - when 'created' - wiki_page.created_on - when 'deleted' - Time.now - else - wiki_page.updated_on - end - - { - type: event, - timestamp: ts.iso8601, - data: { - wiki_page: ApiRenderer.new("app/views/wiki/show.api.rsb", user).to_h(page: wiki_page, content: wiki_page.content) - } - } - end - - def time_entry_payload(action) - time_entry = object - ts = case action - when 'created' - time_entry.created_on - when 'deleted' - Time.now - else - time_entry.updated_on - end - { - type: event, - timestamp: ts.iso8601, - data: { - time_entry: ApiRenderer.new("app/views/timelog/show.api.rsb", user).to_h(time_entry: time_entry) - } - } - end - - def news_payload(action) - news = object - ts = case action - when 'created' - news.created_on - when 'deleted' - Time.now - else - news.updated_on - end - { - type: event, - timestamp: ts.iso8601, - data: { - news: ApiRenderer.new("app/views/news/show.api.rsb", user).to_h(news: news) - } - } - end - - def version_payload(action) - version = object - ts = case action - when 'created' - version.created_on - when 'deleted' - Time.now - else - version.updated_on - end - { - type: event, - timestamp: ts.iso8601, - data: { - version: ApiRenderer.new("app/views/versions/show.api.rsb", user).to_h(version: version) - } - } - end - # given a path to an API template (relative to RAILS_ROOT), renders it and returns the resulting hash class ApiRenderer include ApplicationHelper diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 331208a11..34e33506b 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -47,6 +47,7 @@ class WikiPage < ApplicationRecord :preload => [:content, {:wiki => :project}], :permission => :view_wiki_pages, :project_key => "#{Wiki.table_name}.project_id" + acts_as_webhookable attr_accessor :redirect_existing_links attr_writer :deleted_attachment_ids @@ -62,10 +63,6 @@ class WikiPage < ApplicationRecord before_destroy :delete_redirects after_save :handle_children_move, :delete_selected_attachments - after_create_commit ->{ Webhook.trigger('wiki_page.created', self) } - after_update_commit ->{ Webhook.trigger('wiki_page.updated', self) } - after_destroy_commit ->{ Webhook.trigger('wiki_page.deleted', self) } - # eager load information about last updates, without loading text scope :with_updated_on, lambda {preload(:content_without_text)} @@ -81,6 +78,14 @@ class WikiPage < ApplicationRecord safe_attributes 'deleted_attachment_ids', :if => lambda {|page, user| page.attachments_deletable?(user)} + def webhook_payload_ivars + { page: self, content: content } + end + + def webhook_payload_api_template + "app/views/wiki/show.api.rsb" + end + def initialize(attributes=nil, *args) super if new_record? && DEFAULT_PROTECTED_PAGES.include?(title.to_s.downcase) diff --git a/config/locales/en.yml b/config/locales/en.yml index fd1a1c8b1..a340fa978 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1185,22 +1185,9 @@ en: label_alert_caution: Caution label_alert_important: Important - webhook_events_issue: Issues - webhook_events_issue_created: Issue created - webhook_events_issue_updated: Issue updated - webhook_events_issue_deleted: Issue deleted - webhook_events_wiki_page: Wiki pages - webhook_events_wiki_page_created: Wiki page created - webhook_events_wiki_page_updated: Wiki page updated - webhook_events_wiki_page_deleted: Wiki page deleted - webhook_events_time_entry: Time entries - webhook_events_time_entry_created: Time entry created - webhook_events_time_entry_updated: Time entry updated - webhook_events_time_entry_deleted: Time entry deleted - webhook_events_news: News - webhook_events_news_created: News created - webhook_events_news_updated: News updated - webhook_events_news_deleted: News deleted + webhook_event_created: "%{type} created" + webhook_event_updated: "%{type} updated" + webhook_event_deleted: "%{type} deleted" 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. 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. diff --git a/lib/redmine/acts/webhookable.rb b/lib/redmine/acts/webhookable.rb new file mode 100644 index 000000000..3f9100165 --- /dev/null +++ b/lib/redmine/acts/webhookable.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +module Redmine + module Acts + module Webhookable + def self.included(base) + base.extend ClassMethods + end + + module ClassMethods + def acts_as_webhookable(options = {}) + cattr_accessor :webhook_options + self.webhook_options = options + + after_create_commit ->{ Webhook.trigger(event_name('created'), self) } + after_update_commit ->{ Webhook.trigger(event_name('updated'), self) } + after_destroy_commit ->{ Webhook.trigger(event_name('deleted'), self) } + + include Redmine::Acts::Webhookable::InstanceMethods + end + end + + module InstanceMethods + def event_name(action) + "#{self.class.model_name.singular}.#{action}" + end + + def webhook_payload(user, action) + { + type: event_name(action), + timestamp: webhook_payload_timestamp(action), + data: { + self.class.model_name.singular.to_sym => + WebhookPayload::ApiRenderer.new(webhook_payload_api_template, user).to_h(**webhook_payload_ivars) + } + } + end + + def webhook_payload_ivars + { self.class.model_name.singular.to_sym => self } + end + + def webhook_payload_api_template + "app/views/#{self.class.model_name.plural}/show.api.rsb" + end + + def webhook_payload_timestamp(action) + ts = case action + when 'created' + created_on + when 'deleted' + Time.now + else + updated_on + end + ts.iso8601 + end + end + end + end +end diff --git a/lib/redmine/preparation.rb b/lib/redmine/preparation.rb index fec55eb46..0dd20f15b 100644 --- a/lib/redmine/preparation.rb +++ b/lib/redmine/preparation.rb @@ -22,6 +22,7 @@ module Redmine def self.prepare ApplicationRecord.include Redmine::Acts::Positioned ApplicationRecord.include Redmine::Acts::Mentionable + ApplicationRecord.include Redmine::Acts::Webhookable ApplicationRecord.include Redmine::I18n Scm::Base.add "Subversion" diff --git a/test/unit/webhook_payload_test.rb b/test/unit/webhook_payload_test.rb index df60d36c6..caf598298 100644 --- a/test/unit/webhook_payload_test.rb +++ b/test/unit/webhook_payload_test.rb @@ -28,6 +28,19 @@ class WebhookPayloadTest < ActiveSupport::TestCase @issue = @project.issues.first end + WebhookPayload.events.each do |type, actions| + actions.each do |action| + test "#{type} #{action} payload should be correct" do + model_class = type.to_s.classify.constantize + obj = model_class.first || model_class.generate! + p = WebhookPayload.new("#{type}.#{action}", obj, @dlopper) + assert h = p.to_h + assert_equal "#{type}.#{action}", h[:type] + assert h.dig(:data, type) + end + end + end + test "issue update payload should contain journal" do @issue.init_journal(@dlopper) @issue.subject = "new subject" @@ -40,151 +53,4 @@ class WebhookPayloadTest < ActiveSupport::TestCase assert i = h.dig(:data, :issue) assert_equal 'new subject', i[:subject], i.inspect end - - test "should compute payload of deleted issue" do - @issue.destroy - p = WebhookPayload.new('issue.deleted', @issue, @dlopper) - assert h = p.to_h - assert_equal 'issue.deleted', h[:type] - assert_nil h.dig(:data, :journal) - assert i = h.dig(:data, :issue) - assert_equal @issue.subject, i[:subject], i.inspect - end - - test "wiki page created payload should contain page details" do - wiki = @project.wiki - page = WikiPage.new(:title => 'Test Page', :wiki => wiki) - page.content = WikiContent.new(text: 'Test content', author: @dlopper) - page.save! - - p = WebhookPayload.new('wiki_page.created', page, @dlopper) - assert h = p.to_h - assert_equal 'wiki_page.created', h[:type] - assert_equal 'Test_Page', h.dig(:data, :wiki_page, :title) - assert_equal 'Test content', h.dig(:data, :wiki_page, :text) - assert_equal @dlopper.name, h.dig(:data, :wiki_page, :author, :name) - end - - test "wiki page updated payload should contain updated timestamp" do - wiki = @project.wiki - page = WikiPage.new(wiki: wiki, title: 'Test Page') - page.content = WikiContent.new(text: 'Initial content', author: @dlopper) - page.save! - - page.content.text = 'Updated content' - page.content.save! - page.reload - - p = WebhookPayload.new('wiki_page.updated', page, @dlopper) - h = p.to_h - assert_equal 'wiki_page.updated', h[:type] - assert_equal 'Updated content', h.dig(:data, :wiki_page, :text) - end - - test "wiki page deleted payload should contain basic info" do - wiki = @project.wiki - page = WikiPage.new(wiki: wiki, title: 'Test Page') - page.content = WikiContent.new(text: 'Test content', author: @dlopper) - page.save! - - page.destroy - - p = WebhookPayload.new('wiki_page.deleted', page, @dlopper) - h = p.to_h - assert_equal 'wiki_page.deleted', h[:type] - assert_equal 'Test_Page', h.dig(:data, :wiki_page, :title) - end - - test "time entry created payload should contain time entry details" do - time_entry = TimeEntry.generate! - - p = WebhookPayload.new('time_entry.created', time_entry, @dlopper) - assert h = p.to_h - assert_equal 'time_entry.created', h[:type] - assert_equal time_entry.hours, h.dig(:data, :time_entry, :hours) - end - - test "time entry updated payload should contain updated timestamp" do - time_entry = TimeEntry.first - - time_entry.hours = 2.5 - time_entry.save! - - p = WebhookPayload.new('time_entry.updated', time_entry, @dlopper) - h = p.to_h - assert_equal 'time_entry.updated', h[:type] - assert_equal 2.5, h.dig(:data, :time_entry, :hours) - end - - test "time entry deleted payload should contain basic info" do - time_entry = TimeEntry.first - time_entry.destroy - - p = WebhookPayload.new('time_entry.deleted', time_entry, @dlopper) - h = p.to_h - assert_equal 'time_entry.deleted', h[:type] - assert_equal 4.25, h.dig(:data, :time_entry, :hours) - end - - test "news created payload should contain news details" do - news = News.generate! - - p = WebhookPayload.new('news.created', news, @dlopper) - assert h = p.to_h - assert_equal 'news.created', h[:type] - assert_equal news.title, h.dig(:data, :news, :title) - end - - test "news updated payload should contain updated timestamp" do - news = News.first - - news.title = 'Updated title' - news.save! - - p = WebhookPayload.new('news.updated', news, @dlopper) - h = p.to_h - assert_equal 'news.updated', h[:type] - assert_equal 'Updated title', h.dig(:data, :news, :title) - end - - test "news deleted payload should contain basic info" do - news = News.first - news.destroy - - p = WebhookPayload.new('news.deleted', news, @dlopper) - h = p.to_h - assert_equal 'news.deleted', h[:type] - assert_equal 'Updated title', h.dig(:data, :news, :title) - end - - test "version created payload should contain version details" do - version = Version.generate! - - p = WebhookPayload.new('version.created', version, @dlopper) - assert h = p.to_h - assert_equal 'version.created', h[:type] - assert_equal version.name, h.dig(:data, :version, :name) - end - - test "version updated payload should contain updated timestamp" do - version = Version.first - - version.name = 'Updated name' - version.save! - - p = WebhookPayload.new('version.updated', version, @dlopper) - h = p.to_h - assert_equal 'version.updated', h[:type] - assert_equal 'Updated name', h.dig(:data, :version, :name) - end - - test "version deleted payload should contain basic info" do - version = Version.first - version.destroy - - p = WebhookPayload.new('version.deleted', version, @dlopper) - h = p.to_h - assert_equal 'version.deleted', h[:type] - assert_equal 'Updated name', h.dig(:data, :version, :name) - end end -- 2.50.1 (Apple Git-155)