Project

General

Profile

Feature #27705 » 0001-Enable-to-use-gem-as-a-Redmine-plugin.patch

Takashi Kato, 2023-08-23 15:48

View differences:

.gitignore
42 42
/.bundle
43 43
/Gemfile.lock
44 44
/Gemfile.local
45
/Gemfile.extension
45 46

  
46 47
/node_modules
47 48
yarn-error.log
Gemfile
114 114
end
115 115

  
116 116
# Load plugins' Gemfiles
117
Dir.glob File.expand_path("../plugins/*/{Gemfile,PluginGemfile}", __FILE__) do |file|
118
  eval_gemfile file
117
Dir.glob(File.expand_path("./plugins/*/", __dir__)) do |entry|
118
  if File.directory?(entry)
119
    plugin_dir = File.expand_path(entry, __dir__)
120
    files =
121
      if Dir.exist?(plugin_dir + "*.gemspec")
122
        Dir.glob(File.join(plugin_dir, "PluginGemfile"))
123
      else
124
        Dir.glob(File.join(plugin_dir,"{Gemfile,PluginGemfile}"))
125
      end
126
    files.each do |file|
127
      eval_gemfile file
128
    end
129
  end
130
end
131

  
132
extension_gemfile = File.join(File.dirname(__FILE__), "Gemfile.extension")
133
if File.exist?(extension_gemfile)
134
  group :redmine_extension do
135
    eval_gemfile extension_gemfile
136
  end
119 137
end
Gemfile.extension.example
1
# vim: set ft=ruby
2
# Copy this file to Gemfile.extension and add any rubygem plugins
3

  
4
# Example:
5
#   gem 'redmine_sample_plugin', '~> 1.1.0'
config/routes.rb
404 404

  
405 405
  get 'robots', :to => 'welcome#robots'
406 406

  
407
  Dir.glob File.expand_path("#{Redmine::Plugin.directory}/*") do |plugin_dir|
408
    file = File.join(plugin_dir, "config/routes.rb")
409
    if File.exist?(file)
407
  Redmine::Plugin.all.each do |plugin|
408
    if file = plugin.routes
410 409
      begin
411 410
        instance_eval File.read(file)
412 411
      rescue SyntaxError, StandardError => e
413
        puts "An error occurred while loading the routes definition of #{File.basename(plugin_dir)} plugin (#{file}): #{e.message}."
412
        puts "An error occurred while loading the routes definition of #{File.basename(plugin.directory)} plugin (#{file}): #{e.message}."
414 413
        exit 1
415 414
      end
416 415
    end
lib/redmine/plugin.rb
25 25
  # Exception raised when a plugin requirement is not met.
26 26
  class PluginRequirementError < StandardError; end
27 27

  
28
  # Exception raised when plugin id and gemspec metadata is not met.
29
  class InvalidPluginId < StandardError; end
30

  
28 31
  # Base class for Redmine plugins.
29 32
  # Plugins are registered using the <tt>register</tt> class method that acts as the public constructor.
30 33
  #
......
95 98
      p = new(id)
96 99
      p.instance_eval(&block)
97 100

  
98
      # Set a default name if it was not provided during registration
99
      p.name(id.to_s.humanize) if p.name.nil?
100
      # Set a default directory if it was not provided during registration
101
      p.directory(File.join(self.directory, id.to_s)) if p.directory.nil?
101
      # Set a default attributes if it was not provided during registration
102
      p.set_default_attrs File.join(self.directory, id.to_s)
102 103

  
103 104
      unless File.directory?(p.directory)
104 105
        raise PluginNotFound, "Plugin not found. The directory for plugin #{p.id} should be #{p.directory}."
105 106
      end
106 107

  
107
      p.path = PluginLoader.directories.find {|d| d.to_s == p.directory}
108
      loc =  caller_locations(1, 1).first.absolute_path
109
      if p.has_initializer? && (File.dirname(loc) != p.directory)
110
        raise InvalidPluginId, "The location of init.rb is different from #{p.directory}. It called from #{loc}"
111
      end
108 112

  
109 113
      # Adds plugin locales if any
110 114
      # YAML translation files should be found under <plugin>/config/locales/
......
136 140
        @used_partials[partial] = p.id
137 141
      end
138 142

  
143
      # Load dependencies
144
      if p.gem?
145
        p.path.gemspec.dependencies.each do |d|
