Index: test/unit/lib/redmine/plugin_test.rb
===================================================================
--- test/unit/lib/redmine/plugin_test.rb	(revision 0)
+++ test/unit/lib/redmine/plugin_test.rb	(revision 0)
@@ -0,0 +1,20 @@
+require File.dirname(__FILE__) + '/../../../test_helper'
+
+class Redmine::PluginTest < Test::Unit::TestCase
+  def test_sanity
+    assert true
+  end
+  
+  def test_add_hook
+    assert_equal false, Redmine::Plugin::Hook::Manager.hook_registered?(:issue_show)
+    Redmine::Plugin.add_hook(:issue_show, Proc.new { })
+    assert Redmine::Plugin::Hook::Manager.hook_registered?(:issue_show)
+  end
+  
+  def test_add_hook_invalid
+    assert_equal false, Redmine::Plugin::Hook::Manager.hook_registered?(:invalid)
+    Redmine::Plugin.add_hook(:invalid, Proc.new { })
+    assert Redmine::Plugin::Hook::Manager.hook_registered?(:invalid)
+  end
+end
+
Index: test/unit/lib/redmine/plugin_hook_test.rb
===================================================================
--- test/unit/lib/redmine/plugin_hook_test.rb	(revision 0)
+++ test/unit/lib/redmine/plugin_hook_test.rb	(revision 0)
@@ -0,0 +1,128 @@
+require File.dirname(__FILE__) + '/../../../test_helper'
+
+class Redmine::Plugin::Hook::ManagerTest < Test::Unit::TestCase
+  def setup
+    @manager =  Redmine::Plugin::Hook::Manager
+  end
+  
+  def teardown
+    @manager.clear_listeners
+  end
+  
+  def test_sanity
+    assert true
+  end
+  
+  def test_hook_format
+    assert_kind_of Hash, @manager::hooks
+    @manager::hooks.each do |hook, registrations|
+      assert_kind_of Symbol, hook
+      assert_kind_of Array, registrations
+      assert_equal 0, registrations.length
+    end
+  end
+  
+  def test_valid_hook
+    assert @manager::valid_hook?(:issue_show)
+  end
+  
+  def test_invalid_hook
+    assert_equal false, @manager::valid_hook?(:an_invalid_hook_name)
+  end
+  
+  def test_clear_listeners
+    assert_equal 0, @manager::hooks[:issue_show].length
+    @manager.add_listener(:issue_show, Proc.new { } )
+    @manager.add_listener(:issue_show, Proc.new { } )
+    @manager.add_listener(:issue_show, Proc.new { } )
+    @manager.add_listener(:issue_show, Proc.new { } )
+    assert_equal 4, @manager::hooks[:issue_show].length
+    
+    @manager.clear_listeners
+    assert_equal 0, @manager::hooks[:issue_show].length
+  end
+  
+  def test_add_listener
+    assert_equal 0, @manager::hooks[:issue_show].length
+    @manager.add_listener(:issue_show, Proc.new { } )
+    assert_equal 1, @manager::hooks[:issue_show].length
+  end
+  
+  def test_add_invalid_listener
+    hooks = @manager::hooks
+    @manager.add_listener(:invalid, Proc.new { } )
+    assert_equal hooks, @manager::hooks
+  end
+  
+  def test_call_hook_with_response
+    function = Proc.new { return 'response' }
+    
+    @manager.add_listener(:issue_show, function)
+    
+    assert_equal 'response', @manager.call_hook(:issue_show)
+  end
+
+  def test_call_multiple_hooks_with_response
+    function1 = Proc.new { return 'First Call.' }
+    function2 = Proc.new { return 'Second Call.' }
+    function3 = Proc.new { return 'Third Call.' }
+    
+    @manager.add_listener(:issue_show, function1)
+    @manager.add_listener(:issue_show, function2)
+    @manager.add_listener(:issue_show, function3)
+    
+    assert_equal 'First Call.Second Call.Third Call.', @manager.call_hook(:issue_show)
+  end
+
+  def test_call_hook_without_response
+    function = Proc.new { }
+    
+    @manager.add_listener(:issue_show, function)
+    
+    assert_equal '', @manager.call_hook(:issue_show)
+  end
+
+   def test_call_multiple_hooks_without_responses
+     function1 = Proc.new { }
+     function2 = Proc.new { }
+     function3 = Proc.new { }
+    
+     @manager.add_listener(:issue_show, function1)
+     @manager.add_listener(:issue_show, function2)
+     @manager.add_listener(:issue_show, function3)
+    
+     assert_equal '', @manager.call_hook(:issue_show)
+   end
+
+   def test_hook_registered_yes
+     @manager.add_listener(:issue_show, Proc.new { })
+     assert @manager.hook_registered?(:issue_show)
+   end
+
+    def test_hook_registered_no
+     assert_equal false, @manager.hook_registered?(:issue_show)
+   end
+end
+
+class Redmine::Plugin::Hook::BaseTest < Test::Unit::TestCase
+  def test_sanity
+    assert true
+  end
+  
+  def test_help_should_be_a_singleton
+    assert Redmine::Plugin::Hook::Base::Helper.include?(Singleton)
+  end
+  
+  def test_helper_should_include_actionview_helpers
+    [ActionView::Helpers::TagHelper,
+     ActionView::Helpers::FormHelper,
+     ActionView::Helpers::FormTagHelper,
+     ActionView::Helpers::FormOptionsHelper,
+     ActionView::Helpers::JavaScriptHelper, 
+     ActionView::Helpers::PrototypeHelper,
+     ActionView::Helpers::NumberHelper,
+     ActionView::Helpers::UrlHelper].each do |helper|
+      assert Redmine::Plugin::Hook::Base::Helper.include?(helper), "#{helper} wasn't included."
+    end
+  end
+end
Index: app/helpers/issues_helper.rb
===================================================================
--- app/helpers/issues_helper.rb	(revision 1709)
+++ app/helpers/issues_helper.rb	(working copy)
@@ -86,7 +86,9 @@
     when 'attachment'
       label = l(:label_attachment)
     end
