Grant merge request creators the ability to change the status
[gitorious:yousource.git] / app / models / merge_request.rb
1 # encoding: utf-8
2 #--
3 #   Copyright (C) 2009 Nokia Corporation and/or its subsidiary(-ies)
4 #   Copyright (C) 2008 Johan Sørensen <johan@johansorensen.com>
5 #   Copyright (C) 2008 David A. Cuadrado <krawek@gmail.com>
6 #   Copyright (C) 2008 Tor Arne Vestbø <tavestbo@trolltech.com>
7 #   Copyright (C) 2009 Fabio Akita <fabio.akita@gmail.com>
8 #
9 #   This program is free software: you can redistribute it and/or modify
10 #   it under the terms of the GNU Affero General Public License as published by
11 #   the Free Software Foundation, either version 3 of the License, or
12 #   (at your option) any later version.
13 #
14 #   This program is distributed in the hope that it will be useful,
15 #   but WITHOUT ANY WARRANTY; without even the implied warranty of
16 #   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 #   GNU Affero General Public License for more details.
18 #
19 #   You should have received a copy of the GNU Affero General Public License
20 #   along with this program.  If not, see <http://www.gnu.org/licenses/>.
21 #++
22
23 class MergeRequest < ActiveRecord::Base
24   include ActiveMessaging::MessageSender
25   belongs_to :user
26   belongs_to :source_repository, :class_name => 'Repository'
27   belongs_to :target_repository, :class_name => 'Repository'
28   has_many   :events, :as => :target, :dependent => :destroy
29   has_many :messages, :as => :notifiable
30   has_many :comments, :as => :target, :dependent => :destroy
31   has_many :versions, :class_name => 'MergeRequestVersion', :order => 'version', :dependent => :destroy
32   
33   before_destroy :nullify_messages
34   after_destroy  :delete_tracking_branches
35   
36   is_indexed :fields => ["proposal", {:field => "status_tag", :as => "status"}],
37     :include => [{
38       :association_name => "user",
39       :field => "login",
40       :as => "proposed_by"
41     }], :conditions => "status != 0"
42   
43   attr_protected :user_id, :status, :merge_requests_need_signoff, :oauth_path_prefix,
44                   :oauth_signoff_key, :oauth_signoff_secret, :oauth_signoff_site
45     
46   validates_presence_of :user, :source_repository, :target_repository, :summary
47   
48   validates_presence_of :ending_commit, :on => :create
49   
50   STATUS_PENDING_ACCEPTANCE_OF_TERMS = 0
51   STATUS_OPEN = 1
52   STATUS_CLOSED = 5 # further states must start at 5+n (for backwards compat)
53 #   STATUS_MERGED = 2
54 #   STATUS_REJECTED = 3
55 #   STATUS_VERIFYING = 4
56
57   state_machine :status, :initial => :pending do
58     state :pending, :value => ::MergeRequest::STATUS_PENDING_ACCEPTANCE_OF_TERMS
59     state :open, :value => ::MergeRequest::STATUS_OPEN
60     state :closed, :value => ::MergeRequest::STATUS_CLOSED
61     
62     event :open do
63       transition :pending => :open
64     end
65
66     event :close do
67       transition :open => :closed
68     end
69
70     event :reopen do
71       transition :closed => :open
72     end
73   end
74   
75   named_scope :public, :conditions => ["status != ?", STATUS_PENDING_ACCEPTANCE_OF_TERMS]
76   named_scope :open, :conditions => ['status = ?', STATUS_OPEN]
77   named_scope :closed, :conditions => ["status = ?", STATUS_CLOSED]
78   named_scope :by_status, lambda {|state|
79     {:conditions => ["LOWER(status_tag) = ? AND status != ?",
80                      state.downcase, STATUS_PENDING_ACCEPTANCE_OF_TERMS ] }
81   }
82   
83   def reopen_with_user(a_user)
84     if can_be_reopened_by?(a_user)
85       return reopen
86     end
87   end
88   
89   def can_be_reopened_by?(a_user)
90     return can_reopen? && resolvable_by?(a_user)
91   end
92   
93   def self.human_name
94     I18n.t("activerecord.models.merge_request")
95   end
96   
97   def self.count_open
98     count(:all, :conditions => {:status => STATUS_OPEN})
99   end
100   
101   def self.statuses
102     @statuses ||= state_machines[:status].states.inject({}){ |result, state |
103       result[state.name.to_s.capitalize] = state.value
104       result
105     }
106   end
107   
108   def self.from_filter(filter_name = nil)
109     if filter_name.blank?
110       open
111     else
112       by_status(filter_name)
113     end
114   end
115   
116   def status_string
117     self.class.status_string(status)
118   end
119   
120   def self.status_string(status_code)
121     statuses.invert[status_code.to_i].to_s.downcase
122   end
123   
124   def pending_acceptance_of_terms?
125     pending?
126   end
127
128   def open_or_in_verification?
129     open? || verifying?
130   end
131
132   def possible_next_states
133     status == STATUS_OPEN ? [STATUS_CLOSED] : [STATUS_OPEN]
134   end
135   
136   def updated_by=(user)
137     self.updated_by_user_id = user.id
138   end
139   
140   def updated_by
141     if updated_by_user_id.blank?
142       user
143     else
144       User.find(updated_by_user_id)
145     end
146   end
147
148   def with_user(a_user)
149     @current_user = a_user
150     yield
151     @current_user = nil
152   end
153   
154   def status_tag=(tag)
155     unless tag.is_a?(StatusTag)
156       tag = StatusTag.new(tag, target_repository.project)
157     end
158     if tag.open?
159       # TODO: should use the statemachine events instead?
160       self.status = STATUS_OPEN
161     elsif tag.closed?
162       self.status = STATUS_CLOSED
163     else
164       self.status = STATUS_OPEN # FIXME: fallback
165     end
166     
167     @previous_state = status_tag.name if status_tag
168     write_attribute(:status_tag, tag.name)
169     save
170   end
171
172   def status_tag
173     if target_repository && (status_tag_name = super)
174       StatusTag.new(status_tag_name, self.target_repository.project)
175     end
176   end
177
178   def create_status_change_event(comment)
179     if @current_user
180       message = "State changed "
181       message << "from <span class=\"changed\">#{@previous_state}</span> " if @previous_state
182       message << "to <span class=\"changed\">#{status_tag}</span>"
183       target_repository.project.create_event(Action::UPDATE_MERGE_REQUEST, self,
184         @current_user, message, comment)
185     end
186   end
187   
188   # Returns a hash (for the view) of labels and event names for next states
189   # TODO: Obviously, putting the states and transitions inside a map is not all that DRY,
190   # but the state machine does not have a one-to-one relationship between states and events
191   def possible_next_states_hash
192     map = {
193         STATUS_OPEN => ['Open', 'open'],
194         STATUS_VERIFYING => ['Verifying', 'in_verification'],
195         STATUS_REJECTED => ['Rejected', 'reject'],
196         STATUS_MERGED => ['Merged', 'merge']
197         }
198     result = {}
199     possible_next_states.each do |s|
200       label, value = map[s]
201       result[label] = value
202     end
203     return result
204   end
205
206   def can_transition_to?(new_state)
207     send("can_#{new_state}?")
208   end
209   
210   
211   def transition_to(status)
212     if can_transition_to?(status)
213       send(status)
214       yield 
215       return true
216     end
217   end
218   
219   def source_branch
220     super || "master"
221   end
222   
223   def target_branch
224     super || "master"
225   end
226   
227   def deliver_status_update(a_user)
228     message = Message.new({
229       :sender => a_user,
230       :recipient => user,
231       :subject => "Your merge request was updated",
232       :body => "The merge request is now #{status_tag}.",
233       :notifiable => self,
234     })
235     message.save
236   end
237   
238   def source_name
239     if source_repository
240       "#{source_repository.name}:#{source_branch}"
241     end
242   end
243   
244   def target_name
245     if target_repository
246       "#{target_repository.name}:#{target_branch}"
247     end
248   end
249   
250   def resolvable_by?(candidate)
251     return false unless candidate.is_a?(User)
252     (candidate === user) || candidate.can_write_to?(target_repository)
253   end
254   
255   def commits_for_selection
256     return [] if !target_repository
257     @commits_for_selection ||= target_repository.git.commit_deltas_from(source_repository.git, target_branch, source_branch)
258   end
259   
260   def applies_to_specific_commits?
261     !ending_commit.blank?
262   end
263   
264   def commits_to_be_merged
265     if ready?
266       commit_diff_from_tracking_repo
267     else
268       commits_to_be_merged_when_no_version
269     end
270   end
271
272   def commits_to_be_merged_when_no_version
273     idx = commits_for_selection.index(commits_for_selection.find{|c| c.id == ending_commit})
274     return idx ? commits_for_selection[idx..-1] : []
275   end
276   
277   def ready?
278     !versions.blank?
279   end
280   
281   # Returns the name for the merge request branch. version can be:
282   # - the number of a version,
283   # - :current for the latest version
284   # - nil for no version
285   def merge_branch_name(version=false)
286     result = ["refs","merge-requests",id]
287     case version
288     when :current
289       result << versions.last.version
290     when Fixnum
291       result << version
292     end
293     result.join("/")
294   end
295   
296   def commit_diff_from_tracking_repo(which_version=nil)
297     RAILS_DEFAULT_LOGGER.debug "Merge request looking for version #{which_version} in #{versions.collect(&:version)}"
298     version = if which_version
299       version_number(which_version)
300     else
301       versions.last
302     end
303     version.affected_commits
304   end
305   
306   def potential_commits
307     if applies_to_specific_commits?
308       idx = commits_for_selection.index(commits_for_selection.find{|c| c.id == ending_commit})
309       return idx ? commits_for_selection[idx..-1] : []
310     else
311       return commits_for_selection
312     end
313   end
314   
315   def target_branches_for_selection
316     return [] unless target_repository
317     target_repository.git.branches || []
318   end
319   
320   def breadcrumb_parent
321     Breadcrumb::MergeRequests.new(target_repository)
322   end
323   
324   def breadcrumb_css_class
325     "merge_request"
326   end
327   
328   def title
329     id
330   end
331   
332   def acceptance_of_terms_required?
333     target_repository.requires_signoff_on_merge_requests?
334   end
335
336   # Publishes a notification, causing a new tracking branch (and version) to be created in the background
337   def publish_notification
338     publish :mirror_merge_request, {:merge_request_id => to_param}.to_json
339   end
340   
341   def default_status
342     target_repository.project.merge_request_statuses.default
343   end
344   
345   def confirmed_by_user
346     if default_status
347       self.status = default_status.state
348       self.status_tag = default_status.name
349     else
350       self.status = STATUS_OPEN
351       self.status_tag = "Open"
352     end
353     save
354     publish_notification
355     notify_subscribers_about_creation
356   end
357
358   def notify_subscribers_about_creation
359     return unless target_repository.notify_committers_on_new_merge_request?
360     target_repository.committers.uniq.reject{|c|c == user }.each do |committer|
361       message = messages.build({
362         :sender => user, 
363         :recipient => committer,
364         :subject => I18n.t("mailer.request_notification",
365           :login => user.login,
366           :title => target_repository.project.title),
367         :body => proposal,
368         :notifiable => self
369         })
370       message.save
371     end
372   end
373   
374   def oauth_request_token=(token)
375     self.oauth_token = token.token
376     self.oauth_secret = token.secret
377   end
378   
379   def terms_accepted
380     validate_through_oauth do
381       confirmed_by_user
382       callback_response = access_token.post(target_repository.project.oauth_path_prefix, oauth_signoff_parameters)
383       
384       if Net::HTTPAccepted === callback_response
385         self.contribution_notice = callback_response.body
386       end
387       
388       contribution_agreement_version = callback_response['X-Contribution-Agreement-Version']
389       update_attributes(:contribution_agreement_version => contribution_agreement_version)
390     end
391   end
392   
393   # If the contribution agreement site wants to remind the user of the current contribution license, 
394   # they respond with a Net::HTTPAccepted header along with a response body containing the notice
395   def contribution_notice=(notice)
396     @contribution_notice = notice
397   end
398   
399   def has_contribution_notice?
400     !contribution_notice.blank?
401   end
402   
403   def contribution_notice
404     @contribution_notice
405   end
406   
407   # Returns the parameters that are passed on to the contribution agreement site
408   def oauth_signoff_parameters
409     {
410       'commit_id' => ending_commit, 
411       'user_email' => user.email, 
412       'user_login'  => user.login,
413       'user_name' => URI.escape(user.title), 
414       'commit_shas' => commits_to_be_merged.collect(&:id).join(","), 
415       'proposal' => URI.escape(proposal), 
416       'project_name' => source_repository.project.slug,
417       'repository_name' => source_repository.name, 
418       'merge_request_id' => id
419     }
420   end
421   
422   def validate_through_oauth
423     yield if valid_oauth_credentials?
424   end
425   
426   
427   def access_token
428     @access_token ||= oauth_consumer.build_access_token(oauth_token, oauth_secret)
429   end
430   
431   def oauth_consumer
432     target_repository.project.oauth_consumer
433   end
434   
435   def ending_commit_exists?
436     !source_repository.git.commit(ending_commit).nil?
437   end
438   
439   def to_xml(opts = {})
440     info_proc = Proc.new do |options|
441       builder = options[:builder]
442       builder.status(status_tag.to_s.blank? ? status_string : status_tag.to_s)
443       builder.username(user.to_param_with_prefix)
444       builder.source_repository do |source|
445         source.name(source_repository.name)
446         source.branch(source_branch)
447       end
448       builder.target_repository do |source|
449         source.name(target_repository.name)
450         source.branch(target_branch)
451       end
452     end
453     
454     super({
455       :procs => [info_proc],
456       :only => [:summary, :proposal, :created_at, :updated_at, :id, :ending_commit],
457       :methods => []
458     }.merge(opts))
459   end
460   
461   def update_from_push!
462     push_new_branch_to_tracking_repo
463     save
464   end
465   
466   def valid_oauth_credentials?
467     response = access_token.get("/")
468     return Net::HTTPSuccess === response
469   end
470   
471   def nullify_messages
472     messages.update_all({:notifiable_id => nil, :notifiable_type => nil})
473   end
474   
475   def recently_created?
476     !ready? && created_at > 2.minutes.ago
477   end
478   
479   def push_to_tracking_repository!(force = false)
480     options = {:timeout => false}
481     options[:force] = true if force
482     branch_spec = "#{ending_commit}:#{merge_branch_name}"
483     source_repository.git.git.push(options,
484       target_repository.full_repository_path, branch_spec)
485     push_new_branch_to_tracking_repo
486   end
487
488   def push_new_branch_to_tracking_repo
489     branch_spec = [merge_branch_name, merge_branch_name(next_version_number)].join(":")
490     raise "No tracking repository exists for merge request #{id}" unless tracking_repository
491     target_repository.git.git.push({:timeout => false},
492       tracking_repository.full_repository_path, branch_spec)
493     create_new_version
494     target_repository.project.create_event(Action::UPDATE_MERGE_REQUEST, self,
495       user, "new version #{current_version_number}")
496   end
497
498   # Since we'll be deleting the ref in the backend, this will be
499   # handled in the message queue
500   def delete_tracking_branches
501     msg = {
502       :merge_request_id => to_param,
503       :action => "delete",
504       :target_path => target_repository.full_repository_path,
505       :target_name => target_repository.url_path,
506       :merge_branch_name => merge_branch_name,
507       :source_repository_id => source_repository.id,
508       :target_repository_id => target_repository.id,
509     }
510     publish :merge_request_backend_updates, msg.to_json
511   end
512   
513   def tracking_repository
514     unless target_repository.has_tracking_repository?
515       target_repository.create_tracking_repository
516     end
517     target_repository.tracking_repository
518   end
519   
520   # Returns the version with version number +n+
521   def version_number(n)
522     versions.inject({}){|result,v|result[v.version]=v;result}[n]
523   end
524
525   def current_version_number
526     versions.blank? ? nil : versions.last.version
527   end
528   
529   # Verify that +a_commit+ exists in target branch. Git cherry would
530   # return a list of commits if this is not the case
531   def commit_merged?(a_commit)
532     # FIXME: could fetch them all in one target_branch..this_branch operation
533     key = "merge_status_for_commit_#{a_commit}_in_repository_#{target_repository.id}"
534     result = Rails.cache.fetch(key, :expires_in => 60.minutes) do
535       output = target_repository.git.git.cherry({},target_branch, a_commit)
536       output.blank? ? :true : :false # Storing false in the cache would make it miss each time
537     end
538     result == :true
539   end
540   
541   def create_new_version
542     result = build_new_version
543     result.merge_base_sha = calculate_merge_base
544     result.save
545     return result
546   end
547   
548   def calculate_merge_base
549     target_repository.git.git.merge_base({:timeout => false},
550       target_branch, merge_branch_name).strip
551   end
552   
553   def build_new_version
554     versions.build(:version => next_version_number)
555   end
556   
557   def next_version_number
558     highest_version = versions.last
559     highest_version_number = highest_version ? highest_version.version : 0
560     highest_version_number + 1
561   end
562   
563   # Migrate repositories from the old regime with reasons:
564   # If a reason exists: create a comment from the user who last updated us, provide the state to have it look right
565   # If no reason exists: simply set the status tag directly from whatever status_string is
566   def migrate_to_status_tag
567     if reason.blank?
568       self.status_tag = status_string.capitalize
569       save
570     else
571       comment = comments.create!({
572           :user => updated_by,
573           :body => reason,
574           :project => target_repository.project
575         })
576       comment.state = status_string
577       comment.save!
578     end
579   end
580   
581 end