Index: test/unit/changeset_test.rb
===================================================================
--- test/unit/changeset_test.rb	(revision 1616)
+++ test/unit/changeset_test.rb	(working copy)
@@ -32,25 +32,210 @@
     c = Changeset.new(:repository => Project.find(1).repository,
                       :committed_on => Time.now,
                       :comments => 'New commit (#2). Fixes #1')
-    c.scan_comment_for_issue_ids
-    
+
+    c.parse_comment
+
     assert_equal [1, 2], c.issue_ids.sort
     fixed = Issue.find(1)
     assert fixed.closed?
     assert_equal 90, fixed.done_ratio
   end
+
+  def test_not_associate_any_mentioned_tickets
+    Setting.commit_ref_keywords = 'key'
+    
+    c = Changeset.new(:repository => Project.find(1).repository,
+                      :committed_on => Time.now,
+                      :comments => 'New commit (#2). #1')
+
+    c.parse_comment
+
+    assert_equal [], c.issue_ids
+  end
   
+  def test_fixes_multiple_tickets
+    Setting.commit_fix_status_id = IssueStatus.find(:first, :conditions => ["is_closed = ?", true]).id
+    Setting.commit_fix_keywords = 'fixes , closes'
+    
+    c = Changeset.new(:repository => Project.find(1).repository,
+                      :committed_on => Time.now,
+                      :comments => 'Fixes #1,#2')
+
+    c.parse_comment
+
+    assert_equal [1, 2], c.issue_ids.sort
+  end
+  
   def test_ref_keywords_any_line_start
     Setting.commit_ref_keywords = '*'
 
     c = Changeset.new(:repository => Project.find(1).repository,
                       :committed_on => Time.now,
                       :comments => '#1 is the reason of this commit')
-    c.scan_comment_for_issue_ids
+    c.parse_comment
 
     assert_equal [1], c.issue_ids.sort
   end
 
