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