Project

General

Profile

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

patch for r22583 - Takashi Kato, 2024-01-02 19:36

View differences:

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

  
47 48
/node_modules
48 49
yarn-error.log
Gemfile
122 122
end
123 123

  
124 124
# Load plugins' Gemfiles
125
Dir.glob File.expand_path("../plugins/*/{Gemfile,PluginGemfile}", __FILE__) do |file|
126
  eval_gemfile file
125
Dir.glob(File.expand_path("plugins/*/", __dir__)) do |entry|
126
  next unless File.directory?(entry)
127

  
128
  plugin_dir = File.expand_path(entry, __dir__)
129
  spec = File.join plugin_dir, '*.gemspec'
130
  files =
131
    if Dir.exist? spec
132
      Dir.glob(File.join(plugin_dir, "PluginGemfile"))
133
    else
134
      Dir.glob(File.join(plugin_dir, "{Gemfile,PluginGemfile}"))
135
    end
136
  files.each do |file|
137
    eval_gemfile file
138
  end
139
end
140

  
141
extension_gemfile = File.join(File.dirname(__FILE__), "Gemfile.extension")
142
if File.exist?(extension_gemfile)
143
  group :redmine_extension do
144
    eval_gemfile extension_gemfile
145
  end
127 146
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
407 407

  
408 408
  get 'robots', :to => 'welcome#robots'
409 409

  
410
  Redmine::Plugin.directory.glob("*/config/routes.rb").sort.each do |plugin_routes_path|
411
    instance_eval(plugin_routes_path.read, plugin_routes_path.to_s)
410
  Redmine::PluginLoader.directories.each do |plugin_path|
411
    next unless plugin_path.routes
412

  
413
    instance_eval plugin_path.routes.read, plugin_path.routes.to_s
412 414
  rescue SyntaxError, StandardError => e
413
    plugin_name = plugin_routes_path.parent.parent.basename.to_s
414
    puts "An error occurred while loading the routes definition of #{plugin_name} plugin (#{plugin_routes_path}): #{e.message}."
415
    plugin_name = File.basename plugin_path.to_s
416
    puts "An error occurred while loading the routes definition of #{plugin_name} plugin (#{plugin_path.routes}): #{e.message}."
415 417
    exit 1
416 418
  end
417 419
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

  
195 241
    # Sets a requirement on Redmine version
196 242
    # Raises a PluginRequirementError exception if the requirement is not met
197 243
    #
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.all_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
......
79 91
    def has_initializer?
80 92
      File.file?(@initializer)
81 93
    end
94

  
95
    def routes
96
      file = Pathname.new(@dir).join('config/routes.rb')
97
      if file.exist?
98
        file
99
      else
100
        nil
101
      end
102
    end
82 103
  end
83 104

  
84 105
  class PluginLoader
106
    class PluginIdDuplicated < StandardError; end
85 107
    # Absolute path to the directory where plugins are located
86 108
    cattr_accessor :directory
87 109
    self.directory = Rails.root.join Rails.application.config.redmine_plugins_directory
......
102 124

  
103 125
    def self.load
104 126
      setup
105
      add_autoload_paths
106 127

  
107 128
      Rails.application.config.to_prepare do
108 129
        PluginLoader.directories.each(&:run_initializer)
130
        PluginLoader.require_dependencies
109 131

  
110 132
        Redmine::Hook.call_hook :after_plugins_loaded
111 133
      end
......
114 136
    def self.setup
115 137
      @plugin_directories = []
116 138

  
117
      Dir.glob(File.join(directory, '*')).sort.each do |directory|
118
        next unless File.directory?(directory)
139
      Dir.glob(File.join(directory, '*')).sort.each do |dir|
140
        next unless File.directory?(dir)
119 141

  
120
        @plugin_directories << PluginPath.new(directory)
142
        @plugin_directories << PluginPath.new(dir)
121 143
      end
122
    end
123 144

  
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.all_eager_load_paths.each do |dir|
130
          Rails.autoloaders.main.push_dir dir
131
          Rails.application.config.watchable_dirs[dir] = [:rb]
145
      # If there are plugins under plugins/, do not register a gem with the same name.
146
      plugin_specs.each do |spec|
147
        dir = File.join(directory, spec.name)
148
        if File.directory?(dir)
149
          warn "WARN: \"#{spec.name}\" plugin installed as gems also exist in the \"#{dir}\" directory; use the ones in \"#{dir}\"."
150
          next
132 151
        end
152
        @plugin_directories << PluginPath.new(spec.full_gem_path, spec)
153
      end
154
    end
155

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

  
164
      specs
165
    end
166

  
167
    cattr_accessor :dependencies
168
    self.dependencies = []
169

  
170
    def self.require_dependencies
171
      # Load dependencies. If the dependency is a redmine plugin, do not load it
172
      # (it should already be initialized)
173
      deps = dependencies - plugin_specs.map(&:name)
174
      deps.uniq.each do |d|
175
        require d
176
      end
177
    end
178

  
179
    def self.find_path(plugin_id:, plugin_dir:)
180
      path = directories.find {|d| d.gemspec.present? && d.gemspec.metadata['redmine_plugin_id'] == plugin_id.to_s }
181
      if path.nil?
182
        path = directories.find {|d| d.to_s == plugin_dir}
133 183
      end
184
      path
134 185
    end
135 186

  
136 187
    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
test/fixtures/gem_plugins/quux/init.rb
1
# frozen_string_literal: true
2

  
3
Redmine::Plugin.register :quux_plugin do
4
  # For gemmed plugins, the attributes of the plugin are described in the gemspec.
5
  # The correspondence between plugin attributes and gemspec is as follows
6
  name "This name should be overwritten with gemspec 'summary'"
7
  author_url "https://example.org/this_url_should_not_be_overwritten_with_gemspec"
8
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
  # Do not use constants or variables from the gem's own code in this block, as is normally
5
  # done with gems. (e.g. Foo::VERSION)
6
  # Specify the version of redmine or dependencies between plugins in the init.rb file.
7

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

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

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

  
21
  # DO NOT DELETE this attribute
22
  spec.metadata["redmine_plugin_id"] = "quux_plugin"
23

  
24
  spec.metadata['rubygems_mfa_required'] = 'true'
25
end
test/fixtures/invalid_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

  
86
    @klass.directory = Rails.root.join('test/fixtures/invalid_plugins')
87
    @klass.clear
88
    Redmine::PluginLoader.directory = @klass.directory
89
    Redmine::PluginLoader.setup
90

  
91
    plugin_dir = File.join(@klass.directory, 'qux_plugin')
92
    path = Redmine::PluginLoader.find_path(plugin_id: nil, plugin_dir: plugin_dir)
93
    e = assert_raises Redmine::InvalidPluginId do
94
      path.run_initializer
95
    end
96
    assert_equal "The location of init.rb is different from #{Rails.root.join('test/fixtures/gem_plugins/baz')}. It called from #{path.initializer}", e.message
97
  end
98

  
65 99
  def test_register_should_raise_error_if_plugin_directory_does_not_exist
66 100
    e = assert_raises Redmine::PluginNotFound do
67 101
      @klass.register(:bar_plugin) {}
(6-6/6)