-       
+
+    Redmine::Plugin::Hook::Manager.call_hook(:issues_helper_show_details, {:detail => detail, :label => label, :value => value, :old_value => old_value })
+    
     label ||= detail.prop_key
     value ||= detail.value
     old_value ||= detail.old_value
Index: app/controllers/issues_controller.rb
===================================================================
--- app/controllers/issues_controller.rb	(revision 1709)
+++ app/controllers/issues_controller.rb	(working copy)
@@ -223,7 +223,6 @@
       assigned_to = (params[:assigned_to_id].blank? || params[:assigned_to_id] == 'none') ? nil : User.find_by_id(params[:assigned_to_id])
       category = (params[:category_id].blank? || params[:category_id] == 'none') ? nil : @project.issue_categories.find_by_id(params[:category_id])
       fixed_version = (params[:fixed_version_id].blank? || params[:fixed_version_id] == 'none') ? nil : @project.versions.find_by_id(params[:fixed_version_id])
-      
       unsaved_issue_ids = []      
       @issues.each do |issue|
         journal = issue.init_journal(User.current, params[:notes])
@@ -234,6 +233,9 @@
         issue.start_date = params[:start_date] unless params[:start_date].blank?
         issue.due_date = params[:due_date] unless params[:due_date].blank?
         issue.done_ratio = params[:done_ratio] unless params[:done_ratio].blank?
+
+        Redmine::Plugin::Hook::Manager.call_hook(:issue_bulk_edit_save, {:params => params, :issue => issue })
+
         # Don't save any change to the issue if the user is not authorized to apply the requested status
         if (status.nil? || (issue.status.new_status_allowed_to?(status, current_role, issue.tracker) && issue.status = status)) && issue.save
           # Send notification for each issue (if changed)
Index: app/views/projects/settings/_members.rhtml
===================================================================
--- app/views/projects/settings/_members.rhtml	(revision 1709)
+++ app/views/projects/settings/_members.rhtml	(working copy)
@@ -9,6 +9,7 @@
 	<thead>
 	  <th><%= l(:label_user) %></th>
 	  <th><%= l(:label_role) %></th>
+          <%= Redmine::Plugin::Hook::Manager.call_hook(:project_member_list_header, {:project => @project }) %>
 	  <th style="width:15%"></th>
 	</thead>
 	<tbody>
@@ -24,6 +25,8 @@
       <% end %>
     <% end %>
     </td>