+  def test_log_time_without_issues_should_do_nothing
+    count = TimeEntry.count
+    Setting.advanced_commit_parsing = 1
+    Setting.commit_ref_keywords = '*'
+
+    c = Changeset.new(:repository => Project.find(1).repository,
+                      :committed_on => Time.now,
+                      :comments => 'time 3,5')
+    c.parse_comment
+
+    assert_equal count, TimeEntry.count
+  end
+
+  def test_log_time_should_work
+    count = TimeEntry.count
+    Setting.advanced_commit_parsing = 1
+    Setting.commit_ref_keywords = '*'
+    
+    comment = <<-EOL
+#1
+time 3,5
+EOL
+    
+    c = Changeset.new(:repository => Project.find(1).repository,
+                      :committed_on => Time.now,
+                      :comments => comment)
+
+    c.parse_comment
+
+    assert_equal count+1, TimeEntry.count
+  end
+
+  def test_log_time_splits_the_time_equally
+    Setting.commit_fix_keywords = 'fixes , closes'
+    Setting.advanced_commit_parsing = 1
+    
+    comment = <<-EOL
+fixes #1,#2
+time 3
+
+the comment
+EOL
+    
+    c = Changeset.new(:repository => Project.find(1).repository,
+                      :committed_on => Time.now,
+                      :comments => comment)
+
+    c.parse_comment
+
+    time_entry = TimeEntry.find(:first, :order => 'id DESC')
+    assert_equal 1.5, time_entry.hours
+    assert_equal 'the comment', time_entry.comments
+
+  end
+
+  def test_extract_time
+    c = Changeset.new
+    time_formats =  [ "2", "21.1", "2,1","7:12", "10h", "10 h", "45m", "45 m", "3h15", "3h 15", "3 h 15", "3 h 15m", "3 h 15 m"]
+    
+    time_formats.each do |format|
+      assert_equal format, c.send(:extract_time!, "time #{format}")
+    end
+  end
+
+  def test_set_done_ratio
+    Setting.commit_ref_keywords = '*'
+    Setting.advanced_commit_parsing = 1
+
+    comment = <<-EOL
+#1
+done 50%
+#2
+done 40
+EOL
+    
+    c = Changeset.new(:repository => Project.find(1).repository,
+                      :committed_on => Time.now,
+                      :comments => comment)
+
+    c.parse_comment
+
+    assert_equal [1, 2], c.issue_ids.sort
+    assert_equal 50, Issue.find(1).done_ratio
+    assert_equal 40, Issue.find(2).done_ratio
+  end
+
+  def test_set_committer_identified_by_email
+    Setting.commit_fix_keywords = 'fixes'
+    user = User.find(:first)
+    
+    c = Changeset.new(:repository => Project.find(1).repository,
+                      :committed_on => Time.now,
+                      :committer => "arnie<#{user.mail}>",
+                      :comments => 'Fixes #1')
+
+    c.parse_comment
+
+    fixed = Issue.find(1)
+    assert fixed.closed?
+    assert_equal user, fixed.journals.find(:first, :order => 'id DESC').user
+  end
+
+  def test_set_committer_identified_by_login
+    Setting.commit_fix_keywords = 'fixes'
+    user = User.find(:first)
+    
+    c = Changeset.new(:repository => Project.find(1).repository,
+                      :committed_on => Time.now,
+                      :committer => user.login,
+                      :comments => 'Fixes #1')
+
+    c.parse_comment
+
+    fixed = Issue.find(1)
+    assert fixed.closed?
+    assert_equal user, fixed.journals.find(:first, :order => 'id DESC').user
+  end
+
+  def test_set_annonymous_if_committer_unknown
+    Setting.commit_fix_keywords = 'fixes'
+
+    c = Changeset.new(:repository => Project.find(1).repository,
+                      :committed_on => Time.now,
+                      :committer => 'arnie',
+                      :comments => 'Fixes #1')
+
+    c.parse_comment
+
+    fixed = Issue.find(1)
+    assert fixed.closed?
+    assert_equal User.anonymous, fixed.journals.find(:first, :order => 'id DESC').user
+  end
+
+  def test_mail_deliveries
+    ActionMailer::Base.deliveries.clear
+
+    Setting.commit_fix_keywords = 'fixes'
+    
+    c = Changeset.new(:repository => Project.find(1).repository,
+                      :committed_on => Time.now,
+                      :comments => 'Fixes #1')
+
+    c.parse_comment
+    
+    assert_equal 1, ActionMailer::Base.deliveries.size
+  end
+
+  def test_ignore_cross_refrenced_issue_ids
+    Setting.commit_fix_keywords = 'fixes'
+
+    c = Changeset.new(:repository => Project.find(1).repository,
+                      :committed_on => Time.now,
+                      :comments => 'Fixes #1234')
+
+    c.parse_comment
+
+    assert_equal [], c.issue_ids.sort
+  end
+
   def test_previous
     changeset = Changeset.find_by_revision('3')
     assert_equal Changeset.find_by_revision('2'), changeset.previous
@@ -70,4 +255,16 @@
     changeset = Changeset.find_by_revision('4')
     assert_nil changeset.next
   end
+
+  def test_for_changeset_comments_strip
+    comment = <<-COMMENT
+    This is a loooooooooooooooooooooooooooong comment                                                   
+                                                                                                       
+                                                                                            
+    COMMENT
+    changeset = Changeset.new :comments => comment
+    assert_equal( 'This is a loooooooooooooooooooooooooooong comment', changeset.comments )
+  end
+  
+
 end
Index: test/unit/repository_test.rb
===================================================================
--- test/unit/repository_test.rb	(revision 1616)
+++ test/unit/repository_test.rb	(working copy)
@@ -55,60 +55,6 @@
     Setting.delete_all
   end
   
