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