146
          PluginLoader.dependencies << d.name
147
        end
148
      end
149

  
139 150
      registered_plugins[id] = p
140 151
    end
141 152

  
......
192 203
      self.id.to_s <=> plugin.id.to_s
193 204
    end
194 205

  
206
    def set_default_attrs(default_dir)
207
      dir = directory || default_dir
208
      self.path = PluginLoader.find_path(plugin_id: id, plugin_dir: dir)
209

  
210
      if gem?
211
        spec = path.gemspec
212
        name spec.summary if spec.summary
213
        author spec.authors.join(",") if spec.authors
214
        description spec.description if spec.description
215
        version spec.version.to_s if spec.version
216
        url spec.homepage if spec.homepage
217
        author_url spec.metadata["author_url"] if spec.metadata["author_url"]
218
      end
219

  
220
      # Set a default name if it was not provided during registration
221
      name(id.to_s.humanize) if name.nil?
222

  
223
      # Set a default directory if it was not provided during registration
224
      if directory.nil?
225
        if path.present?
226
          directory(path.to_s)
227
        else
228
          directory(default_dir)
229
        end
230
      end
231
    end
232

  
233
    def gem?
234
      path && path.gemspec.present?
235
    end
236

  
237
    def has_initializer?
238
      path.present? && path.has_initializer?
239
    end
240

  
241
    def routes
242
      file = File.join(directory, "config/routes.rb")
243
      if File.exist?(file)
244
        file
245
      else
246
        nil
247
      end
248
    end
249

  
195 250
    # Sets a requirement on Redmine version
196 251
    # Raises a PluginRequirementError exception if the requirement is not met
197 252
    #
lib/redmine/plugin_loader.rb
19 19

  
20 20
module Redmine
21 21
  class PluginPath
22
    attr_reader :assets_dir, :initializer
22
    attr_reader :assets_dir, :initializer, :gemspec
23 23

  
24
    def initialize(dir)
24
    def initialize(dir, gemspec = nil)
25 25
      @dir = dir
26
      @gemspec = gemspec
26 27
      @assets_dir = File.join dir, 'assets'
27 28
      @initializer = File.join dir, 'init.rb'
29
      add_autoload_paths
30
    end
31

  
32
    def add_autoload_paths
33
      # Add the plugin directories to rails autoload paths
34
      engine_cfg = Rails::Engine::Configuration.new(self.to_s)
35
      engine_cfg.paths.add 'lib', eager_load: true
36
      engine_cfg.eager_load_paths.each do |dir|
37
        Rails.autoloaders.main.push_dir dir
38
        Rails.application.config.watchable_dirs[dir] = [:rb]
39
      end
28 40
    end
29 41

  
30 42
    def run_initializer
......
82 94
  end
83 95

  
84 96
  class PluginLoader
97
    class PluginIdDuplicated < StandardError; end
85 98
    # Absolute path to the directory where plugins are located
86 99
    cattr_accessor :directory
87 100
    self.directory = Rails.root.join('plugins')
......
90 103
    cattr_accessor :public_directory
91 104
    self.public_directory = Rails.public_path.join('plugin_assets')
92 105

  
106
    cattr_accessor :dependencies
107
    self.dependencies = []
108

  
93 109
    def self.create_assets_reloader
94 110
      plugin_assets_dirs = {}
95 111
      directories.each do |dir|
......
102 118

  
103 119
    def self.load
104 120
      setup
105
      add_autoload_paths
106 121

  
107 122
      Rails.application.config.to_prepare do
108 123
        PluginLoader.directories.each(&:run_initializer)
124
        PluginLoader.require_dependencies
109 125

  
110 126
        Redmine::Hook.call_hook :after_plugins_loaded
111 127
      end
......
114 130
    def self.setup
115 131
      @plugin_directories = []
116 132

  
117
      Dir.glob(File.join(directory, '*')).sort.each do |directory|
118
        next unless File.directory?(directory)
133
      Dir.glob(File.join(directory, '*')).sort.each do |dir|
134
        next unless File.directory?(dir)
119 135

  
120
        @plugin_directories << PluginPath.new(directory)
136
        @plugin_directories << PluginPath.new(dir)
121 137
      end
122
    end
123 138

  
124
    def self.add_autoload_paths
125
      directories.each do |directory|
126
        # Add the plugin directories to rails autoload paths