-  def test_scan_changesets_for_issue_ids
-    # choosing a status to apply to fix issues
-    Setting.commit_fix_status_id = IssueStatus.find(:first, :conditions => ["is_closed = ?", true]).id
-    Setting.commit_fix_done_ratio = "90"
-    Setting.commit_ref_keywords = 'refs , references, IssueID'
-    Setting.commit_fix_keywords = 'fixes , closes'
-    Setting.default_language = 'en'
-    ActionMailer::Base.deliveries.clear
-    
-    # make sure issue 1 is not already closed
-    fixed_issue = Issue.find(1)
-    assert !fixed_issue.status.is_closed?
-    old_status = fixed_issue.status
-        
-    Repository.scan_changesets_for_issue_ids
-    assert_equal [101, 102], Issue.find(3).changeset_ids
-    
-    # fixed issues
-    fixed_issue.reload
-    assert fixed_issue.status.is_closed?
-    assert_equal 90, fixed_issue.done_ratio
-    assert_equal [101], fixed_issue.changeset_ids
-    
-    # issue change
-    journal = fixed_issue.journals.find(:first, :order => 'created_on desc')
-    assert_equal User.find_by_login('dlopper'), journal.user
-    assert_equal 'Applied in changeset r2.', journal.notes
-    
-    # 2 email notifications
-    assert_equal 2, ActionMailer::Base.deliveries.size
-    mail = ActionMailer::Base.deliveries.first
-    assert_kind_of TMail::Mail, mail
-    assert mail.subject.starts_with?("[#{fixed_issue.project.name} - #{fixed_issue.tracker.name} ##{fixed_issue.id}]")
-    assert mail.body.include?("Status changed from #{old_status} to #{fixed_issue.status}")
-    
-    # ignoring commits referencing an issue of another project
-    assert_equal [], Issue.find(4).changesets
-  end
-  
-  def test_for_changeset_comments_strip
-    repository = Repository::Mercurial.create( :project => Project.find( 4 ), :url => '/foo/bar/baz' )
-    comment = <<-COMMENT
-    This is a loooooooooooooooooooooooooooong comment                                                   
-                                                                                                       
-                                                                                            
-    COMMENT
-    changeset = Changeset.new(
-      :comments => comment, :commit_date => Time.now, :revision => 0, :scmid => 'f39b7922fb3c',
-      :committer => 'foo <foo@example.com>', :committed_on => Time.now, :repository => repository )
-    assert( changeset.save )
-    assert_not_equal( comment, changeset.comments )
-    assert_equal( 'This is a loooooooooooooooooooooooooooong comment', changeset.comments )
-  end
-  
   def test_for_urls_strip
     repository = Repository::Cvs.create(:project => Project.find(4), :url => ' :pserver:login:password@host:/path/to/the/repository',
                                                                      :root_url => 'foo  ')
Index: app/models/repository.rb
===================================================================
--- app/models/repository.rb	(revision 1616)
+++ app/models/repository.rb	(working copy)
@@ -84,10 +84,6 @@
     @latest_changeset ||= changesets.find(:first)
   end
     
-  def scan_changesets_for_issue_ids
-    self.changesets.each(&:scan_comment_for_issue_ids)
-  end
-  
   # fetch new changesets for all repositories
   # can be called periodically by an external script
   # eg. ruby script/runner "Repository.fetch_changesets"
@@ -95,11 +91,6 @@
     find(:all).each(&:fetch_changesets)
   end
   
-  # scan changeset comments to find related and fixed issues for all repositories
-  def self.scan_changesets_for_issue_ids
-    find(:all).each(&:scan_changesets_for_issue_ids)
-  end
-
   def self.scm_name
     'Abstract'
   end
Index: app/models/changeset.rb
===================================================================
--- app/models/changeset.rb	(revision 1616)
+++ app/models/changeset.rb	(working copy)
@@ -34,6 +34,8 @@
   validates_presence_of :repository_id, :revision, :committed_on, :commit_date
   validates_uniqueness_of :revision, :scope => :repository_id
   validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
