Index: app/models/changeset.rb =================================================================== --- app/models/changeset.rb (revision 2118) +++ app/models/changeset.rb (working copy) @@ -41,6 +41,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 @@ -66,72 +68,211 @@ self.user = repository.find_committer_user(committer) end - def after_create - scan_comment_for_issue_ids + # This starts the comment parsing. Executed by an after_create 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) - # 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? - csettext = "r#{self.revision}" - if self.scmid && (! (csettext =~ /^r[0-9]+$/)) - csettext = "commit:\"#{self.scmid}\"" - end - journal = issue.init_journal(user || User.anonymous, 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') - end - end + process_part(action, target_issues, rest) + referenced_issues += target_issues end self.issues = referenced_issues.uniq end - # Returns the previous changeset - def previous - @previous ||= Changeset.find(:first, :conditions => ['id < ? AND repository_id = ?', self.id, self.repository_id], :order => 'id DESC') + # 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 - # Returns the next changeset - def next - @next ||= Changeset.find(:first, :conditions => ['id > ? AND repository_id = ?', self.id, self.repository_id], :order => 'id ASC') + # Process_part analyses the part and executes ticket changes, time logs etc. + def process_part(action,target_issues,rest) + # initialize three variables (time, ratio and timelogcomment) when advanced commit parsing is active + if Setting.advanced_commit_parsing? + time = extract_time!(rest) + ratio = extract_ratio!(rest) + timelogcomment = extract_timelogcomment!(rest) + + # use changeset-id as timelog-comment if time is not nil (so timelog should be created) && no timelog-comment is given && Setting.commit_timelog_default_comment? + if !time.nil? && timelogcomment.nil? && Setting.commit_timelog_default_comment? + timelogcomment = "r#{self.revision}" + # use blank timelog-comment if time is not nil (so timelog should be created) && no timelog-comment is given && Setting.commit_timelog_default_comment isnt set + elsif !time.nil? && timelogcomment.nil? + timelogcomment = "" + end + end + + target_issues.each do |issue| + if fix_status && action && fix_keywords.include?(action.downcase) + # create an issue-journal if the issue is closed due to fix_keywords + journal = init_journal(issue) + # add debug messages if issue is fixed + logger.debug "Issues fixed by changeset #{self.revision}: #{issue_ids.join(', ')}." if logger && logger.debug? + # the issue may have been updated by the closure of another one (eg. duplicate) + issue.reload + # don't change the status if the issue is closed + break if issue.status.is_closed? + # update the issue-status and issue-done_ratio due to fix_keywords + issue.status = fix_status + issue.done_ratio = done_ratio if done_ratio + elsif action && !fix_keywords.include?(action.downcase) && ratio + # create an issue-journal if the issue is not updated due to fix_keywords && the issue is updated due to done_ratio_keywords (which requires Setting.advanced_commit_parsing active) + journal = init_journal_r(issue) + # the issue may have been updated by the closure of another one (eg. duplicate) + issue.reload + # don't change the done-ratio if the issue is closed + break if issue.status.is_closed? + # update the issue-done_ratio due to ratio_keywords + issue.done_ratio = ratio + elsif Setting.commit_ref_keywords == '*' && ratio + # create an issue-journal if ref_keywords equals '*' && the issue is updated due to done_ratio_keywords (which requires Setting.advanced_commit_parsing active) + journal = init_journal_r(issue) + # the issue may have been updated by the closure of another one (eg. duplicate) + issue.reload + # don't change the done-ratio if the issue is closed + break if issue.status.is_closed? + # update the issue-done_ratio due to ratio_keywords + issue.done_ratio = ratio + end + + if time && issue.time_entries.find(:first, :conditions => ['spent_on = ? AND comments = ? AND user_id = ?',committed_on.to_date,timelogcomment[0..254],user_id]).nil? + time_entry = TimeEntry.new( :hours => time, + :spent_on => committed_on.to_date, + :activity_id => activity_id, + :comments => timelogcomment[0..254], + :user => user || User.anonymous) + 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 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(user || User.anonymous, l(:text_status_changed_by_changeset, csettext)) + end + + # init the journal for our issue when only the issue-done_ratio is changed + def init_journal_r(issue) + csettext = "r#{self.revision}" + if self.scmid && (! (csettext =~ /^r[0-9]+$/)) + csettext = "commit:\"#{self.scmid}\"" + end + issue.init_journal(user || User.anonymous, l(:text_ratio_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 + + # extracts the timelogcomment + def extract_timelogcomment!(string) + extract!(/(?:#{timelogcomment_keywords.join("|")})[\s:]+(.*)?/,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 + + # keywords used to set the comment of the timelog + def timelogcomment_keywords + @timelogcomment_keywords ||= Setting.commit_timelogcomment_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 activity id for the timelog created via a commit-message from the global-settings + def activity_id + @activity_id ||= Setting.commit_timelog_activity_id + 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 2118) +++ 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 - # Returns an array of committers usernames and associated user_id def committers @committers ||= Changeset.connection.select_rows("SELECT DISTINCT committer, user_id FROM #{Changeset.table_name} WHERE repository_id = #{id}") @@ -142,11 +138,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 2118) +++ app/views/settings/_repositories.rhtml (working copy) @@ -32,5 +32,26 @@
<%= 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_timelogcomment_keywords]', Setting.commit_timelogcomment_keywords, :size => 30 %> +
<%= l(:text_comma_separated) %>

+

+<%= select_tag 'settings[commit_timelog_activity_id]', options_for_select(Enumeration::get_values('ACTI').collect{|enumeration| [enumeration.name, enumeration.id.to_s]}, Setting.commit_timelog_activity_id) %>

+

+<%= check_box_tag 'settings[commit_timelog_default_comment]', 1, Setting.commit_timelog_default_comment? %><%= hidden_field_tag 'settings[commit_timelog_default_comment]', 0 %>

+

+<%= 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 2118) +++ config/settings.yml (working copy) @@ -86,6 +86,19 @@ 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' +commit_timelogcomment_keywords: + default: 'timelogcomment' +commit_timelog_activity_id: + format: int + default: 0 +commit_timelog_default_comment: + default: 0 # autologin duration in days # 0 means autologin is disabled autologin: Index: lang/en.yml =================================================================== --- lang/en.yml (revision 2118) +++ lang/en.yml (working copy) @@ -698,3 +698,12 @@ 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 +setting_commit_timelogcomment_keywords: Time logging comment keywords +text_issues_advanced_commit_message_keywords: Logging time and setting issue ratios via commit messages +setting_commit_timelog_activity_id: Default Activity-type for timelogs created via commit messages +text_ratio_changed_by_changeset: Partially applied in changeset %s. +setting_commit_timelog_default_comment: Use changeset-identifier if timelog comment is missing (else comment is blank)