127
        engine_cfg = Rails::Engine::Configuration.new(directory.to_s)
128
        engine_cfg.paths.add 'lib', eager_load: true
129
        engine_cfg.eager_load_paths.each do |dir|
130
          Rails.autoloaders.main.push_dir dir
131
          Rails.application.config.watchable_dirs[dir] = [:rb]
139
      # If there are plugins under plugins/, do not register a gem with the same name.
140
      plugin_specs.each do |spec|
141
        dir = File.join(directory, spec.name)
142
        if File.directory?(dir)
143
          warn "WARN: \"#{spec.name}\" plugin installed as gems also exist in the \"#{dir}\" directory; use the ones in \"#{dir}\"."
144
          next
132 145
        end
146
        @plugin_directories << PluginPath.new(spec.full_gem_path, spec)
147
      end
148
    end
149

  
150
    def self.plugin_specs
151
      specs = Bundler.definition
152
                     .specs_for([:redmine_extension])
153
                     .to_a
154
                     .select{|s| s.name != 'bundler' && !s.metadata['redmine_plugin_id'].nil?}
155
      duplicates = specs.group_by{|s| s.metadata['redmine_plugin_id']}.reject{|k, v| v.one?}.keys
156
      raise PluginIdDuplicated.new("#{duplicates.join(',')} Duplicate plugin id") unless duplicates.empty?
157

  
158
      specs
159
    end
160

  
161
    def self.require_dependencies
162
      # Load dependencies. If the dependency is a redmine plugin, do not load it
163
      # (it should already be initialized)
164
      deps = dependencies - plugin_specs.map(&:name)
165
      deps.uniq.each do |d|
166
        require d
167
      end
168
    end
169

  
170
    def self.find_path(plugin_id:, plugin_dir:)
171
      path = directories.find {|d| d.gemspec.present? && d.gemspec.metadata['redmine_plugin_id'] == plugin_id.to_s }
172
      if path.nil?
173
        path = directories.find {|d| d.to_s == plugin_dir}
133 174
      end
175
      path
134 176
    end
135 177

  
136 178
    def self.directories
test/fixtures/gem_plugins/.gitignore
1
Gemfile.lock
test/fixtures/gem_plugins/Gemfile
1
# frozen_string_literal: true
2

  
3
original_gemfile = File.join(File.dirname(__FILE__), "../../../Gemfile")
4

  
5
eval_gemfile original_gemfile
6

  
7
gem 'quux', path: './quux', require: false
8

  
9
group :redmine_extension do
10
  gem 'baz', path: './baz'
11
end
test/fixtures/gem_plugins/baz/Gemfile
1
# frozen_string_literal: true
2

  
3
source "https://rubygems.org"
4

  
5
# Specify your gem's dependencies in baz_plugin.gemspec
6
gemspec
test/fixtures/gem_plugins/baz/baz.gemspec
1
# frozen_string_literal: true
2

  
3
Gem::Specification.new do |spec|
4
  spec.name = "baz"
5
  spec.version = "0.0.1"
6
  spec.authors = ['johndoe', 'janedoe']
7
  spec.email = ['johndoe@example.org']
8

  
9
  spec.summary = "Baz Plugin"
10
  spec.description = "This is a gemified plugin for Redmine"
11
  spec.homepage = "https://example.org/plugins/baz"
12

  
13
  spec.required_ruby_version = ">= 2.7.0"
14

  
15
  spec.metadata['allowed_push_host'] = "https://example.org"
16

  
17
  spec.metadata['redmine_plugin_id'] = "baz_plugin"
18
  spec.metadata['rubygems_mfa_required'] = "true"
19
  spec.files = Dir["{app,lib,config,assets,db}/**/*", "init.rb", "Gemfile", "README.rdoc"]
20

  
21
  spec.add_dependency 'quux'
22
end
test/fixtures/gem_plugins/baz/init.rb
1
# frozen_string_literal: true
2

  
3
Redmine::Plugin.register :baz_plugin do
4
  name "This name should be overwritten with gemspec 'summary'"
5
  author_url "https://example.org/this_url_should_not_be_overwritten_with_gemspec"
6
end
test/fixtures/gem_plugins/baz/lib/baz_plugin.rb
1
# frozen_string_literal: true
2

  
3
module BazPlugin
4
  class Error < StandardError; end
