File: app/models/changeset.rb

Overview
Module Structure
Class Hierarchy
Code

Overview

Module Structure

  module: <Toplevel Module>
  class: Changeset#20
inherits from
  Base ( ActiveRecord )
has properties
method: revision= / 1 #58
method: identifier #63
method: committed_on= / 1 #71
method: format_identifier #77
method: project #85
method: author #89
method: before_create_cs #93
method: scan_for_issues #100
constant: TIMELOG_RE #104
method: scan_comment_for_issue_ids #116
method: short_comments #146
method: long_comments #150
method: text_tag / 1 #154
method: title #170
method: previous #177
method: next #185
method: create_change / 1 #193
method: find_referenced_issue_by_id / 1 #202
method: fix_issue / 1 #220
method: log_time / 2 #245
method: log_time_activity #262
method: split_comments #268
class method: normalize_comments / 2 #278
class method: to_utf8 / 2 #282

Class Hierarchy

Object ( Builtin-Module )
Base ( ActiveRecord )
  Changeset    #20

Code

   1  # Redmine - project management software
   2  # Copyright (C) 2006-2011  Jean-Philippe Lang
   3  #
   4  # This program is free software; you can redistribute it and/or
   5  # modify it under the terms of the GNU General Public License
   6  # as published by the Free Software Foundation; either version 2
   7  # of the License, or (at your option) any later version.
   8  #
   9  # This program is distributed in the hope that it will be useful,
  10  # but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  # GNU General Public License for more details.
  13  #
  14  # You should have received a copy of the GNU General Public License
  15  # along with this program; if not, write to the Free Software
  16  # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  17 
  18  require 'iconv'
  19 
  20  class Changeset < ActiveRecord::Base
  21    belongs_to :repository
  22    belongs_to :user
  23    has_many :changes, :dependent => :delete_all
  24    has_and_belongs_to_many :issues
  25    has_and_belongs_to_many :parents,
  26                            :class_name => "Changeset",
  27                            :join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}",
  28                            :association_foreign_key => 'parent_id', :foreign_key => 'changeset_id'
  29    has_and_belongs_to_many :children,
  30                            :class_name => "Changeset",
  31                            :join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}",
  32                            :association_foreign_key => 'changeset_id', :foreign_key => 'parent_id'
  33 
  34    acts_as_event :title => Proc.new {|o| o.title},
  35                  :description => :long_comments,
  36                  :datetime => :committed_on,
  37                  :url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :repository_id => o.repository.identifier_param, :rev => o.identifier}}
  38 
  39    acts_as_searchable :columns => 'comments',
  40                       :include => {:repository => :project},
  41                       :project_key => "#{Repository.table_name}.project_id",
  42                       :date_column => 'committed_on'
  43 
  44    acts_as_activity_provider :timestamp => "#{table_name}.committed_on",
  45                              :author_key => :user_id,
  46                              :find_options => {:include => [:user, {:repository => :project}]}
  47 
  48    validates_presence_of :repository_id, :revision, :committed_on, :commit_date
  49    validates_uniqueness_of :revision, :scope => :repository_id
  50    validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
  51 
  52    named_scope :visible, lambda {|*args| { :include => {:repository => :project},
  53                                            :conditions => Project.allowed_to_condition(args.shift || User.current, :view_changesets, *args) } }
  54 
  55    after_create :scan_for_issues
  56    before_create :before_create_cs
  57 
  58    def revision=(r)
  59      write_attribute :revision, (r.nil? ? nil : r.to_s)
  60    end
  61 
  62    # Returns the identifier of this changeset; depending on repository backends
  63    def identifier
  64      if repository.class.respond_to? :changeset_identifier
  65        repository.class.changeset_identifier self
  66      else
  67        revision.to_s
  68      end
  69    end
  70 
  71    def committed_on=(date)
  72      self.commit_date = date
  73      super
  74    end
  75 
  76    # Returns the readable identifier
  77    def format_identifier
  78      if repository.class.respond_to? :format_changeset_identifier
  79        repository.class.format_changeset_identifier self
  80      else
  81        identifier
  82      end
  83    end
  84 
  85    def project
  86      repository.project
  87    end
  88 
  89    def author
  90      user || committer.to_s.split('<').first
  91    end
  92 
  93    def before_create_cs
  94      self.committer = self.class.to_utf8(self.committer, repository.repo_log_encoding)
  95      self.comments  = self.class.normalize_comments(
  96                         self.comments, repository.repo_log_encoding)
  97      self.user = repository.find_committer_user(self.committer)
  98    end
  99 
 100    def scan_for_issues
 101      scan_comment_for_issue_ids
 102    end
 103 
 104    TIMELOG_RE = /
 105      (
 106      ((\d+)(h|hours?))((\d+)(m|min)?)?
 107      |
 108      ((\d+)(h|hours?|m|min))
 109      |
 110      (\d+):(\d+)
 111      |
 112      (\d+([\.,]\d+)?)h?
 113      )
 114      /x
 115 
 116    def scan_comment_for_issue_ids
 117      return if comments.blank?
 118      # keywords used to reference issues
 119      ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
 120      ref_keywords_any = ref_keywords.delete('*')
 121      # keywords used to fix issues
 122      fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
 123 
 124      kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|")
 125 
 126      referenced_issues = []
 127 
 128      comments.scan(/([\s\(\[,-]|^)((#{kw_regexp})[\s:]+)?(#\d+(\s+@#{TIMELOG_RE})?([\s,;&]+#\d+(\s+@#{TIMELOG_RE})?)*)(?=[[:punct:]]|\s|<|$)/i) do |match|
 129        action, refs = match[2], match[3]
 130        next unless action.present? || ref_keywords_any
 131 
 132        refs.scan(/#(\d+)(\s+@#{TIMELOG_RE})?/).each do |m|
 133          issue, hours = find_referenced_issue_by_id(m[0].to_i), m[2]
 134          if issue
 135            referenced_issues << issue
 136            fix_issue(issue) if fix_keywords.include?(action.to_s.downcase)
 137            log_time(issue, hours) if hours && Setting.commit_logtime_enabled?
 138          end
 139        end
 140      end
 141 
 142      referenced_issues.uniq!
 143      self.issues = referenced_issues unless referenced_issues.empty?
 144    end
 145 
 146    def short_comments
 147      @short_comments || split_comments.first
 148    end
 149 
 150    def long_comments
 151      @long_comments || split_comments.last
 152    end
 153 
 154    def text_tag(ref_project=nil)
 155      tag = if scmid?
 156        "commit:#{scmid}"
 157      else
 158        "r#{revision}"
 159      end
 160      if repository && repository.identifier.present?
 161        tag = "#{repository.identifier}|#{tag}"
 162      end
 163      if ref_project && project && ref_project != project
 164        tag = "#{project.identifier}:#{tag}" 
 165      end
 166      tag
 167    end
 168 
 169    # Returns the title used for the changeset in the activity/search results
 170    def title
 171      repo = (repository && repository.identifier.present?) ? " (#{repository.identifier})" : ''
 172      comm = short_comments.blank? ? '' : (': ' + short_comments)
 173      "#{l(:label_revision)} #{format_identifier}#{repo}#{comm}"
 174    end
 175 
 176    # Returns the previous changeset
 177    def previous
 178      @previous ||= Changeset.find(:first,
 179                      :conditions => ['id < ? AND repository_id = ?',
 180                                      self.id, self.repository_id],
 181                      :order => 'id DESC')
 182    end
 183 
 184    # Returns the next changeset
 185    def next
 186      @next ||= Changeset.find(:first,
 187                      :conditions => ['id > ? AND repository_id = ?',
 188                                      self.id, self.repository_id],
 189                      :order => 'id ASC')
 190    end
 191 
 192    # Creates a new Change from it's common parameters
 193    def create_change(change)
 194      Change.create(:changeset     => self,
 195                    :action        => change[:action],
 196                    :path          => change[:path],
 197                    :from_path     => change[:from_path],
 198                    :from_revision => change[:from_revision])
 199    end
 200 
 201    # Finds an issue that can be referenced by the commit message
 202    def find_referenced_issue_by_id(id)
 203      return nil if id.blank?
 204      issue = Issue.find_by_id(id.to_i, :include => :project)
 205      if Setting.commit_cross_project_ref?
 206        # all issues can be referenced/fixed
 207      elsif issue
 208        # issue that belong to the repository project, a subproject or a parent project only
 209        unless issue.project &&
 210                  (project == issue.project || project.is_ancestor_of?(issue.project) ||
 211                   project.is_descendant_of?(issue.project))
 212          issue = nil
 213        end
 214      end
 215      issue
 216    end
 217 
 218    private
 219 
 220    def fix_issue(issue)
 221      status = IssueStatus.find_by_id(Setting.commit_fix_status_id.to_i)
 222      if status.nil?
 223        logger.warn("No status matches commit_fix_status_id setting (#{Setting.commit_fix_status_id})") if logger
 224        return issue
 225      end
 226 
 227      # the issue may have been updated by the closure of another one (eg. duplicate)
 228      issue.reload
 229      # don't change the status is the issue is closed
 230      return if issue.status && issue.status.is_closed?
 231 
 232      journal = issue.init_journal(user || User.anonymous, ll(Setting.default_language, :text_status_changed_by_changeset, text_tag(issue.project)))
 233      issue.status = status
 234      unless Setting.commit_fix_done_ratio.blank?
 235        issue.done_ratio = Setting.commit_fix_done_ratio.to_i
 236      end
 237      Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update,
 238                              { :changeset => self, :issue => issue })
 239      unless issue.save
 240        logger.warn("Issue ##{issue.id} could not be saved by changeset #{id}: #{issue.errors.full_messages}") if logger
 241      end
 242      issue
 243    end
 244 
 245    def log_time(issue, hours)
 246      time_entry = TimeEntry.new(
 247        :user => user,
 248        :hours => hours,
 249        :issue => issue,
 250        :spent_on => commit_date,
 251        :comments => l(:text_time_logged_by_changeset, :value => text_tag(issue.project),
 252                       :locale => Setting.default_language)
 253        )
 254      time_entry.activity = log_time_activity unless log_time_activity.nil?
 255 
 256      unless time_entry.save
 257        logger.warn("TimeEntry could not be created by changeset #{id}: #{time_entry.errors.full_messages}") if logger
 258      end
 259      time_entry
 260    end
 261 
 262    def log_time_activity
 263      if Setting.commit_logtime_activity_id.to_i > 0
 264        TimeEntryActivity.find_by_id(Setting.commit_logtime_activity_id.to_i)
 265      end
 266    end
 267 
 268    def split_comments
 269      comments =~ /\A(.+?)\r?\n(.*)$/m
 270      @short_comments = $1 || comments
 271      @long_comments = $2.to_s.strip
 272      return @short_comments, @long_comments
 273    end
 274 
 275    public
 276 
 277    # Strips and reencodes a commit log before insertion into the database
 278    def self.normalize_comments(str, encoding)
 279      Changeset.to_utf8(str.to_s.strip, encoding)
 280    end
 281 
 282    def self.to_utf8(str, encoding)
 283      Redmine::CodesetUtil.to_utf8(str, encoding)
 284    end
 285  end