Index: app/models/changeset.rb =================================================================== --- app/models/changeset.rb (revision 1926) +++ app/models/changeset.rb (working copy) @@ -40,6 +40,8 @@ validates_uniqueness_of :revision, :scope => :repository_id validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true + after_create :parse_comment + def revision=(r) write_attribute :revision, (r.nil? ? nil : r.to_s) end @@ -57,83 +59,174 @@ 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| + journal = init_journal(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 + break if issue.status.is_closed? + issue.status = fix_status + issue.done_ratio = done_ratio if done_ratio + else + if ratio and !issue.status.is_closed? + issue.done_ratio = ratio end end - referenced_issues += target_issues + if time && issue.time_entries.find(:first, :conditions => ['spent_on = ? AND comments = ? AND user_id = ?',committed_on.to_date,rest[0..254],committer_user.id]).nil? + time_entry = TimeEntry.new( :hours => time, + :spent_on => committed_on.to_date, + :activity_id => activity_id, + :comments => rest[0..254], + :user => committer_user) + time_entry.hours /= target_issues.length + issue.time_entries << time_entry + end + if issue.changed? + issue.save + Mailer.deliver_issue_edit(journal) if Setting.notified_events.include?('issue_updated') + end end - - self.issues = referenced_issues.uniq end + # init the journal for our issue + def init_journal(issue) + csettext = "r#{self.revision}" + if self.scmid && (! (csettext =~ /^r[0-9]+$/)) + csettext = "commit:\"#{self.scmid}\"" + end + issue.init_journal(committer_user, l(:text_status_changed_by_changeset, csettext)) + end + + # extracts the time + def extract_time!(string) + extract!(/(?:#{time_keywords.join("|")})[\s:]+(\d+[.,:hm ]*\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 + + # gets the default activity id or the id of the first + def activity_id + @activity_id ||= (Enumeration.default('ACTI') || Enumeration::get_values('ACTI').first).id + end + # Returns the Redmine User corresponding to the committer + # or the anonymous user def committer_user - if committer && committer.strip =~ /^([^<]+)(<(.*)>)?$/ + @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 - # Strips and reencodes a commit log before insertion into the database def self.normalize_comments(str) to_utf8(str.to_s.strip) Index: app/models/repository.rb =================================================================== --- app/models/repository.rb (revision 1926) +++ app/models/repository.rb (working copy) @@ -92,10 +92,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" @@ -103,11 +99,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/views/settings/_repositories.rhtml =================================================================== --- app/views/settings/_repositories.rhtml (revision 1926) +++ app/views/settings/_repositories.rhtml (working copy) @@ -32,5 +32,19 @@
<%= l(:text_comma_separated) %>

+
<%= l(:text_issues_advanced_commit_message_keywords) %> +

+<%= 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 %>

+ +
> +

+<%= text_field_tag 'settings[commit_time_keywords]', Setting.commit_time_keywords, :size => 30 %> +
<%= l(:text_comma_separated) %>

+

+<%= text_field_tag 'settings[commit_ratio_keywords]', Setting.commit_ratio_keywords, :size => 30 %> +
<%= l(:text_comma_separated) %>

+
+
+ <%= submit_tag l(:button_save) %> <% end %> Index: config/settings.yml =================================================================== --- config/settings.yml (revision 1926) +++ 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: Index: lang/en.yml =================================================================== --- lang/en.yml (revision 1926) +++ lang/en.yml (working copy) @@ -641,3 +641,8 @@ enumeration_issue_priorities: Issue priorities enumeration_doc_categories: Document categories enumeration_activities: Activities (time tracking) + +setting_advanced_commit_keywords: Enable advanced keywords +setting_commit_time_keywords: Time logging keywords +setting_commit_ratio_keywords: Done ratio keywords +text_issues_advanced_commit_message_keywords: Logging time and setting issue ratios via commit messages Index: test/unit/changeset_test.rb =================================================================== --- test/unit/changeset_test.rb (revision 1926) +++ test/unit/changeset_test.rb (working copy) @@ -32,25 +32,234 @@ 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 + Setting.advanced_commit_parsing = 1 + Setting.commit_ref_keywords = '*' + + comment = <<-EOL +#1 +time 3,5 +EOL + + time = Time.now + c = Changeset.new(:repository => Project.find(1).repository, + :committed_on => time, + :comments => comment) + + c.parse_comment + + time_entry = TimeEntry.last + assert_equal 3.5, time_entry.hours + end + + def test_log_time_should_not_create_duplicate_logs + count = TimeEntry.count + Setting.advanced_commit_parsing = 1 + Setting.commit_ref_keywords = '*' + + committed_on = Time.now + + comment = <<-EOL +#1 +time 3,5 +EOL + + c = Changeset.new(:repository => Project.find(1).repository, + :committed_on => committed_on, + :comments => comment) + + c.parse_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 +279,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 1926) +++ test/unit/repository_test.rb (working copy) @@ -65,60 +65,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 ', :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 ')