5
end
test/fixtures/gem_plugins/quux/Gemfile
1
# frozen_string_literal: true
2

  
3
source "https://rubygems.org"
4

  
5
# Specify your gem's dependencies in quux.gemspec
6
gemspec
7

  
test/fixtures/gem_plugins/quux/init.rb
1
Redmine::Plugin.register :quux_plugin do
2
  # For gemmed plugins, the attributes of the plugin are described in the gemspec.
3
  # The correspondence between plugin attributes and gemspec is as follows
4
  name "This name should be overwritten with gemspec 'summary'"
5
  author_url "https://example.org/this_url_should_not_be_overwritten_with_gemspec"
6
end
test/fixtures/gem_plugins/quux/lib/quux_plugin.rb
1
# frozen_string_literal: true
2

  
3
module QuuxPlugin
4
  class Error < StandardError; end
5
end
test/fixtures/gem_plugins/quux/quux.gemspec
1
# frozen_string_literal: true
2

  
3
Gem::Specification.new do |spec|
4

  
5
  # Do not use constants or variables from the gem's own code in this block, as is normally
6
  # done with gems. (e.g. Foo::VERSION)
7
  # Specify the version of redmine or dependencies between plugins in the init.rb file.
8

  
9
  spec.name = "quux"
10
  spec.version = "0.0.1"
11
  spec.authors = ["johndoe"]
12
  spec.email = ["johndoe@example.org"]
13

  
14
  spec.summary = "Quux plugin"
15
  spec.description = "This is a plugin for Redmine"
16
  spec.homepage = "https://example.org"
17
  spec.required_ruby_version = ">= 2.7.0"
18

  
19
  spec.metadata["author_url"] = spec.homepage
20
  spec.files = Dir["{lib}/**/*", "init.rb", "Gemfile"]
21

  
22
  # DO NOT DELETE this attribute
23
  spec.metadata["redmine_plugin_id"] = "quux_plugin"
24
end
test/fixtures/plugins/qux_plugin/init.rb
1
# frozen_string_literal: true
2

  
3
Redmine::Plugin.register :baz_plugin do
4
  name "This name should be overwritten with gemspec 'summary'"
5
end
test/unit/lib/redmine/plugin_test.rb
37 37
    @klass.clear
38 38
  end
39 39

  
40
  def gemfile_for_test?
41
    File.expand_path(ENV['BUNDLE_GEMFILE']) == Rails.root.join('test/fixtures/gem_plugins/Gemfile').expand_path.to_s
42
  end
43

  
40 44
  def test_register
41 45
    @klass.register :foo_plugin do
42 46
      name 'Foo plugin'
......
62 66
    assert_equal File.join(@klass.directory, 'foo_plugin', 'assets'), plugin.assets_directory
63 67
  end
64 68

  
69
  def test_gemified_plugin
70
    skip unless gemfile_for_test?
71
    path = Redmine::PluginLoader.find_path(plugin_id: "baz_plugin", plugin_dir: nil)
72
    path.run_initializer
73
    plugin = @klass.find('baz_plugin')
74
    assert_equal :baz_plugin, plugin.id
75
    assert_equal 'Baz Plugin', plugin.name
76
    assert_equal 'https://example.org/plugins/baz', plugin.url
77
    assert_equal 'johndoe,janedoe', plugin.author
78
    assert_equal 'https://example.org/this_url_should_not_be_overwritten_with_gemspec', plugin.author_url
79
    assert_equal 'This is a gemified plugin for Redmine', plugin.description
80
    assert_equal '0.0.1', plugin.version
81
  end
82

  
83
  def test_invalid_gemified_plugin
84
    skip unless gemfile_for_test?
85
    path = Redmine::PluginLoader.find_path(plugin_id: nil, plugin_dir: File.join(@klass.directory, 'qux_plugin'))
86
    e = assert_raises Redmine::InvalidPluginId do
87
      path.run_initializer
88
    end
89
    initrb = Rails.root.join('test/fixtures/plugins/qux_plugin/init.rb')
90
    assert_equal "The location of init.rb is different from #{Rails.root.join('test/fixtures/gem_plugins/baz')}. It called from #{initrb}", e.message
91
  end
92

  
65 93
  def test_register_should_raise_error_if_plugin_directory_does_not_exist
66 94
    e = assert_raises Redmine::PluginNotFound do
67 95
      @klass.register(:bar_plugin) {}
(2-2/6)