+    <%= Redmine::Plugin::Hook::Manager.call_hook(:project_member_list_column_three, {:project => @project, :member => member }) %>
+
     <td align="center">
       <%= link_to_remote l(:button_delete), { :url => {:controller => 'members', :action => 'destroy', :id => member},                                              
                                               :method => :post
Index: app/views/issues/bulk_edit.rhtml
===================================================================
--- app/views/issues/bulk_edit.rhtml	(revision 1709)
+++ app/views/issues/bulk_edit.rhtml	(working copy)
@@ -38,6 +38,7 @@
 <label><%= l(:field_done_ratio) %>: 
 <%= select_tag 'done_ratio', options_for_select([[l(:label_no_change_option), '']] + (0..10).to_a.collect {|r| ["#{r*10} %", r*10] }) %></label>
 </p>
+<%= Redmine::Plugin::Hook::Manager.call_hook(:issue_bulk_edit, {:project => @project, :issue => @issues }) %>
 </fieldset>
 
 <fieldset><legend><%= l(:field_notes) %></legend>
Index: app/views/issues/_form.rhtml
===================================================================
--- app/views/issues/_form.rhtml	(revision 1709)
+++ app/views/issues/_form.rhtml	(working copy)
@@ -48,4 +48,6 @@
 <p><label><%=l(:label_attachment_plural)%></label><%= render :partial => 'attachments/form' %></p>
 <% end %>
 
+<%= Redmine::Plugin::Hook::Manager.call_hook(:issue_edit, {:project => @project, :issue => @issue, :form => f }) %>
+
 <%= wikitoolbar_for 'issue_description' %>
Index: app/views/issues/show.rhtml
===================================================================
--- app/views/issues/show.rhtml	(revision 1709)
+++ app/views/issues/show.rhtml	(working copy)
@@ -53,6 +53,8 @@
  <%end
 end %>
 </tr>
+<%= Redmine::Plugin::Hook::Manager.call_hook(:issue_show, {:project => @project, :issue => @issue}) %>
+       
 </table>
 <hr />
 
Index: lib/redmine/plugin.rb
===================================================================
--- lib/redmine/plugin.rb	(revision 1709)
+++ lib/redmine/plugin.rb	(working copy)
@@ -117,6 +117,18 @@
       @project_module = nil
     end
     
+    # Registers a +method+ to be called when Redmine runs a hook called
+    # +hook_name+
+    #
+    #   # Run puts whenever the issue_show hook is called
+    #   add_hook :issue_show, Proc.new { puts 'Hello' }
+    #
+    #   # Call the class method +my_method+ passing in all the context
+    #   add_hook :issue_show, Proc.new {|context| MyPlugin.my_method(context)}
+    def add_hook(hook_name, method)
+      Redmine::Plugin::Hook::Manager.add_listener(hook_name, method)
+    end
+      
     # Registers an activity provider.
     #
     # Options:
@@ -147,5 +159,92 @@
     def configurable?
       settings && settings.is_a?(Hash) && !settings[:partial].blank?
     end
+    
+    # Hook is used to allow plugins to hook into Redmine at specific sections
+    # to change it's behavior.  See +Redmine::Plugin.add_hook+ for details.
+    class Hook
+      class Manager
+        # Hooks and the procs added
+        @@hooks = {
+          :issue_show => [],
+          :issue_edit => [],
+          :issue_bulk_edit => [],
+          :issue_bulk_edit_save => [],
+          :issue_update => [],
+          :project_member_list_header => [],
+          :project_member_list_column_three => [],
+          :issues_helper_show_details => []
+        }
+        
+        cattr_reader :hooks
+      
+        class << self
+        
+          def valid_hook?(hook_name)
+            return @@hooks.has_key?(hook_name)
+          end
+
+          # Add +method+ to +hook_name+
+          def add_listener(hook_name, method)
+            if valid_hook?(hook_name)
+              @@hooks[hook_name.to_sym] << method
+              puts "Listener added for #{hook_name.to_s}"
+            end
+          end
+
+          # Removes all listeners
+          def clear_listeners()
+            @@hooks.each do |hook, registrations|
+              @@hooks[hook] = []
+            end
+          end
+        
+          # Run all the hooks for +hook_name+ passing in +context+
+          def call_hook(hook_name, context = { })
+            response = ''
+            @@hooks[hook_name.to_sym].each do |method|
+              method_response = method.call(context)
+              response += method_response unless method_response.nil?
+            end
+            response
+          end
+        
+          # Are hooks registered for +hook_name+
+          def hook_registered?(hook_name)
+            return @@hooks[hook_name.to_sym].size > 0
+          end
+        end
+      end
+
+      # Base class for Redmin Plugin hooks.
+      class Base
+        
+        # Class level access to Rails' helper methods.
+        def self.help
+          Helper.instance
+        end
+        
+        # Includes several Helper methods to be used in the class
+        class Helper # :nodoc:
+          include Singleton
+          include ERB::Util
+          include ActionView::Helpers::TagHelper
+          include ActionView::Helpers::FormHelper
+          include ActionView::Helpers::FormTagHelper
+          include ActionView::Helpers::FormOptionsHelper
+          include ActionView::Helpers::JavaScriptHelper 
+          include ActionView::Helpers::PrototypeHelper
+          include ActionView::Helpers::NumberHelper
+          include ActionView::Helpers::UrlHelper
+          
+          include ActionController::UrlWriter 
+          
+          def protect_against_forgery? # :nodoc:
+            false
+          end
+          
+        end
+      end
+    end
   end
 end