+
+  after_save :parse_comment
   
   def revision=(r)
     write_attribute :revision, (r.nil? ? nil : r.to_s)
@@ -52,80 +54,166 @@
     repository.project
   end
   
-  def after_create
-    scan_comment_for_issue_ids
+  # This starts the comment parsing. Executed by an after_save filter
+  def parse_comment
+    return if comments.blank?
+
+    keywords = (ref_keywords + fix_keywords)
+    return if keywords.blank?
+
+    process_issues_marked_by(keywords)
   end
-  require 'pp'
-  
-  def scan_comment_for_issue_ids
-    return if comments.blank?
-    # keywords used to reference issues
-    ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
-    # keywords used to fix issues
-    fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
-    # status and optional done ratio applied
-    fix_status = IssueStatus.find_by_id(Setting.commit_fix_status_id)
-    done_ratio = Setting.commit_fix_done_ratio.blank? ? nil : Setting.commit_fix_done_ratio.to_i
-    
-    kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|")
-    return if kw_regexp.blank?
-    
+
+  # Returns the previous changeset
+  def previous
+    @previous ||= Changeset.find(:first, :conditions => ['id < ? AND repository_id = ?', self.id, self.repository_id], :order => 'id DESC')
+  end
+
+  # Returns the next changeset
+  def next
+    @next ||= Changeset.find(:first, :conditions => ['id > ? AND repository_id = ?', self.id, self.repository_id], :order => 'id ASC')
+  end
+
+ protected
+
+  # This parses the whole comment. Therefore the comment gets split into parts.
+  def process_issues_marked_by(ticket_keywords)
     referenced_issues = []
