changeset.rb
294 lines
| 10.0 KiB
| text/x-ruby
|
RubyLexer
|
r2004 | # Redmine - project management software | ||
|
r13490 | # Copyright (C) 2006-2015 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. | ||||
class Changeset < ActiveRecord::Base | ||||
belongs_to :repository | ||||
|
r2004 | belongs_to :user | ||
|
r9576 | has_many :filechanges, :class_name => 'Change', :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', | ||
|
r13357 | :preload => {:repository => :project}, | ||
|
r755 | :project_key => "#{Repository.table_name}.project_id", | ||
|
r13357 | :date_column => :committed_on | ||
|
r5672 | |||
|
r1692 | acts_as_activity_provider :timestamp => "#{table_name}.committed_on", | ||
|
r2064 | :author_key => :user_id, | ||
|
r13100 | :scope => preload(: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 | ||
|
r13100 | attr_protected :id | ||
|
r5672 | |||
|
r10723 | scope :visible, lambda {|*args| | ||
|
r13100 | joins(:repository => :project). | ||
where(Project.allowed_to_condition(args.shift || User.current, :view_changesets, *args)) | ||||
|
r10723 | } | ||
|
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 | ||
|
r13754 | self.committer = self.class.to_utf8(self.committer, repository.repo_log_encoding) | ||
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 | ||
|
r11978 | fix_keywords = Setting.commit_update_keywords_array.map {|r| r['keywords']}.flatten.compact | ||
|
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| | ||
|
r11967 | action, refs = match[2].to_s.downcase, match[3] | ||
|
r4356 | 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] | ||||
|
r13063 | if issue && !issue_linked_to_same_commit?(issue) | ||
|
r4356 | referenced_issues << issue | ||
|
r11969 | # Don't update issues or log time when importing old commits | ||
unless repository.created_on && committed_on && committed_on < repository.created_on | ||||
fix_issue(issue, action) if fix_keywords.include?(action) | ||||
log_time(issue, hours) if hours && Setting.commit_logtime_enabled? | ||||
end | ||||
|
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) | ||
|
r12385 | repo = "" | ||
if repository && repository.identifier.present? | ||||
repo = "#{repository.identifier}|" | ||||
end | ||||
|
r8797 | tag = if scmid? | ||
|
r12385 | "commit:#{repo}#{scmid}" | ||
|
r4356 | else | ||
|
r12385 | "#{repo}r#{revision}" | ||
|
r9135 | end | ||
|
r8797 | if ref_project && project && ref_project != project | ||
|
r9839 | tag = "#{project.identifier}:#{tag}" | ||
|
r8797 | 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 | ||||
|
r9753 | @previous ||= Changeset.where(["id < ? AND repository_id = ?", id, repository_id]).order('id DESC').first | ||
|
r925 | end | ||
# Returns the next changeset | ||||
def next | ||||
|
r9753 | @next ||= Changeset.where(["id > ? AND repository_id = ?", id, repository_id]).order('id ASC').first | ||
|
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? | ||||
|
r12198 | issue = Issue.includes(:project).where(:id => id.to_i).first | ||
|
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 | ||
|
r13063 | # Returns true if the issue is already linked to the same commit | ||
# from a different repository | ||||
def issue_linked_to_same_commit?(issue) | ||||
repository.same_commits_in_scope(issue.changesets, self).any? | ||||
end | ||||
|
r11967 | # Updates the +issue+ according to +action+ | ||
def fix_issue(issue, action) | ||||
|
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 | ||||
|
r13126 | return if issue.closed? | ||
|
r5672 | |||
|
r12001 | journal = issue.init_journal(user || User.anonymous, | ||
ll(Setting.default_language, | ||||
:text_status_changed_by_changeset, | ||||
text_tag(issue.project))) | ||||
|
r11978 | rule = Setting.commit_update_keywords_array.detect do |rule| | ||
|
r12001 | rule['keywords'].include?(action) && | ||
(rule['if_tracker_id'].blank? || rule['if_tracker_id'] == issue.tracker_id.to_s) | ||||
|
r11978 | end | ||
if rule | ||||
issue.assign_attributes rule.slice(*Issue.attribute_names) | ||||
end | ||||
|
r4356 | Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update, | ||
|
r11968 | { :changeset => self, :issue => issue, :action => action }) | ||
|
r13782 | |||
if issue.changes.any? | ||||
unless issue.save | ||||
logger.warn("Issue ##{issue.id} could not be saved by changeset #{id}: #{issue.errors.full_messages}") if logger | ||||
end | ||||
|
r4356 | 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 | ||