changeset.rb
286 lines
| 9.6 KiB
| text/x-ruby
|
RubyLexer
|
r2004 | # Redmine - project management software | ||
|
r9453 | # Copyright (C) 2006-2012 Jean-Philippe Lang | ||
|
r374 | # | ||
# This program is free software; you can redistribute it and/or | ||||
# modify it under the terms of the GNU General Public License | ||||
# as published by the Free Software Foundation; either version 2 | ||||
# of the License, or (at your option) any later version. | ||||
|
r5672 | # | ||
|
r374 | # This program is distributed in the hope that it will be useful, | ||
# but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||
# GNU General Public License for more details. | ||||
|
r5672 | # | ||
|
r374 | # You should have received a copy of the GNU General Public License | ||
# along with this program; if not, write to the Free Software | ||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||||
|
r1766 | require 'iconv' | ||
|
r374 | class Changeset < ActiveRecord::Base | ||
belongs_to :repository | ||||
|
r2004 | belongs_to :user | ||
|
r374 | has_many :changes, :dependent => :delete_all | ||
|
r470 | has_and_belongs_to_many :issues | ||
|
r7590 | has_and_belongs_to_many :parents, | ||
:class_name => "Changeset", | ||||
:join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}", | ||||
:association_foreign_key => 'parent_id', :foreign_key => 'changeset_id' | ||||
has_and_belongs_to_many :children, | ||||
:class_name => "Changeset", | ||||
:join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}", | ||||
:association_foreign_key => 'changeset_id', :foreign_key => 'parent_id' | ||||
|
r663 | |||
|
r9137 | acts_as_event :title => Proc.new {|o| o.title}, | ||
|
r2344 | :description => :long_comments, | ||
|
r663 | :datetime => :committed_on, | ||
|
r8531 | :url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :repository_id => o.repository.identifier_param, :rev => o.identifier}} | ||
|
r5672 | |||
|
r755 | acts_as_searchable :columns => 'comments', | ||
|
r1420 | :include => {:repository => :project}, | ||
|
r755 | :project_key => "#{Repository.table_name}.project_id", | ||
:date_column => 'committed_on' | ||||
|
r5672 | |||
|
r1692 | acts_as_activity_provider :timestamp => "#{table_name}.committed_on", | ||
|
r2064 | :author_key => :user_id, | ||
|
r2571 | :find_options => {:include => [:user, {:repository => :project}]} | ||
|
r5672 | |||
|
r377 | validates_presence_of :repository_id, :revision, :committed_on, :commit_date | ||
|
r374 | validates_uniqueness_of :revision, :scope => :repository_id | ||
|
r570 | validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true | ||
|
r5672 | |||
|
r9355 | scope :visible, | ||
lambda {|*args| { :include => {:repository => :project}, | ||||
|
r5204 | :conditions => Project.allowed_to_condition(args.shift || User.current, :view_changesets, *args) } } | ||
|
r5672 | |||
|
r6620 | after_create :scan_for_issues | ||
|
r6624 | before_create :before_create_cs | ||
|
r6620 | |||
|
r1348 | def revision=(r) | ||
write_attribute :revision, (r.nil? ? nil : r.to_s) | ||||
end | ||||
|
r4493 | |||
# Returns the identifier of this changeset; depending on repository backends | ||||
def identifier | ||||
if repository.class.respond_to? :changeset_identifier | ||||
repository.class.changeset_identifier self | ||||
else | ||||
revision.to_s | ||||
end | ||||
end | ||||
|
r651 | |||
|
r377 | def committed_on=(date) | ||
self.commit_date = date | ||||
super | ||||
end | ||||
|
r4493 | |||
# Returns the readable identifier | ||||
def format_identifier | ||||
if repository.class.respond_to? :format_changeset_identifier | ||||
repository.class.format_changeset_identifier self | ||||
else | ||||
identifier | ||||
end | ||||
end | ||||
|
r5672 | |||
|
r1213 | def project | ||
repository.project | ||||
end | ||||
|
r5252 | |||
|
r2004 | def author | ||
user || committer.to_s.split('<').first | ||||
end | ||||
|
r5252 | |||
|
r6624 | def before_create_cs | ||
|
r4842 | self.committer = self.class.to_utf8(self.committer, repository.repo_log_encoding) | ||
|
r5252 | self.comments = self.class.normalize_comments( | ||
self.comments, repository.repo_log_encoding) | ||||
|
r4842 | self.user = repository.find_committer_user(self.committer) | ||
|
r2004 | end | ||
|
r4842 | |||
|
r6620 | def scan_for_issues | ||
|
r470 | scan_comment_for_issue_ids | ||
end | ||||
|
r5252 | |||
|
r4356 | TIMELOG_RE = / | ||
( | ||||
|
r4831 | ((\d+)(h|hours?))((\d+)(m|min)?)? | ||
| | ||||
((\d+)(h|hours?|m|min)) | ||||
|
r4356 | | | ||
(\d+):(\d+) | ||||
| | ||||
|
r4831 | (\d+([\.,]\d+)?)h? | ||
|
r4356 | ) | ||
/x | ||||
|
r5672 | |||
|
r470 | def scan_comment_for_issue_ids | ||
|
r476 | return if comments.blank? | ||
|
r470 | # keywords used to reference issues | ||
|
r848 | ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip) | ||
|
r4356 | ref_keywords_any = ref_keywords.delete('*') | ||
|
r470 | # keywords used to fix issues | ||
|
r848 | fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip) | ||
|
r5672 | |||
|
r848 | kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|") | ||
|
r5672 | |||
|
r848 | referenced_issues = [] | ||
|
r5672 | |||
|
r4356 | comments.scan(/([\s\(\[,-]|^)((#{kw_regexp})[\s:]+)?(#\d+(\s+@#{TIMELOG_RE})?([\s,;&]+#\d+(\s+@#{TIMELOG_RE})?)*)(?=[[:punct:]]|\s|<|$)/i) do |match| | ||
action, refs = match[2], match[3] | ||||
next unless action.present? || ref_keywords_any | ||||
|
r5672 | |||
|
r4356 | refs.scan(/#(\d+)(\s+@#{TIMELOG_RE})?/).each do |m| | ||
issue, hours = find_referenced_issue_by_id(m[0].to_i), m[2] | ||||
if issue | ||||
referenced_issues << issue | ||||
fix_issue(issue) if fix_keywords.include?(action.to_s.downcase) | ||||
log_time(issue, hours) if hours && Setting.commit_logtime_enabled? | ||||
|
r470 | end | ||
end | ||||
end | ||||
|
r5672 | |||
|
r3359 | referenced_issues.uniq! | ||
self.issues = referenced_issues unless referenced_issues.empty? | ||||
|
r470 | end | ||
|
r5672 | |||
|
r2344 | def short_comments | ||
@short_comments || split_comments.first | ||||
end | ||||
|
r5672 | |||
|
r2344 | def long_comments | ||
@long_comments || split_comments.last | ||||
end | ||||
|
r4356 | |||
|
r8797 | def text_tag(ref_project=nil) | ||
tag = if scmid? | ||||
|
r4376 | "commit:#{scmid}" | ||
|
r4356 | else | ||
|
r4376 | "r#{revision}" | ||
|
r4356 | end | ||
|
r9135 | if repository && repository.identifier.present? | ||
tag = "#{repository.identifier}|#{tag}" | ||||
end | ||||
|
r8797 | if ref_project && project && ref_project != project | ||
tag = "#{project.identifier}:#{tag}" | ||||
end | ||||
tag | ||||
|
r4356 | end | ||
|
r5254 | |||
|
r9137 | # Returns the title used for the changeset in the activity/search results | ||
def title | ||||
repo = (repository && repository.identifier.present?) ? " (#{repository.identifier})" : '' | ||||
comm = short_comments.blank? ? '' : (': ' + short_comments) | ||||
"#{l(:label_revision)} #{format_identifier}#{repo}#{comm}" | ||||
end | ||||
|
r925 | # Returns the previous changeset | ||
def previous | ||||
|
r5254 | @previous ||= Changeset.find(:first, | ||
:conditions => ['id < ? AND repository_id = ?', | ||||
self.id, self.repository_id], | ||||
:order => 'id DESC') | ||||
|
r925 | end | ||
# Returns the next changeset | ||||
def next | ||||
|
r5254 | @next ||= Changeset.find(:first, | ||
:conditions => ['id > ? AND repository_id = ?', | ||||
self.id, self.repository_id], | ||||
:order => 'id ASC') | ||||
|
r925 | end | ||
|
r5254 | |||
|
r3246 | # Creates a new Change from it's common parameters | ||
def create_change(change) | ||||
|
r5254 | Change.create(:changeset => self, | ||
:action => change[:action], | ||||
:path => change[:path], | ||||
:from_path => change[:from_path], | ||||
|
r3246 | :from_revision => change[:from_revision]) | ||
end | ||||
|
r4842 | |||
|
r4356 | # Finds an issue that can be referenced by the commit message | ||
def find_referenced_issue_by_id(id) | ||||
return nil if id.blank? | ||||
issue = Issue.find_by_id(id.to_i, :include => :project) | ||||
|
r8630 | if Setting.commit_cross_project_ref? | ||
# all issues can be referenced/fixed | ||||
elsif issue | ||||
# issue that belong to the repository project, a subproject or a parent project only | ||||
|
r5252 | unless issue.project && | ||
(project == issue.project || project.is_ancestor_of?(issue.project) || | ||||
project.is_descendant_of?(issue.project)) | ||||
|
r4356 | issue = nil | ||
end | ||||
end | ||||
issue | ||||
end | ||||
|
r5672 | |||
|
r8657 | private | ||
|
r4356 | def fix_issue(issue) | ||
status = IssueStatus.find_by_id(Setting.commit_fix_status_id.to_i) | ||||
if status.nil? | ||||
|
r7451 | logger.warn("No status matches commit_fix_status_id setting (#{Setting.commit_fix_status_id})") if logger | ||
|
r4356 | return issue | ||
end | ||||
|
r5672 | |||
|
r4356 | # 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 | ||||
return if issue.status && issue.status.is_closed? | ||||
|
r5672 | |||
|
r8797 | journal = issue.init_journal(user || User.anonymous, ll(Setting.default_language, :text_status_changed_by_changeset, text_tag(issue.project))) | ||
|
r4356 | issue.status = status | ||
unless Setting.commit_fix_done_ratio.blank? | ||||
issue.done_ratio = Setting.commit_fix_done_ratio.to_i | ||||
end | ||||
Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update, | ||||
{ :changeset => self, :issue => issue }) | ||||
unless issue.save | ||||
logger.warn("Issue ##{issue.id} could not be saved by changeset #{id}: #{issue.errors.full_messages}") if logger | ||||
end | ||||
issue | ||||
end | ||||
|
r5252 | |||
|
r4356 | def log_time(issue, hours) | ||
time_entry = TimeEntry.new( | ||||
:user => user, | ||||
:hours => hours, | ||||
:issue => issue, | ||||
:spent_on => commit_date, | ||||
|
r8797 | :comments => l(:text_time_logged_by_changeset, :value => text_tag(issue.project), | ||
|
r5252 | :locale => Setting.default_language) | ||
|
r4356 | ) | ||
time_entry.activity = log_time_activity unless log_time_activity.nil? | ||||
|
r5672 | |||
|
r4356 | unless time_entry.save | ||
logger.warn("TimeEntry could not be created by changeset #{id}: #{time_entry.errors.full_messages}") if logger | ||||
end | ||||
time_entry | ||||
end | ||||
|
r5252 | |||
|
r4356 | def log_time_activity | ||
if Setting.commit_logtime_activity_id.to_i > 0 | ||||
TimeEntryActivity.find_by_id(Setting.commit_logtime_activity_id.to_i) | ||||
end | ||||
|
r3243 | end | ||
|
r5252 | |||
|
r2344 | def split_comments | ||
comments =~ /\A(.+?)\r?\n(.*)$/m | ||||
@short_comments = $1 || comments | ||||
@long_comments = $2.to_s.strip | ||||
return @short_comments, @long_comments | ||||
end | ||||
|
r2004 | |||
|
r4842 | public | ||
# Strips and reencodes a commit log before insertion into the database | ||||
def self.normalize_comments(str, encoding) | ||||
Changeset.to_utf8(str.to_s.strip, encoding) | ||||
end | ||||
def self.to_utf8(str, encoding) | ||||
|
r7690 | Redmine::CodesetUtil.to_utf8(str, encoding) | ||
|
r1766 | end | ||
|
r374 | end | ||