-    
-    if ref_keywords.delete('*')
-      # find any issue ID in the comments
-      target_issue_ids = []
-      comments.scan(%r{([\s\(,-]|^)#(\d+)(?=[[:punct:]]|\s|<|$)}).each { |m| target_issue_ids << m[1] }
-      referenced_issues += repository.project.issues.find_all_by_id(target_issue_ids)
-    end
-    
-    comments.scan(Regexp.new("(#{kw_regexp})[\s:]+(([\s,;&]*#?\\d+)+)", Regexp::IGNORECASE)).each do |match|
+    comments.scan( splitting_regexp(ticket_keywords) ).each do |match|
       action = match[0]
       target_issue_ids = match[1].scan(/\d+/)
+      rest = match.last
+
       target_issues = repository.project.issues.find_all_by_id(target_issue_ids)
-      if fix_status && fix_keywords.include?(action.downcase)
+      process_part(action, target_issues, rest)
+
+      referenced_issues += target_issues
+    end
+
+    self.issues = referenced_issues.uniq
+  end
+
+  # returns a regexp that splits the long comment into parts
+  #
+  # Each part starts with a valid ticket reference and 
+  # either ends with one or ends at the end of the comment
+  def splitting_regexp(ticket_keywords)
+    ref_any = ticket_keywords.delete('*')
+    joined_kw = ticket_keywords.join("|")
+    first = "(#{joined_kw})#{ref_any ? '*' : '+' }"
+    second = joined_kw + (ref_any ? '|#' : '')
+    /#{first}[\s:]*(([\s,;&]*#?\d+)+)(.*?)(?=#{second}|\Z)/im 
+  end
+
+  # Process_part analyses the part and executes ticket changes, time logs etc.
+  def process_part(action,target_issues,rest)
+    if Setting.advanced_commit_parsing?
+      time = extract_time!(rest)
+      ratio = extract_ratio!(rest)
+    end
+
+    target_issues.each do |issue|
+      if fix_status && action && fix_keywords.include?(action.downcase)
         # update status of issues
         logger.debug "Issues fixed by changeset #{self.revision}: #{issue_ids.join(', ')}." if logger && logger.debug?
-        target_issues.each do |issue|
-          # the issue may have been updated by the closure of another one (eg. duplicate)
-          issue.reload
-          # don't change the status is the issue is closed
-          next if issue.status.is_closed?
-          user = committer_user || User.anonymous
-          csettext = "r#{self.revision}"
-          if self.scmid && (! (csettext =~ /^r[0-9]+$/))
-            csettext = "commit:\"#{self.scmid}\""
-          end
-          journal = issue.init_journal(user, l(:text_status_changed_by_changeset, csettext))
-          issue.status = fix_status
-          issue.done_ratio = done_ratio if done_ratio
-          issue.save
-          Mailer.deliver_issue_edit(journal) if Setting.notified_events.include?('issue_updated')
+        # the issue may have been updated by the closure of another one (eg. duplicate)
+        issue.reload
+        # don't change the status is the issue is closed
+        next if issue.status.is_closed?
+        issue.status = fix_status
+        issue.done_ratio = done_ratio if done_ratio
+        update_journal(issue)
+      else # elsif Setting.extended_comment_parsing
+        if ratio
+          issue.done_ratio = ratio
+          update_journal(issue)
         end
       end
-      referenced_issues += target_issues
+      if time
+        time_entry = TimeEntry.new( :hours => time,
+                                    :spent_on => Time.now,
+                                    :comments => rest[0..254],
+                                    :user => committer_user)
+        time_entry.hours /= target_issues.length
+        issue.time_entries << time_entry
+      end
+      issue.save
     end
-    
-    self.issues = referenced_issues.uniq
   end
 
+  # This updates the journal of an Issue and sends an update email if necessary
+  def update_journal(issue)
+    user = committer_user
+    csettext = "r#{self.revision}"
+    if self.scmid && (! (csettext =~ /^r[0-9]+$/))
+      csettext = "commit:\"#{self.scmid}\""
+    end
+    journal = issue.init_journal(user, l(:text_status_changed_by_changeset, csettext))
+    Mailer.deliver_issue_edit(journal) if Setting.notified_events.include?('issue_updated')
+  end
+
+  # extracts the time
+  def extract_time!(string)
+    extract!(/(?:#{time_keywords.join("|")})[\s:]+(\d+(?:[.,:hm ]|hours)*\d*[m ]*)/,string)
+  end
+
+  # extracts the ratio
+  def extract_ratio!(string)
+    extract!(/(?:#{ratio_keywords.join("|")})[\s:]+(\d+)%?/,string)
+  end
+
+  # generic extract function. Notice the !. The original string is silently manipulated
+  def extract!(regexp,string)
+    if match = string.match(/(.*?)#{regexp}(.*)/mi)
+      replacement = if match[1] && !match[1].strip.empty?
+                      match[1].strip + ' ' + match[3].strip
+                    else
+                      match[3].strip
+                    end
+      string.replace(replacement)
+      match[2]
+    end
+  end
+
+  # keywords used to reference issues
+  def ref_keywords
+    @ref_keywords ||= Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
+  end
+
+  # keywords used to fix issues
+  def fix_keywords
+    @fix_keywords ||= Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
+  end
+
+  # keywords used to set the ratio of the issues
+  def ratio_keywords
+    @ratio_keywords ||= Setting.commit_ratio_keywords.downcase.split(',').collect(&:strip)
+  end
+
+  # keywords used to log time of an issue
+  def time_keywords
+    @time_keywords ||= Setting.commit_time_keywords.downcase.split(',').collect(&:strip)
+  end
+
+  # status if an issue is fixed
+  def fix_status
+    @fix_status ||= IssueStatus.find_by_id(Setting.commit_fix_status_id)
+  end
+
+  # the ratio if an issue is fixed
+  def done_ratio
+    @done_ratio ||= Setting.commit_fix_done_ratio.blank? ? nil : Setting.commit_fix_done_ratio.to_i
+  end
+
   # Returns the Redmine User corresponding to the committer
+  # or the anonymous user
   def committer_user
     if committer && committer.strip =~ /^([^<]+)(<(.*)>)?$/
       username, email = $1.strip, $3
       u = User.find_by_login(username)
       u ||= User.find_by_mail(email) unless email.blank?
       u
-    end
+    end || User.anonymous
   end
   
-  # Returns the previous changeset
-  def previous
-    @previous ||= Changeset.find(:first, :conditions => ['id < ? AND repository_id = ?', self.id, self.repository_id], :order => 'id DESC')
-  end
-
-  # Returns the next changeset
-  def next
-    @next ||= Changeset.find(:first, :conditions => ['id > ? AND repository_id = ?', self.id, self.repository_id], :order => 'id ASC')
-  end
 end
Index: app/views/settings/_repositories.rhtml
===================================================================
--- app/views/settings/_repositories.rhtml	(revision 1616)
+++ app/views/settings/_repositories.rhtml	(working copy)
@@ -29,5 +29,19 @@
 <br /><em><%= l(:text_comma_separated) %></em></p>
 </fieldset>
 
+<fieldset class="box tabular settings"><legend><%= l(:text_issues_advanced_commit_message_keywords) %></legend>
+<p><label><%= l(:setting_advanced_commit_keywords) %></label>
+<%= check_box_tag 'settings[advanced_commit_parsing]', 1, Setting.advanced_commit_parsing?, :onclick=>"Element.toggle('advanced_keywords'); return true;" %><%= hidden_field_tag 'settings[advanced_commit_parsing]', 0 %></p>
+
+<div id="advanced_keywords" <%= Setting.advanced_commit_parsing? ? '' : 'style="display:none"' %>>
+<p><label><%= l(:setting_commit_time_keywords) %></label>
+<%= text_field_tag 'settings[commit_time_keywords]', Setting.commit_time_keywords, :size => 30 %>
+<br /><em><%= l(:text_comma_separated) %></em></p>
+<p><label><%= l(:setting_commit_ratio_keywords) %></label>
+<%= text_field_tag 'settings[commit_ratio_keywords]', Setting.commit_ratio_keywords, :size => 30 %>
+<br /><em><%= l(:text_comma_separated) %></em></p>
+</div>
+</fieldset>
+
 <%= submit_tag l(:button_save) %>
 <% end %>
Index: lang/en.yml
===================================================================
--- lang/en.yml	(revision 1616)
+++ lang/en.yml	(working copy)
@@ -202,6 +202,9 @@
 setting_sys_api_enabled: Enable WS for repository management
 setting_commit_ref_keywords: Referencing keywords
 setting_commit_fix_keywords: Fixing keywords
+setting_advanced_commit_keywords: Enable advanced keywords
+setting_commit_time_keywords: Time logging keywords
+setting_commit_ratio_keywords: Done ratio keywords
 setting_autologin: Autologin
 setting_date_format: Date format
 setting_time_format: Time format
@@ -584,6 +587,7 @@
 text_unallowed_characters: Unallowed characters
 text_comma_separated: Multiple values allowed (comma separated).
 text_issues_ref_in_commit_messages: Referencing and fixing issues in commit messages
+text_issues_advanced_commit_message_keywords: Logging time and setting issue ratios via commit messages
 text_issue_added: Issue %s has been reported by %s.
 text_issue_updated: Issue %s has been updated by %s.
 text_wiki_destroy_confirmation: Are you sure you want to delete this wiki and all its content ?
Index: config/settings.yml
===================================================================
--- config/settings.yml	(revision 1616)
+++ config/settings.yml	(working copy)
@@ -81,6 +81,12 @@
   default: 0
 commit_fix_done_ratio:
   default: 100
+advanced_commit_parsing:
+  default: 0
+commit_time_keywords:
+  default: 'time,log'
+commit_ratio_keywords:
+  default: 'done,ratio'
 # autologin duration in days
 # 0 means autologin is disabled 
 autologin:
