Commit 551946a34e60195c44f293febaa8a0e77f0a23c7

Authored by Dmitriy Zaporozhets
2 parents 2b36dee6 c8a115c0

Merge pull request #4507 from smashwilson/linking-issues

Linking objects from GFM references
  1 +v 6.1.0
  2 + - Link issues, merge requests, and commits when they reference each other with GFM
  3 + - Close issues automatically when pushing commits with a special message
  4 +
1 v 6.0.0 5 v 6.0.0
2 - Feature: Replace teams with group membership 6 - Feature: Replace teams with group membership
3 We introduce group membership in 6.0 as a replacement for teams. 7 We introduce group membership in 6.0 as a replacement for teams.
@@ -54,7 +58,7 @@ v 5.4.0 @@ -54,7 +58,7 @@ v 5.4.0
54 - Notify mentioned users with email 58 - Notify mentioned users with email
55 59
56 v 5.3.0 60 v 5.3.0
57 - - Refactored services 61 + - Refactored services
58 - Campfire service added 62 - Campfire service added
59 - HipChat service added 63 - HipChat service added
60 - Fixed bug with LDAP + git over http 64 - Fixed bug with LDAP + git over http
app/controllers/projects/merge_requests_controller.rb
@@ -3,6 +3,7 @@ require 'gitlab/satellite/satellite' @@ -3,6 +3,7 @@ require 'gitlab/satellite/satellite'
3 class Projects::MergeRequestsController < Projects::ApplicationController 3 class Projects::MergeRequestsController < Projects::ApplicationController
4 before_filter :module_enabled 4 before_filter :module_enabled
5 before_filter :merge_request, only: [:edit, :update, :show, :commits, :diffs, :automerge, :automerge_check, :ci_status] 5 before_filter :merge_request, only: [:edit, :update, :show, :commits, :diffs, :automerge, :automerge_check, :ci_status]
  6 + before_filter :closes_issues, only: [:edit, :update, :show, :commits, :diffs]
6 before_filter :validates_merge_request, only: [:show, :diffs] 7 before_filter :validates_merge_request, only: [:show, :diffs]
7 before_filter :define_show_vars, only: [:show, :diffs] 8 before_filter :define_show_vars, only: [:show, :diffs]
8 9
@@ -135,6 +136,10 @@ class Projects::MergeRequestsController &lt; Projects::ApplicationController @@ -135,6 +136,10 @@ class Projects::MergeRequestsController &lt; Projects::ApplicationController
135 @merge_request ||= @project.merge_requests.find_by_iid!(params[:id]) 136 @merge_request ||= @project.merge_requests.find_by_iid!(params[:id])
136 end 137 end
137 138
  139 + def closes_issues
  140 + @closes_issues ||= @merge_request.closes_issues
  141 + end
  142 +
138 def authorize_modify_merge_request! 143 def authorize_modify_merge_request!
139 return render_404 unless can?(current_user, :modify_merge_request, @merge_request) 144 return render_404 unless can?(current_user, :modify_merge_request, @merge_request)
140 end 145 end
app/models/commit.rb
@@ -2,6 +2,9 @@ class Commit @@ -2,6 +2,9 @@ class Commit
2 include ActiveModel::Conversion 2 include ActiveModel::Conversion
3 include StaticModel 3 include StaticModel
4 extend ActiveModel::Naming 4 extend ActiveModel::Naming
  5 + include Mentionable
  6 +
  7 + attr_mentionable :safe_message
5 8
6 # Safe amount of files with diffs in one commit to render 9 # Safe amount of files with diffs in one commit to render
7 # Used to prevent 500 error on huge commits by suppressing diff 10 # Used to prevent 500 error on huge commits by suppressing diff
@@ -65,6 +68,29 @@ class Commit @@ -65,6 +68,29 @@ class Commit
65 end 68 end
66 end 69 end
67 70
  71 + # Regular expression that identifies commit message clauses that trigger issue closing.
  72 + def issue_closing_regex
  73 + @issue_closing_regex ||= Regexp.new(Gitlab.config.gitlab.issue_closing_pattern)
  74 + end
  75 +
  76 + # Discover issues should be closed when this commit is pushed to a project's
  77 + # default branch.
  78 + def closes_issues project
  79 + md = issue_closing_regex.match(safe_message)
  80 + if md
  81 + extractor = Gitlab::ReferenceExtractor.new
  82 + extractor.analyze(md[0])
  83 + extractor.issues_for(project)
  84 + else
  85 + []
  86 + end
  87 + end
  88 +
  89 + # Mentionable override.
  90 + def gfm_reference
  91 + "commit #{sha[0..5]}"
  92 + end
  93 +
68 def method_missing(m, *args, &block) 94 def method_missing(m, *args, &block)
69 @raw.send(m, *args, &block) 95 @raw.send(m, *args, &block)
70 end 96 end
app/models/concerns/mentionable.rb
1 # == Mentionable concern 1 # == Mentionable concern
2 # 2 #
3 -# Contains common functionality shared between Issues and Notes 3 +# Contains functionality related to objects that can mention Users, Issues, MergeRequests, or Commits by
  4 +# GFM references.
4 # 5 #
5 -# Used by Issue, Note 6 +# Used by Issue, Note, MergeRequest, and Commit.
6 # 7 #
7 module Mentionable 8 module Mentionable
8 extend ActiveSupport::Concern 9 extend ActiveSupport::Concern
9 10
  11 + module ClassMethods
  12 + # Indicate which attributes of the Mentionable to search for GFM references.
  13 + def attr_mentionable *attrs
  14 + mentionable_attrs.concat(attrs.map(&:to_s))
  15 + end
  16 +
  17 + # Accessor for attributes marked mentionable.
  18 + def mentionable_attrs
  19 + @mentionable_attrs ||= []
  20 + end
  21 + end
  22 +
  23 + # Generate a GFM back-reference that will construct a link back to this Mentionable when rendered. Must
  24 + # be overridden if this model object can be referenced directly by GFM notation.
  25 + def gfm_reference
  26 + raise NotImplementedError.new("#{self.class} does not implement #gfm_reference")
  27 + end
  28 +
  29 + # Construct a String that contains possible GFM references.
  30 + def mentionable_text
  31 + self.class.mentionable_attrs.map { |attr| send(attr) || '' }.join
  32 + end
  33 +
  34 + # The GFM reference to this Mentionable, which shouldn't be included in its #references.
  35 + def local_reference
  36 + self
  37 + end
  38 +
  39 + # Determine whether or not a cross-reference Note has already been created between this Mentionable and
  40 + # the specified target.
  41 + def has_mentioned? target
  42 + Note.cross_reference_exists?(target, local_reference)
  43 + end
  44 +
10 def mentioned_users 45 def mentioned_users
11 users = [] 46 users = []
12 return users if mentionable_text.blank? 47 return users if mentionable_text.blank?
@@ -24,14 +59,39 @@ module Mentionable @@ -24,14 +59,39 @@ module Mentionable
24 users.uniq 59 users.uniq
25 end 60 end
26 61
27 - def mentionable_text  
28 - if self.class == Issue  
29 - description  
30 - elsif self.class == Note  
31 - note  
32 - else  
33 - nil 62 + # Extract GFM references to other Mentionables from this Mentionable. Always excludes its #local_reference.
  63 + def references p = project, text = mentionable_text
  64 + return [] if text.blank?
  65 + ext = Gitlab::ReferenceExtractor.new
  66 + ext.analyze(text)
  67 + (ext.issues_for(p) + ext.merge_requests_for(p) + ext.commits_for(p)).uniq - [local_reference]
  68 + end
  69 +
  70 + # Create a cross-reference Note for each GFM reference to another Mentionable found in +mentionable_text+.
  71 + def create_cross_references! p = project, a = author, without = []
  72 + refs = references(p) - without
  73 + refs.each do |ref|
  74 + Note.create_cross_reference_note(ref, local_reference, a, p)
34 end 75 end
35 end 76 end
36 77
  78 + # If the mentionable_text field is about to change, locate any *added* references and create cross references for
  79 + # them. Invoke from an observer's #before_save implementation.
  80 + def notice_added_references p = project, a = author
  81 + ch = changed_attributes
  82 + original, mentionable_changed = "", false
  83 + self.class.mentionable_attrs.each do |attr|
  84 + if ch[attr]
  85 + original << ch[attr]
  86 + mentionable_changed = true
  87 + end
  88 + end
  89 +
  90 + # Only proceed if the saved changes actually include a chance to an attr_mentionable field.
  91 + return unless mentionable_changed
  92 +
  93 + preexisting = references(p, original)
  94 + create_cross_references!(p, a, preexisting)
  95 + end
  96 +
37 end 97 end
app/models/issue.rb
@@ -32,6 +32,7 @@ class Issue &lt; ActiveRecord::Base @@ -32,6 +32,7 @@ class Issue &lt; ActiveRecord::Base
32 attr_accessible :title, :assignee_id, :position, :description, 32 attr_accessible :title, :assignee_id, :position, :description,
33 :milestone_id, :label_list, :author_id_of_changes, 33 :milestone_id, :label_list, :author_id_of_changes,
34 :state_event 34 :state_event
  35 + attr_mentionable :title, :description
35 36
36 acts_as_taggable_on :labels 37 acts_as_taggable_on :labels
37 38
@@ -56,4 +57,10 @@ class Issue &lt; ActiveRecord::Base @@ -56,4 +57,10 @@ class Issue &lt; ActiveRecord::Base
56 57
57 # Both open and reopened issues should be listed as opened 58 # Both open and reopened issues should be listed as opened
58 scope :opened, -> { with_state(:opened, :reopened) } 59 scope :opened, -> { with_state(:opened, :reopened) }
  60 +
  61 + # Mentionable overrides.
  62 +
  63 + def gfm_reference
  64 + "issue ##{iid}"
  65 + end
59 end 66 end
app/models/merge_request.rb
@@ -31,7 +31,7 @@ class MergeRequest &lt; ActiveRecord::Base @@ -31,7 +31,7 @@ class MergeRequest &lt; ActiveRecord::Base
31 belongs_to :source_project, foreign_key: :source_project_id, class_name: "Project" 31 belongs_to :source_project, foreign_key: :source_project_id, class_name: "Project"
32 32
33 attr_accessible :title, :assignee_id, :source_project_id, :source_branch, :target_project_id, :target_branch, :milestone_id, :author_id_of_changes, :state_event 33 attr_accessible :title, :assignee_id, :source_project_id, :source_branch, :target_project_id, :target_branch, :milestone_id, :author_id_of_changes, :state_event
34 - 34 + attr_mentionable :title
35 35
36 attr_accessor :should_remove_source_branch 36 attr_accessor :should_remove_source_branch
37 37
@@ -255,6 +255,20 @@ class MergeRequest &lt; ActiveRecord::Base @@ -255,6 +255,20 @@ class MergeRequest &lt; ActiveRecord::Base
255 target_project 255 target_project
256 end 256 end
257 257
  258 + # Return the set of issues that will be closed if this merge request is accepted.
  259 + def closes_issues
  260 + if target_branch == project.default_branch
  261 + unmerged_commits.map { |c| c.closes_issues(project) }.flatten.uniq.sort_by(&:id)
  262 + else
  263 + []
  264 + end
  265 + end
  266 +
  267 + # Mentionable override.
  268 + def gfm_reference
  269 + "merge request !#{iid}"
  270 + end
  271 +
258 private 272 private
259 273
260 def dump_commits(commits) 274 def dump_commits(commits)
app/models/note.rb
@@ -24,6 +24,7 @@ class Note &lt; ActiveRecord::Base @@ -24,6 +24,7 @@ class Note &lt; ActiveRecord::Base
24 24
25 attr_accessible :note, :noteable, :noteable_id, :noteable_type, :project_id, 25 attr_accessible :note, :noteable, :noteable_id, :noteable_type, :project_id,
26 :attachment, :line_code, :commit_id 26 :attachment, :line_code, :commit_id
  27 + attr_mentionable :note
27 28
28 belongs_to :project 29 belongs_to :project
29 belongs_to :noteable, polymorphic: true 30 belongs_to :noteable, polymorphic: true
@@ -54,15 +55,36 @@ class Note &lt; ActiveRecord::Base @@ -54,15 +55,36 @@ class Note &lt; ActiveRecord::Base
54 serialize :st_diff 55 serialize :st_diff
55 before_create :set_diff, if: ->(n) { n.line_code.present? } 56 before_create :set_diff, if: ->(n) { n.line_code.present? }
56 57
57 - def self.create_status_change_note(noteable, project, author, status) 58 + def self.create_status_change_note(noteable, project, author, status, source)
  59 + body = "_Status changed to #{status}#{' by ' + source.gfm_reference if source}_"
  60 +
  61 + create({
  62 + noteable: noteable,
  63 + project: project,
  64 + author: author,
  65 + note: body,
  66 + system: true
  67 + }, without_protection: true)
  68 + end
  69 +
  70 + # +noteable+ was referenced from +mentioner+, by including GFM in either +mentioner+'s description or an associated Note.
  71 + # Create a system Note associated with +noteable+ with a GFM back-reference to +mentioner+.
  72 + def self.create_cross_reference_note(noteable, mentioner, author, project)
58 create({ 73 create({
59 noteable: noteable, 74 noteable: noteable,
  75 + commit_id: (noteable.sha if noteable.respond_to? :sha),
60 project: project, 76 project: project,
61 author: author, 77 author: author,
62 - note: "_Status changed to #{status}_" 78 + note: "_mentioned in #{mentioner.gfm_reference}_",
  79 + system: true
63 }, without_protection: true) 80 }, without_protection: true)
64 end 81 end
65 82
  83 + # Determine whether or not a cross-reference note already exists.
  84 + def self.cross_reference_exists?(noteable, mentioner)
  85 + where(noteable_id: noteable.id, system: true, note: "_mentioned in #{mentioner.gfm_reference}_").any?
  86 + end
  87 +
66 def commit_author 88 def commit_author
67 @commit_author ||= 89 @commit_author ||=
68 project.users.find_by_email(noteable.author_email) || 90 project.users.find_by_email(noteable.author_email) ||
@@ -191,6 +213,16 @@ class Note &lt; ActiveRecord::Base @@ -191,6 +213,16 @@ class Note &lt; ActiveRecord::Base
191 for_issue? || (for_merge_request? && !for_diff_line?) 213 for_issue? || (for_merge_request? && !for_diff_line?)
192 end 214 end
193 215
  216 + # Mentionable override.
  217 + def gfm_reference
  218 + noteable.gfm_reference
  219 + end
  220 +
  221 + # Mentionable override.
  222 + def local_reference
  223 + noteable
  224 + end
  225 +
194 def noteable_type_name 226 def noteable_type_name
195 if noteable_type.present? 227 if noteable_type.present?
196 noteable_type.downcase 228 noteable_type.downcase
app/models/repository.rb
@@ -18,6 +18,7 @@ class Repository @@ -18,6 +18,7 @@ class Repository
18 end 18 end
19 19
20 def commit(id = nil) 20 def commit(id = nil)
  21 + return nil unless raw_repository
21 commit = Gitlab::Git::Commit.find(raw_repository, id) 22 commit = Gitlab::Git::Commit.find(raw_repository, id)
22 commit = Commit.new(commit) if commit 23 commit = Commit.new(commit) if commit
23 commit 24 commit
app/observers/activity_observer.rb
@@ -5,8 +5,8 @@ class ActivityObserver &lt; BaseObserver @@ -5,8 +5,8 @@ class ActivityObserver &lt; BaseObserver
5 event_author_id = record.author_id 5 event_author_id = record.author_id
6 6
7 if record.kind_of?(Note) 7 if record.kind_of?(Note)
8 - # Skip system status notes like 'status changed to close'  
9 - return true if record.note.include?("_Status changed to ") 8 + # Skip system notes, like status changes and cross-references.
  9 + return true if record.system?
10 10
11 # Skip wall notes to prevent spamming of dashboard 11 # Skip wall notes to prevent spamming of dashboard
12 return true if record.noteable_type.blank? 12 return true if record.noteable_type.blank?
app/observers/base_observer.rb
@@ -10,4 +10,8 @@ class BaseObserver &lt; ActiveRecord::Observer @@ -10,4 +10,8 @@ class BaseObserver &lt; ActiveRecord::Observer
10 def current_user 10 def current_user
11 Thread.current[:current_user] 11 Thread.current[:current_user]
12 end 12 end
  13 +
  14 + def current_commit
  15 + Thread.current[:current_commit]
  16 + end
13 end 17 end
app/observers/issue_observer.rb
1 class IssueObserver < BaseObserver 1 class IssueObserver < BaseObserver
2 def after_create(issue) 2 def after_create(issue)
3 notification.new_issue(issue, current_user) 3 notification.new_issue(issue, current_user)
  4 +
  5 + issue.create_cross_references!(issue.project, current_user)
4 end 6 end
5 7
6 def after_close(issue, transition) 8 def after_close(issue, transition)
@@ -17,12 +19,14 @@ class IssueObserver &lt; BaseObserver @@ -17,12 +19,14 @@ class IssueObserver &lt; BaseObserver
17 if issue.is_being_reassigned? 19 if issue.is_being_reassigned?
18 notification.reassigned_issue(issue, current_user) 20 notification.reassigned_issue(issue, current_user)
19 end 21 end
  22 +
  23 + issue.notice_added_references(issue.project, current_user)
20 end 24 end
21 25
22 protected 26 protected
23 27
24 # Create issue note with service comment like 'Status changed to closed' 28 # Create issue note with service comment like 'Status changed to closed'
25 def create_note(issue) 29 def create_note(issue)
26 - Note.create_status_change_note(issue, issue.project, current_user, issue.state) 30 + Note.create_status_change_note(issue, issue.project, current_user, issue.state, current_commit)
27 end 31 end
28 end 32 end
app/observers/merge_request_observer.rb
@@ -7,11 +7,13 @@ class MergeRequestObserver &lt; ActivityObserver @@ -7,11 +7,13 @@ class MergeRequestObserver &lt; ActivityObserver
7 end 7 end
8 8
9 notification.new_merge_request(merge_request, current_user) 9 notification.new_merge_request(merge_request, current_user)
  10 +
  11 + merge_request.create_cross_references!(merge_request.project, current_user)
10 end 12 end
11 13
12 def after_close(merge_request, transition) 14 def after_close(merge_request, transition)
13 create_event(merge_request, Event::CLOSED) 15 create_event(merge_request, Event::CLOSED)
14 - Note.create_status_change_note(merge_request, merge_request.target_project, current_user, merge_request.state) 16 + Note.create_status_change_note(merge_request, merge_request.target_project, current_user, merge_request.state, nil)
15 17
16 notification.close_mr(merge_request, current_user) 18 notification.close_mr(merge_request, current_user)
17 end 19 end
@@ -33,11 +35,13 @@ class MergeRequestObserver &lt; ActivityObserver @@ -33,11 +35,13 @@ class MergeRequestObserver &lt; ActivityObserver
33 35
34 def after_reopen(merge_request, transition) 36 def after_reopen(merge_request, transition)
35 create_event(merge_request, Event::REOPENED) 37 create_event(merge_request, Event::REOPENED)
36 - Note.create_status_change_note(merge_request, merge_request.target_project, current_user, merge_request.state) 38 + Note.create_status_change_note(merge_request, merge_request.target_project, current_user, merge_request.state, nil)
37 end 39 end
38 40
39 def after_update(merge_request) 41 def after_update(merge_request)
40 notification.reassigned_merge_request(merge_request, current_user) if merge_request.is_being_reassigned? 42 notification.reassigned_merge_request(merge_request, current_user) if merge_request.is_being_reassigned?
  43 +
  44 + merge_request.notice_added_references(merge_request.project, current_user)
41 end 45 end
42 46
43 def create_event(record, status) 47 def create_event(record, status)
app/observers/note_observer.rb
1 class NoteObserver < BaseObserver 1 class NoteObserver < BaseObserver
2 def after_create(note) 2 def after_create(note)
3 notification.new_note(note) 3 notification.new_note(note)
  4 +
  5 + unless note.system?
  6 + # Create a cross-reference note if this Note contains GFM that names an
  7 + # issue, merge request, or commit.
  8 + note.references.each do |mentioned|
  9 + Note.create_cross_reference_note(mentioned, note.noteable, note.author, note.project)
  10 + end
  11 + end
  12 + end
  13 +
  14 + def after_update(note)
  15 + note.notice_added_references(note.project, current_user)
4 end 16 end
5 end 17 end
app/services/git_push_service.rb
1 class GitPushService 1 class GitPushService
2 - attr_accessor :project, :user, :push_data 2 + attr_accessor :project, :user, :push_data, :push_commits
3 3
4 # This method will be called after each git update 4 # This method will be called after each git update
5 # and only if the provided user and project is present in GitLab. 5 # and only if the provided user and project is present in GitLab.
6 # 6 #
7 # All callbacks for post receive action should be placed here. 7 # All callbacks for post receive action should be placed here.
8 # 8 #
9 - # Now this method do next:  
10 - # 1. Ensure project satellite exists  
11 - # 2. Update merge requests  
12 - # 3. Execute project web hooks  
13 - # 4. Execute project services  
14 - # 5. Create Push Event 9 + # Next, this method:
  10 + # 1. Creates the push event
  11 + # 2. Ensures that the project satellite exists
  12 + # 3. Updates merge requests
  13 + # 4. Recognizes cross-references from commit messages
  14 + # 5. Executes the project's web hooks
  15 + # 6. Executes the project's services
15 # 16 #
16 def execute(project, user, oldrev, newrev, ref) 17 def execute(project, user, oldrev, newrev, ref)
17 @project, @user = project, user 18 @project, @user = project, user
18 19
19 # Collect data for this git push 20 # Collect data for this git push
  21 + @push_commits = project.repository.commits_between(oldrev, newrev)
20 @push_data = post_receive_data(oldrev, newrev, ref) 22 @push_data = post_receive_data(oldrev, newrev, ref)
21 23
22 create_push_event 24 create_push_event
@@ -25,11 +27,27 @@ class GitPushService @@ -25,11 +27,27 @@ class GitPushService
25 project.discover_default_branch 27 project.discover_default_branch
26 project.repository.expire_cache 28 project.repository.expire_cache
27 29
28 - if push_to_branch?(ref, oldrev) 30 + if push_to_existing_branch?(ref, oldrev)
29 project.update_merge_requests(oldrev, newrev, ref, @user) 31 project.update_merge_requests(oldrev, newrev, ref, @user)
  32 + process_commit_messages(ref)
30 project.execute_hooks(@push_data.dup) 33 project.execute_hooks(@push_data.dup)
31 project.execute_services(@push_data.dup) 34 project.execute_services(@push_data.dup)
32 end 35 end
  36 +
  37 + if push_to_new_branch?(ref, oldrev)
  38 + # Re-find the pushed commits.
  39 + if is_default_branch?(ref)
  40 + # Initial push to the default branch. Take the full history of that branch as "newly pushed".
  41 + @push_commits = project.repository.commits(newrev)
  42 + else
  43 + # Use the pushed commits that aren't reachable by the default branch
  44 + # as a heuristic. This may include more commits than are actually pushed, but
  45 + # that shouldn't matter because we check for existing cross-references later.
  46 + @push_commits = project.repository.commits_between(project.default_branch, newrev)
  47 + end
  48 +
  49 + process_commit_messages(ref)
  50 + end
33 end 51 end
34 52
35 # This method provide a sample data 53 # This method provide a sample data
@@ -45,7 +63,7 @@ class GitPushService @@ -45,7 +63,7 @@ class GitPushService
45 protected 63 protected
46 64
47 def create_push_event 65 def create_push_event
48 - Event.create( 66 + Event.create!(
49 project: project, 67 project: project,
50 action: Event::PUSHED, 68 action: Event::PUSHED,
51 data: push_data, 69 data: push_data,
@@ -53,6 +71,36 @@ class GitPushService @@ -53,6 +71,36 @@ class GitPushService
53 ) 71 )
54 end 72 end
55 73
  74 + # Extract any GFM references from the pushed commit messages. If the configured issue-closing regex is matched,
  75 + # close the referenced Issue. Create cross-reference Notes corresponding to any other referenced Mentionables.
  76 + def process_commit_messages ref
  77 + is_default_branch = is_default_branch?(ref)
  78 +
  79 + @push_commits.each do |commit|
  80 + # Close issues if these commits were pushed to the project's default branch and the commit message matches the
  81 + # closing regex. Exclude any mentioned Issues from cross-referencing even if the commits are being pushed to
  82 + # a different branch.
  83 + issues_to_close = commit.closes_issues(project)
  84 + author = commit_user(commit)
  85 +
  86 + if !issues_to_close.empty? && is_default_branch
  87 + Thread.current[:current_user] = author
  88 + Thread.current[:current_commit] = commit
  89 +
  90 + issues_to_close.each { |i| i.close && i.save }
  91 + end
  92 +
  93 + # Create cross-reference notes for any other references. Omit any issues that were referenced in an
  94 + # issue-closing phrase, or have already been mentioned from this commit (probably from this commit
  95 + # being pushed to a different branch).
  96 + refs = commit.references(project) - issues_to_close
  97 + refs.reject! { |r| commit.has_mentioned?(r) }
  98 + refs.each do |r|
  99 + Note.create_cross_reference_note(r, commit, author, project)
  100 + end
  101 + end
  102 + end
  103 +
56 # Produce a hash of post-receive data 104 # Produce a hash of post-receive data
57 # 105 #
58 # data = { 106 # data = {
@@ -72,8 +120,6 @@ class GitPushService @@ -72,8 +120,6 @@ class GitPushService
72 # } 120 # }
73 # 121 #
74 def post_receive_data(oldrev, newrev, ref) 122 def post_receive_data(oldrev, newrev, ref)
75 - push_commits = project.repository.commits_between(oldrev, newrev)  
76 -  
77 # Total commits count 123 # Total commits count
78 push_commits_count = push_commits.size 124 push_commits_count = push_commits.size
79 125
@@ -116,10 +162,26 @@ class GitPushService @@ -116,10 +162,26 @@ class GitPushService
116 data 162 data
117 end 163 end
118 164
119 - def push_to_branch? ref, oldrev 165 + def push_to_existing_branch? ref, oldrev
120 ref_parts = ref.split('/') 166 ref_parts = ref.split('/')
121 167
122 # Return if this is not a push to a branch (e.g. new commits) 168 # Return if this is not a push to a branch (e.g. new commits)
123 - !(ref_parts[1] !~ /heads/ || oldrev == "00000000000000000000000000000000") 169 + ref_parts[1] =~ /heads/ && oldrev != "0000000000000000000000000000000000000000"
  170 + end
  171 +
  172 + def push_to_new_branch? ref, oldrev
  173 + ref_parts = ref.split('/')
  174 +
  175 + ref_parts[1] =~ /heads/ && oldrev == "0000000000000000000000000000000000000000"
  176 + end
  177 +
  178 + def is_default_branch? ref
  179 + ref == "refs/heads/#{project.default_branch}"
  180 + end
  181 +
  182 + def commit_user commit
  183 + User.where(email: commit.author_email).first ||
  184 + User.where(name: commit.author_name).first ||
  185 + user
124 end 186 end
125 end 187 end
app/views/projects/merge_requests/show/_mr_box.html.haml
@@ -33,4 +33,10 @@ @@ -33,4 +33,10 @@
33 %i.icon-ok 33 %i.icon-ok
34 Merged by #{link_to_member(@project, @merge_request.merge_event.author)} 34 Merged by #{link_to_member(@project, @merge_request.merge_event.author)}
35 #{time_ago_in_words(@merge_request.merge_event.created_at)} ago. 35 #{time_ago_in_words(@merge_request.merge_event.created_at)} ago.
36 - 36 + - if !@closes_issues.empty? && @merge_request.opened?
  37 + .ui-box-bottom.alert-info
  38 + %span
  39 + %i.icon-ok
  40 + Accepting this merge request will close #{@closes_issues.size == 1 ? 'issue' : 'issues'}
  41 + = succeed '.' do
  42 + != gfm(@closes_issues.map { |i| "##{i.iid}" }.to_sentence)
config/gitlab.yml.example
@@ -46,6 +46,11 @@ production: &amp;base @@ -46,6 +46,11 @@ production: &amp;base
46 ## Users management 46 ## Users management
47 # signup_enabled: true # default: false - Account passwords are not sent via the email if signup is enabled. 47 # signup_enabled: true # default: false - Account passwords are not sent via the email if signup is enabled.
48 48
  49 + ## Automatic issue closing
  50 + # If a commit message matches this regular express, all issues referenced from the matched text will be closed
  51 + # if it's pushed to a project's default branch.
  52 + # issue_closing_pattern: "^([Cc]loses|[Ff]ixes) +#\d+"
  53 +
49 ## Default project features settings 54 ## Default project features settings
50 default_projects_features: 55 default_projects_features:
51 issues: true 56 issues: true
config/initializers/1_settings.rb
@@ -68,6 +68,7 @@ rescue ArgumentError # no user configured @@ -68,6 +68,7 @@ rescue ArgumentError # no user configured
68 end 68 end
69 Settings.gitlab['signup_enabled'] ||= false 69 Settings.gitlab['signup_enabled'] ||= false
70 Settings.gitlab['username_changing_enabled'] = true if Settings.gitlab['username_changing_enabled'].nil? 70 Settings.gitlab['username_changing_enabled'] = true if Settings.gitlab['username_changing_enabled'].nil?
  71 +Settings.gitlab['issue_closing_pattern'] = '^([Cc]loses|[Ff]ixes) #(\d+)' if Settings.gitlab['issue_closing_pattern'].nil?
71 Settings.gitlab['default_projects_features'] ||= {} 72 Settings.gitlab['default_projects_features'] ||= {}
72 Settings.gitlab.default_projects_features['issues'] = true if Settings.gitlab.default_projects_features['issues'].nil? 73 Settings.gitlab.default_projects_features['issues'] = true if Settings.gitlab.default_projects_features['issues'].nil?
73 Settings.gitlab.default_projects_features['merge_requests'] = true if Settings.gitlab.default_projects_features['merge_requests'].nil? 74 Settings.gitlab.default_projects_features['merge_requests'] = true if Settings.gitlab.default_projects_features['merge_requests'].nil?
db/migrate/20130528184641_add_system_to_notes.rb 0 → 100644
@@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
  1 +class AddSystemToNotes < ActiveRecord::Migration
  2 + class Note < ActiveRecord::Base
  3 + end
  4 +
  5 + def up
  6 + add_column :notes, :system, :boolean, default: false, null: false
  7 +
  8 + Note.reset_column_information
  9 + Note.update_all(system: false)
  10 + Note.where("note like '_status changed to%'").update_all(system: true)
  11 + end
  12 +
  13 + def down
  14 + remove_column :notes, :system
  15 + end
  16 +end
@@ -152,6 +152,7 @@ ActiveRecord::Schema.define(:version =&gt; 20130821090531) do @@ -152,6 +152,7 @@ ActiveRecord::Schema.define(:version =&gt; 20130821090531) do
152 t.string "commit_id" 152 t.string "commit_id"
153 t.integer "noteable_id" 153 t.integer "noteable_id"
154 t.text "st_diff" 154 t.text "st_diff"
  155 + t.boolean "system", :default => false, :null => false
155 end 156 end
156 157
157 add_index "notes", ["author_id"], :name => "index_notes_on_author_id" 158 add_index "notes", ["author_id"], :name => "index_notes_on_author_id"
lib/gitlab/reference_extractor.rb 0 → 100644
@@ -0,0 +1,59 @@ @@ -0,0 +1,59 @@
  1 +module Gitlab
  2 + # Extract possible GFM references from an arbitrary String for further processing.
  3 + class ReferenceExtractor
  4 + attr_accessor :users, :issues, :merge_requests, :snippets, :commits
  5 +
  6 + include Markdown
  7 +
  8 + def initialize
  9 + @users, @issues, @merge_requests, @snippets, @commits = [], [], [], [], []
  10 + end
  11 +
  12 + def analyze string
  13 + parse_references(string.dup)
  14 + end
  15 +
  16 + # Given a valid project, resolve the extracted identifiers of the requested type to
  17 + # model objects.
  18 +
  19 + def users_for project
  20 + users.map do |identifier|
  21 + project.users.where(username: identifier).first
  22 + end.reject(&:nil?)
  23 + end
  24 +
  25 + def issues_for project
  26 + issues.map do |identifier|
  27 + project.issues.where(iid: identifier).first
  28 + end.reject(&:nil?)
  29 + end
  30 +
  31 + def merge_requests_for project
  32 + merge_requests.map do |identifier|
  33 + project.merge_requests.where(iid: identifier).first
  34 + end.reject(&:nil?)
  35 + end
  36 +
  37 + def snippets_for project
  38 + snippets.map do |identifier|
  39 + project.snippets.where(id: identifier).first
  40 + end.reject(&:nil?)
  41 + end
  42 +
  43 + def commits_for project
  44 + repo = project.repository
  45 + return [] if repo.nil?
  46 +
  47 + commits.map do |identifier|
  48 + repo.commit(identifier)
  49 + end.reject(&:nil?)
  50 + end
  51 +
  52 + private
  53 +
  54 + def reference_link type, identifier
  55 + # Append identifier to the appropriate collection.
  56 + send("#{type}s") << identifier
  57 + end
  58 + end
  59 +end
spec/lib/gitlab/reference_extractor_spec.rb 0 → 100644
@@ -0,0 +1,95 @@ @@ -0,0 +1,95 @@
  1 +require 'spec_helper'
  2 +
  3 +describe Gitlab::ReferenceExtractor do
  4 + it 'extracts username references' do
  5 + subject.analyze "this contains a @user reference"
  6 + subject.users.should == ["user"]
  7 + end
  8 +
  9 + it 'extracts issue references' do
  10 + subject.analyze "this one talks about issue #1234"
  11 + subject.issues.should == ["1234"]
  12 + end
  13 +
  14 + it 'extracts merge request references' do
  15 + subject.analyze "and here's !43, a merge request"
  16 + subject.merge_requests.should == ["43"]
  17 + end
  18 +
  19 + it 'extracts snippet ids' do
  20 + subject.analyze "snippets like $12 get extracted as well"
  21 + subject.snippets.should == ["12"]
  22 + end
  23 +
  24 + it 'extracts commit shas' do
  25 + subject.analyze "commit shas 98cf0ae3 are pulled out as Strings"
  26 + subject.commits.should == ["98cf0ae3"]
  27 + end
  28 +
  29 + it 'extracts multiple references and preserves their order' do
  30 + subject.analyze "@me and @you both care about this"
  31 + subject.users.should == ["me", "you"]
  32 + end
  33 +
  34 + it 'leaves the original note unmodified' do
  35 + text = "issue #123 is just the worst, @user"
  36 + subject.analyze text
  37 + text.should == "issue #123 is just the worst, @user"
  38 + end
  39 +
  40 + it 'handles all possible kinds of references' do
  41 + accessors = Gitlab::Markdown::TYPES.map { |t| "#{t}s".to_sym }
  42 + subject.should respond_to(*accessors)
  43 + end
  44 +
  45 + context 'with a project' do
  46 + let(:project) { create(:project_with_code) }
  47 +
  48 + it 'accesses valid user objects on the project team' do
  49 + @u_foo = create(:user, username: 'foo')
  50 + @u_bar = create(:user, username: 'bar')
  51 + create(:user, username: 'offteam')
  52 +
  53 + project.team << [@u_foo, :reporter]
  54 + project.team << [@u_bar, :guest]
  55 +
  56 + subject.analyze "@foo, @baduser, @bar, and @offteam"
  57 + subject.users_for(project).should == [@u_foo, @u_bar]
  58 + end
  59 +
  60 + it 'accesses valid issue objects' do
  61 + @i0 = create(:issue, project: project)
  62 + @i1 = create(:issue, project: project)
  63 +
  64 + subject.analyze "##{@i0.iid}, ##{@i1.iid}, and #999."
  65 + subject.issues_for(project).should == [@i0, @i1]
  66 + end
  67 +
  68 + it 'accesses valid merge requests' do
  69 + @m0 = create(:merge_request, source_project: project, target_project: project, source_branch: 'aaa')
  70 + @m1 = create(:merge_request, source_project: project, target_project: project, source_branch: 'bbb')
  71 +
  72 + subject.analyze "!999, !#{@m1.iid}, and !#{@m0.iid}."
  73 + subject.merge_requests_for(project).should == [@m1, @m0]
  74 + end
  75 +
  76 + it 'accesses valid snippets' do
  77 + @s0 = create(:project_snippet, project: project)
  78 + @s1 = create(:project_snippet, project: project)
  79 + @s2 = create(:project_snippet)
  80 +
  81 + subject.analyze "$#{@s0.id}, $999, $#{@s2.id}, $#{@s1.id}"
  82 + subject.snippets_for(project).should == [@s0, @s1]
  83 + end
  84 +
  85 + it 'accesses valid commits' do
  86 + commit = project.repository.commit("master")
  87 +
  88 + subject.analyze "this references commits #{commit.sha[0..6]} and 012345"
  89 + extracted = subject.commits_for(project)
  90 + extracted.should have(1).item
  91 + extracted[0].sha.should == commit.sha
  92 + extracted[0].message.should == commit.message
  93 + end
  94 + end
  95 +end
spec/models/commit_spec.rb
1 require 'spec_helper' 1 require 'spec_helper'
2 2
3 describe Commit do 3 describe Commit do
4 - let(:commit) { create(:project_with_code).repository.commit } 4 + let(:project) { create :project_with_code }
  5 + let(:commit) { project.repository.commit }
5 6
6 describe '#title' do 7 describe '#title' do
7 it "returns no_commit_message when safe_message is blank" do 8 it "returns no_commit_message when safe_message is blank" do
@@ -46,4 +47,23 @@ describe Commit do @@ -46,4 +47,23 @@ describe Commit do
46 it { should respond_to(:id) } 47 it { should respond_to(:id) }
47 it { should respond_to(:to_patch) } 48 it { should respond_to(:to_patch) }
48 end 49 end
  50 +
  51 + describe '#closes_issues' do
  52 + let(:issue) { create :issue, project: project }
  53 +
  54 + it 'detects issues that this commit is marked as closing' do
  55 + commit.stub(issue_closing_regex: /^([Cc]loses|[Ff]ixes) #\d+/, safe_message: "Fixes ##{issue.iid}")
  56 + commit.closes_issues(project).should == [issue]
  57 + end
  58 + end
  59 +
  60 + it_behaves_like 'a mentionable' do
  61 + let(:subject) { commit }
  62 + let(:mauthor) { create :user, email: commit.author_email }
  63 + let(:backref_text) { "commit #{subject.sha[0..5]}" }
  64 + let(:set_mentionable_text) { ->(txt){ subject.stub(safe_message: txt) } }
  65 +
  66 + # Include the subject in the repository stub.
  67 + let(:extra_commits) { [subject] }
  68 + end
49 end 69 end
spec/models/issue_spec.rb
@@ -56,4 +56,10 @@ describe Issue do @@ -56,4 +56,10 @@ describe Issue do
56 Issue.open_for(user).count.should eq 2 56 Issue.open_for(user).count.should eq 2
57 end 57 end
58 end 58 end
  59 +
  60 + it_behaves_like 'an editable mentionable' do
  61 + let(:subject) { create :issue, project: mproject }
  62 + let(:backref_text) { "issue ##{subject.iid}" }
  63 + let(:set_mentionable_text) { ->(txt){ subject.description = txt } }
  64 + end
59 end 65 end
spec/models/merge_request_spec.rb
@@ -43,7 +43,6 @@ describe MergeRequest do @@ -43,7 +43,6 @@ describe MergeRequest do
43 it { should include_module(Issuable) } 43 it { should include_module(Issuable) }
44 end 44 end
45 45
46 -  
47 describe "#mr_and_commit_notes" do 46 describe "#mr_and_commit_notes" do
48 let!(:merge_request) { create(:merge_request) } 47 let!(:merge_request) { create(:merge_request) }
49 48
@@ -104,4 +103,34 @@ describe MergeRequest do @@ -104,4 +103,34 @@ describe MergeRequest do
104 end 103 end
105 end 104 end
106 105
  106 + describe 'detection of issues to be closed' do
  107 + let(:issue0) { create :issue, project: subject.project }
  108 + let(:issue1) { create :issue, project: subject.project }
  109 + let(:commit0) { mock('commit0', closes_issues: [issue0]) }
  110 + let(:commit1) { mock('commit1', closes_issues: [issue0]) }
  111 + let(:commit2) { mock('commit2', closes_issues: [issue1]) }
  112 +
  113 + before do
  114 + subject.stub(unmerged_commits: [commit0, commit1, commit2])
  115 + end
  116 +
  117 + it 'accesses the set of issues that will be closed on acceptance' do
  118 + subject.project.default_branch = subject.target_branch
  119 +
  120 + subject.closes_issues.should == [issue0, issue1].sort_by(&:id)
  121 + end
  122 +
  123 + it 'only lists issues as to be closed if it targets the default branch' do
  124 + subject.project.default_branch = 'master'
  125 + subject.target_branch = 'something-else'
  126 +
  127 + subject.closes_issues.should be_empty
  128 + end
  129 + end
  130 +
  131 + it_behaves_like 'an editable mentionable' do
  132 + let(:subject) { create :merge_request, source_project: mproject, target_project: mproject }
  133 + let(:backref_text) { "merge request !#{subject.iid}" }
  134 + let(:set_mentionable_text) { ->(txt){ subject.title = txt } }
  135 + end
107 end 136 end
spec/models/note_spec.rb
@@ -72,7 +72,6 @@ describe Note do @@ -72,7 +72,6 @@ describe Note do
72 end 72 end
73 73
74 let(:project) { create(:project) } 74 let(:project) { create(:project) }
75 - let(:commit) { project.repository.commit }  
76 75
77 describe "Commit notes" do 76 describe "Commit notes" do
78 let!(:note) { create(:note_on_commit, note: "+1 from me") } 77 let!(:note) { create(:note_on_commit, note: "+1 from me") }
@@ -131,7 +130,7 @@ describe Note do @@ -131,7 +130,7 @@ describe Note do
131 describe "Merge request notes" do 130 describe "Merge request notes" do
132 let!(:note) { create(:note_on_merge_request, note: "+1 from me") } 131 let!(:note) { create(:note_on_merge_request, note: "+1 from me") }
133 132
134 - it "should not be votable" do 133 + it "should be votable" do
135 note.should be_votable 134 note.should be_votable
136 end 135 end
137 end 136 end
@@ -150,7 +149,7 @@ describe Note do @@ -150,7 +149,7 @@ describe Note do
150 let(:author) { create(:user) } 149 let(:author) { create(:user) }
151 let(:status) { 'new_status' } 150 let(:status) { 'new_status' }
152 151
153 - subject { Note.create_status_change_note(thing, project, author, status) } 152 + subject { Note.create_status_change_note(thing, project, author, status, nil) }
154 153
155 it 'creates and saves a Note' do 154 it 'creates and saves a Note' do
156 should be_a Note 155 should be_a Note
@@ -161,6 +160,102 @@ describe Note do @@ -161,6 +160,102 @@ describe Note do
161 its(:project) { should == thing.project } 160 its(:project) { should == thing.project }
162 its(:author) { should == author } 161 its(:author) { should == author }
163 its(:note) { should =~ /Status changed to #{status}/ } 162 its(:note) { should =~ /Status changed to #{status}/ }
  163 +
  164 + it 'appends a back-reference if a closing mentionable is supplied' do
  165 + commit = double('commit', gfm_reference: 'commit 123456')
  166 + n = Note.create_status_change_note(thing, project, author, status, commit)
  167 +
  168 + n.note.should =~ /Status changed to #{status} by commit 123456/
  169 + end
  170 + end
  171 +
  172 + describe '#create_cross_reference_note' do
  173 + let(:project) { create(:project_with_code) }
  174 + let(:author) { create(:user) }
  175 + let(:issue) { create(:issue, project: project) }
  176 + let(:mergereq) { create(:merge_request, target_project: project) }
  177 + let(:commit) { project.repository.commit }
  178 +
  179 + # Test all of {issue, merge request, commit} in both the referenced and referencing
  180 + # roles, to ensure that the correct information can be inferred from any argument.
  181 +
  182 + context 'issue from a merge request' do
  183 + subject { Note.create_cross_reference_note(issue, mergereq, author, project) }
  184 +
  185 + it { should be_valid }
  186 + its(:noteable) { should == issue }
  187 + its(:project) { should == issue.project }
  188 + its(:author) { should == author }
  189 + its(:note) { should == "_mentioned in merge request !#{mergereq.iid}_" }
  190 + end
  191 +
  192 + context 'issue from a commit' do
  193 + subject { Note.create_cross_reference_note(issue, commit, author, project) }
  194 +
  195 + it { should be_valid }
  196 + its(:noteable) { should == issue }
  197 + its(:note) { should == "_mentioned in commit #{commit.sha[0..5]}_" }
  198 + end
  199 +
  200 + context 'merge request from an issue' do
  201 + subject { Note.create_cross_reference_note(mergereq, issue, author, project) }
  202 +
  203 + it { should be_valid }
  204 + its(:noteable) { should == mergereq }
  205 + its(:project) { should == mergereq.project }
  206 + its(:note) { should == "_mentioned in issue ##{issue.iid}_" }
  207 + end
  208 +
  209 + context 'commit from a merge request' do
  210 + subject { Note.create_cross_reference_note(commit, mergereq, author, project) }
  211 +
  212 + it { should be_valid }
  213 + its(:noteable) { should == commit }
  214 + its(:project) { should == project }
  215 + its(:note) { should == "_mentioned in merge request !#{mergereq.iid}_" }
  216 + end
  217 + end
  218 +
  219 + describe '#cross_reference_exists?' do
  220 + let(:project) { create :project }
  221 + let(:author) { create :user }
  222 + let(:issue) { create :issue }
  223 + let(:commit0) { double 'commit0', gfm_reference: 'commit 123456' }
  224 + let(:commit1) { double 'commit1', gfm_reference: 'commit 654321' }
  225 +
  226 + before do
  227 + Note.create_cross_reference_note(issue, commit0, author, project)
  228 + end
  229 +
  230 + it 'detects if a mentionable has already been mentioned' do
  231 + Note.cross_reference_exists?(issue, commit0).should be_true
  232 + end
  233 +
  234 + it 'detects if a mentionable has not already been mentioned' do
  235 + Note.cross_reference_exists?(issue, commit1).should be_false
  236 + end
  237 + end
  238 +
  239 + describe '#system?' do
  240 + let(:project) { create(:project) }
  241 + let(:issue) { create(:issue, project: project) }
  242 + let(:other) { create(:issue, project: project) }
  243 + let(:author) { create(:user) }
  244 +
  245 + it 'should recognize user-supplied notes as non-system' do
  246 + @note = create(:note_on_issue)
  247 + @note.should_not be_system
  248 + end
  249 +
  250 + it 'should identify status-change notes as system notes' do
  251 + @note = Note.create_status_change_note(issue, project, author, 'closed', nil)
  252 + @note.should be_system
  253 + end
  254 +
  255 + it 'should identify cross-reference notes as system notes' do
  256 + @note = Note.create_cross_reference_note(issue, other, author, project)
  257 + @note.should be_system
  258 + end
164 end 259 end
165 260
166 describe :authorization do 261 describe :authorization do
@@ -208,4 +303,11 @@ describe Note do @@ -208,4 +303,11 @@ describe Note do
208 it { @abilities.allowed?(@u3, :admin_note, @p1).should be_false } 303 it { @abilities.allowed?(@u3, :admin_note, @p1).should be_false }
209 end 304 end
210 end 305 end
  306 +
  307 + it_behaves_like 'an editable mentionable' do
  308 + let(:issue) { create :issue, project: project }
  309 + let(:subject) { create :note, noteable: issue, project: project }
  310 + let(:backref_text) { issue.gfm_reference }
  311 + let(:set_mentionable_text) { ->(txt) { subject.note = txt } }
  312 + end
211 end 313 end
spec/observers/activity_observer_spec.rb
@@ -3,6 +3,8 @@ require &#39;spec_helper&#39; @@ -3,6 +3,8 @@ require &#39;spec_helper&#39;
3 describe ActivityObserver do 3 describe ActivityObserver do
4 let(:project) { create(:project) } 4 let(:project) { create(:project) }
5 5
  6 + before { Thread.current[:current_user] = create(:user) }
  7 +
6 def self.it_should_be_valid_event 8 def self.it_should_be_valid_event
7 it { @event.should_not be_nil } 9 it { @event.should_not be_nil }
8 it { @event.project.should == project } 10 it { @event.project.should == project }
@@ -34,4 +36,26 @@ describe ActivityObserver do @@ -34,4 +36,26 @@ describe ActivityObserver do
34 it { @event.action.should == Event::COMMENTED } 36 it { @event.action.should == Event::COMMENTED }
35 it { @event.target.should == @note } 37 it { @event.target.should == @note }
36 end 38 end
  39 +
  40 + describe "Ignore system notes" do
  41 + let(:author) { create(:user) }
  42 + let!(:issue) { create(:issue, project: project) }
  43 + let!(:other) { create(:issue) }
  44 +
  45 + it "should not create events for status change notes" do
  46 + expect do
  47 + Note.observers.enable :activity_observer do
  48 + Note.create_status_change_note(issue, project, author, 'reopened', nil)
  49 + end
  50 + end.to_not change { Event.count }
  51 + end
  52 +
  53 + it "should not create events for cross-reference notes" do
  54 + expect do
  55 + Note.observers.enable :activity_observer do
  56 + Note.create_cross_reference_note(issue, other, author, issue.project)
  57 + end
  58 + end.to_not change { Event.count }
  59 + end
  60 + end
37 end 61 end
spec/observers/issue_observer_spec.rb
@@ -8,8 +8,9 @@ describe IssueObserver do @@ -8,8 +8,9 @@ describe IssueObserver do
8 8
9 9
10 before { subject.stub(:current_user).and_return(some_user) } 10 before { subject.stub(:current_user).and_return(some_user) }
  11 + before { subject.stub(:current_commit).and_return(nil) }
11 before { subject.stub(notification: mock('NotificationService').as_null_object) } 12 before { subject.stub(notification: mock('NotificationService').as_null_object) }
12 - 13 + before { mock_issue.project.stub_chain(:repository, :commit).and_return(nil) }
13 14
14 subject { IssueObserver.instance } 15 subject { IssueObserver.instance }
15 16
@@ -19,6 +20,15 @@ describe IssueObserver do @@ -19,6 +20,15 @@ describe IssueObserver do
19 20
20 subject.after_create(mock_issue) 21 subject.after_create(mock_issue)
21 end 22 end
  23 +
  24 + it 'should create cross-reference notes' do
  25 + other_issue = create(:issue)
  26 + mock_issue.stub(references: [other_issue])
  27 +
  28 + Note.should_receive(:create_cross_reference_note).with(other_issue, mock_issue,
  29 + some_user, mock_issue.project)
  30 + subject.after_create(mock_issue)
  31 + end
22 end 32 end
23 33
24 context '#after_close' do 34 context '#after_close' do
@@ -26,7 +36,7 @@ describe IssueObserver do @@ -26,7 +36,7 @@ describe IssueObserver do
26 before { mock_issue.stub(state: 'closed') } 36 before { mock_issue.stub(state: 'closed') }
27 37
28 it 'note is created if the issue is being closed' do 38 it 'note is created if the issue is being closed' do
29 - Note.should_receive(:create_status_change_note).with(mock_issue, mock_issue.project, some_user, 'closed') 39 + Note.should_receive(:create_status_change_note).with(mock_issue, mock_issue.project, some_user, 'closed', nil)
30 40
31 subject.after_close(mock_issue, nil) 41 subject.after_close(mock_issue, nil)
32 end 42 end
@@ -35,13 +45,23 @@ describe IssueObserver do @@ -35,13 +45,23 @@ describe IssueObserver do
35 subject.notification.should_receive(:close_issue).with(mock_issue, some_user) 45 subject.notification.should_receive(:close_issue).with(mock_issue, some_user)
36 subject.after_close(mock_issue, nil) 46 subject.after_close(mock_issue, nil)
37 end 47 end
  48 +
  49 + it 'appends a mention to the closing commit if one is present' do
  50 + commit = double('commit', gfm_reference: 'commit 123456')
  51 + subject.stub(current_commit: commit)
  52 +
  53 + Note.should_receive(:create_status_change_note).with(mock_issue, mock_issue.project, some_user, 'closed', commit)
  54 +
  55 + subject.after_close(mock_issue, nil)
  56 + end
38 end 57 end
39 58
40 context 'a status "reopened"' do 59 context 'a status "reopened"' do
41 before { mock_issue.stub(state: 'reopened') } 60 before { mock_issue.stub(state: 'reopened') }
42 61
43 it 'note is created if the issue is being reopened' do 62 it 'note is created if the issue is being reopened' do
44 - Note.should_receive(:create_status_change_note).with(mock_issue, mock_issue.project, some_user, 'reopened') 63 + Note.should_receive(:create_status_change_note).with(mock_issue, mock_issue.project, some_user, 'reopened', nil)
  64 +
45 subject.after_reopen(mock_issue, nil) 65 subject.after_reopen(mock_issue, nil)
46 end 66 end
47 end 67 end
@@ -67,5 +87,13 @@ describe IssueObserver do @@ -67,5 +87,13 @@ describe IssueObserver do
67 subject.after_update(mock_issue) 87 subject.after_update(mock_issue)
68 end 88 end
69 end 89 end
  90 +
  91 + context 'cross-references' do
  92 + it 'notices added references' do
  93 + mock_issue.should_receive(:notice_added_references)
  94 +
  95 + subject.after_update(mock_issue)
  96 + end
  97 + end
70 end 98 end
71 end 99 end
spec/observers/merge_request_observer_spec.rb
@@ -5,15 +5,20 @@ describe MergeRequestObserver do @@ -5,15 +5,20 @@ describe MergeRequestObserver do
5 let(:assignee) { create :user } 5 let(:assignee) { create :user }
6 let(:author) { create :user } 6 let(:author) { create :user }
7 let(:mr_mock) { double(:merge_request, id: 42, assignee: assignee, author: author) } 7 let(:mr_mock) { double(:merge_request, id: 42, assignee: assignee, author: author) }
8 - let(:assigned_mr) { create(:merge_request, assignee: assignee, author: author) }  
9 - let(:unassigned_mr) { create(:merge_request, author: author) }  
10 - let(:closed_assigned_mr) { create(:closed_merge_request, assignee: assignee, author: author) }  
11 - let(:closed_unassigned_mr) { create(:closed_merge_request, author: author) } 8 + let(:assigned_mr) { create(:merge_request, assignee: assignee, author: author, target_project: create(:project)) }
  9 + let(:unassigned_mr) { create(:merge_request, author: author, target_project: create(:project)) }
  10 + let(:closed_assigned_mr) { create(:closed_merge_request, assignee: assignee, author: author, target_project: create(:project)) }
  11 + let(:closed_unassigned_mr) { create(:closed_merge_request, author: author, target_project: create(:project)) }
12 12
13 before { subject.stub(:current_user).and_return(some_user) } 13 before { subject.stub(:current_user).and_return(some_user) }
14 before { subject.stub(notification: mock('NotificationService').as_null_object) } 14 before { subject.stub(notification: mock('NotificationService').as_null_object) }
15 before { mr_mock.stub(:author_id) } 15 before { mr_mock.stub(:author_id) }
16 before { mr_mock.stub(:target_project) } 16 before { mr_mock.stub(:target_project) }
  17 + before { mr_mock.stub(:source_project) }
  18 + before { mr_mock.stub(:project) }
  19 + before { mr_mock.stub(:create_cross_references!).and_return(true) }
  20 + before { Repository.any_instance.stub(commit: nil) }
  21 +
17 before(:each) { enable_observers } 22 before(:each) { enable_observers }
18 after(:each) { disable_observers } 23 after(:each) { disable_observers }
19 24
@@ -24,11 +29,20 @@ describe MergeRequestObserver do @@ -24,11 +29,20 @@ describe MergeRequestObserver do
24 subject.should_receive(:notification) 29 subject.should_receive(:notification)
25 subject.after_create(mr_mock) 30 subject.after_create(mr_mock)
26 end 31 end
  32 +
  33 + it 'creates cross-reference notes' do
  34 + project = create :project
  35 + mr_mock.stub(title: "this mr references !#{assigned_mr.id}", project: project)
  36 + mr_mock.should_receive(:create_cross_references!).with(project, some_user)
  37 +
  38 + subject.after_create(mr_mock)
  39 + end
27 end 40 end
28 41
29 context '#after_update' do 42 context '#after_update' do
30 before(:each) do 43 before(:each) do
31 mr_mock.stub(:is_being_reassigned?).and_return(false) 44 mr_mock.stub(:is_being_reassigned?).and_return(false)
  45 + mr_mock.stub(:notice_added_references)
32 end 46 end
33 47
34 it 'is called when a merge request is changed' do 48 it 'is called when a merge request is changed' do
@@ -41,6 +55,12 @@ describe MergeRequestObserver do @@ -41,6 +55,12 @@ describe MergeRequestObserver do
41 end 55 end
42 end 56 end
43 57
  58 + it 'checks for new references' do
  59 + mr_mock.should_receive(:notice_added_references)
  60 +
  61 + subject.after_update(mr_mock)
  62 + end
  63 +
44 context 'a notification' do 64 context 'a notification' do
45 it 'is sent if the merge request is being reassigned' do 65 it 'is sent if the merge request is being reassigned' do
46 mr_mock.should_receive(:is_being_reassigned?).and_return(true) 66 mr_mock.should_receive(:is_being_reassigned?).and_return(true)
@@ -61,13 +81,13 @@ describe MergeRequestObserver do @@ -61,13 +81,13 @@ describe MergeRequestObserver do
61 context '#after_close' do 81 context '#after_close' do
62 context 'a status "closed"' do 82 context 'a status "closed"' do
63 it 'note is created if the merge request is being closed' do 83 it 'note is created if the merge request is being closed' do
64 - Note.should_receive(:create_status_change_note).with(assigned_mr, assigned_mr.target_project, some_user, 'closed') 84 + Note.should_receive(:create_status_change_note).with(assigned_mr, assigned_mr.target_project, some_user, 'closed', nil)
65 85
66 assigned_mr.close 86 assigned_mr.close
67 end 87 end
68 88
69 it 'notification is delivered only to author if the merge request is being closed' do 89 it 'notification is delivered only to author if the merge request is being closed' do
70 - Note.should_receive(:create_status_change_note).with(unassigned_mr, unassigned_mr.target_project, some_user, 'closed') 90 + Note.should_receive(:create_status_change_note).with(unassigned_mr, unassigned_mr.target_project, some_user, 'closed', nil)
71 91
72 unassigned_mr.close 92 unassigned_mr.close
73 end 93 end
@@ -77,13 +97,13 @@ describe MergeRequestObserver do @@ -77,13 +97,13 @@ describe MergeRequestObserver do
77 context '#after_reopen' do 97 context '#after_reopen' do
78 context 'a status "reopened"' do 98 context 'a status "reopened"' do
79 it 'note is created if the merge request is being reopened' do 99 it 'note is created if the merge request is being reopened' do
80 - Note.should_receive(:create_status_change_note).with(closed_assigned_mr, closed_assigned_mr.target_project, some_user, 'reopened') 100 + Note.should_receive(:create_status_change_note).with(closed_assigned_mr, closed_assigned_mr.target_project, some_user, 'reopened', nil)
81 101
82 closed_assigned_mr.reopen 102 closed_assigned_mr.reopen
83 end 103 end
84 104
85 it 'notification is delivered only to author if the merge request is being reopened' do 105 it 'notification is delivered only to author if the merge request is being reopened' do
86 - Note.should_receive(:create_status_change_note).with(closed_unassigned_mr, closed_unassigned_mr.target_project, some_user, 'reopened') 106 + Note.should_receive(:create_status_change_note).with(closed_unassigned_mr, closed_unassigned_mr.target_project, some_user, 'reopened', nil)
87 107
88 closed_unassigned_mr.reopen 108 closed_unassigned_mr.reopen
89 end 109 end
spec/observers/note_observer_spec.rb
@@ -5,9 +5,9 @@ describe NoteObserver do @@ -5,9 +5,9 @@ describe NoteObserver do
5 before { subject.stub(notification: mock('NotificationService').as_null_object) } 5 before { subject.stub(notification: mock('NotificationService').as_null_object) }
6 6
7 let(:team_without_author) { (1..2).map { |n| double :user, id: n } } 7 let(:team_without_author) { (1..2).map { |n| double :user, id: n } }
  8 + let(:note) { double(:note).as_null_object }
8 9
9 describe '#after_create' do 10 describe '#after_create' do
10 - let(:note) { double :note }  
11 11
12 it 'is called after a note is created' do 12 it 'is called after a note is created' do
13 subject.should_receive :after_create 13 subject.should_receive :after_create
@@ -22,5 +22,35 @@ describe NoteObserver do @@ -22,5 +22,35 @@ describe NoteObserver do
22 22
23 subject.after_create(note) 23 subject.after_create(note)
24 end 24 end
  25 +
  26 + it 'creates cross-reference notes as appropriate' do
  27 + @p = create(:project)
  28 + @referenced = create(:issue, project: @p)
  29 + @referencer = create(:issue, project: @p)
  30 + @author = create(:user)
  31 +
  32 + Note.should_receive(:create_cross_reference_note).with(@referenced, @referencer, @author, @p)
  33 +
  34 + Note.observers.enable :note_observer do
  35 + create(:note, project: @p, author: @author, noteable: @referencer,
  36 + note: "Duplicate of ##{@referenced.iid}")
  37 + end
  38 + end
  39 +
  40 + it "doesn't cross-reference system notes" do
  41 + Note.should_receive(:create_cross_reference_note).once
  42 +
  43 + Note.observers.enable :note_observer do
  44 + Note.create_cross_reference_note(create(:issue), create(:issue))
  45 + end
  46 + end
  47 + end
  48 +
  49 + describe '#after_update' do
  50 + it 'checks for new cross-references' do
  51 + note.should_receive(:notice_added_references)
  52 +
  53 + subject.after_update(note)
  54 + end
25 end 55 end
26 end 56 end
spec/services/git_push_service_spec.rb
@@ -6,6 +6,7 @@ describe GitPushService do @@ -6,6 +6,7 @@ describe GitPushService do
6 let (:service) { GitPushService.new } 6 let (:service) { GitPushService.new }
7 7
8 before do 8 before do
  9 + @blankrev = '0000000000000000000000000000000000000000'
9 @oldrev = 'b98a310def241a6fd9c9a9a3e7934c48e498fe81' 10 @oldrev = 'b98a310def241a6fd9c9a9a3e7934c48e498fe81'
10 @newrev = 'b19a04f53caeebf4fe5ec2327cb83e9253dc91bb' 11 @newrev = 'b19a04f53caeebf4fe5ec2327cb83e9253dc91bb'
11 @ref = 'refs/heads/master' 12 @ref = 'refs/heads/master'
@@ -98,7 +99,7 @@ describe GitPushService do @@ -98,7 +99,7 @@ describe GitPushService do
98 99
99 it "when pushing a branch for the first time" do 100 it "when pushing a branch for the first time" do
100 @project_hook.should_not_receive(:execute) 101 @project_hook.should_not_receive(:execute)
101 - service.execute(project, user, '00000000000000000000000000000000', 'newrev', 'refs/heads/master') 102 + service.execute(project, user, @blankrev, 'newrev', 'refs/heads/master')
102 end 103 end
103 104
104 it "when pushing tags" do 105 it "when pushing tags" do
@@ -107,5 +108,107 @@ describe GitPushService do @@ -107,5 +108,107 @@ describe GitPushService do
107 end 108 end
108 end 109 end
109 end 110 end
  111 +
  112 + describe "cross-reference notes" do
  113 + let(:issue) { create :issue, project: project }
  114 + let(:commit_author) { create :user }
  115 + let(:commit) { project.repository.commit }
  116 +
  117 + before do
  118 + commit.stub({
  119 + safe_message: "this commit \n mentions ##{issue.id}",
  120 + references: [issue],
  121 + author_name: commit_author.name,
  122 + author_email: commit_author.email
  123 + })
  124 + project.repository.stub(commits_between: [commit])
  125 + end
  126 +
  127 + it "creates a note if a pushed commit mentions an issue" do
  128 + Note.should_receive(:create_cross_reference_note).with(issue, commit, commit_author, project)
  129 +
  130 + service.execute(project, user, @oldrev, @newrev, @ref)
  131 + end
  132 +
  133 + it "only creates a cross-reference note if one doesn't already exist" do
  134 + Note.create_cross_reference_note(issue, commit, user, project)
  135 +
  136 + Note.should_not_receive(:create_cross_reference_note).with(issue, commit, commit_author, project)
  137 +
  138 + service.execute(project, user, @oldrev, @newrev, @ref)
  139 + end
  140 +
  141 + it "defaults to the pushing user if the commit's author is not known" do
  142 + commit.stub(author_name: 'unknown name', author_email: 'unknown@email.com')
  143 + Note.should_receive(:create_cross_reference_note).with(issue, commit, user, project)
  144 +
  145 + service.execute(project, user, @oldrev, @newrev, @ref)
  146 + end
  147 +
  148 + it "finds references in the first push to a non-default branch" do
  149 + project.repository.stub(:commits_between).with(@blankrev, @newrev).and_return([])
  150 + project.repository.stub(:commits_between).with("master", @newrev).and_return([commit])
  151 +
  152 + Note.should_receive(:create_cross_reference_note).with(issue, commit, commit_author, project)
  153 +
  154 + service.execute(project, user, @blankrev, @newrev, 'refs/heads/other')
  155 + end
  156 +
  157 + it "finds references in the first push to a default branch" do
  158 + project.repository.stub(:commits_between).with(@blankrev, @newrev).and_return([])
  159 + project.repository.stub(:commits).with(@newrev).and_return([commit])
  160 +
  161 + Note.should_receive(:create_cross_reference_note).with(issue, commit, commit_author, project)
  162 +
  163 + service.execute(project, user, @blankrev, @newrev, 'refs/heads/master')
  164 + end
  165 + end
  166 +
  167 + describe "closing issues from pushed commits" do
  168 + let(:issue) { create :issue, project: project }
  169 + let(:other_issue) { create :issue, project: project }
  170 + let(:commit_author) { create :user }
  171 + let(:closing_commit) { project.repository.commit }
  172 +
  173 + before do
  174 + closing_commit.stub({
  175 + issue_closing_regex: /^([Cc]loses|[Ff]ixes) #\d+/,
  176 + safe_message: "this is some work.\n\ncloses ##{issue.iid}",
  177 + author_name: commit_author.name,
  178 + author_email: commit_author.email
  179 + })
  180 +
  181 + project.repository.stub(commits_between: [closing_commit])
  182 + end
  183 +
  184 + it "closes issues with commit messages" do
  185 + service.execute(project, user, @oldrev, @newrev, @ref)
  186 +
  187 + Issue.find(issue.id).should be_closed
  188 + end
  189 +
  190 + it "passes the closing commit as a thread-local" do
  191 + service.execute(project, user, @oldrev, @newrev, @ref)
  192 +
  193 + Thread.current[:current_commit].should == closing_commit
  194 + end
  195 +
  196 + it "doesn't create cross-reference notes for a closing reference" do
  197 + expect {
  198 + service.execute(project, user, @oldrev, @newrev, @ref)
  199 + }.not_to change { Note.where(project_id: project.id, system: true).count }
  200 + end
  201 +
  202 + it "doesn't close issues when pushed to non-default branches" do
  203 + project.stub(default_branch: 'durf')
  204 +
  205 + # The push still shouldn't create cross-reference notes.
  206 + expect {
  207 + service.execute(project, user, @oldrev, @newrev, 'refs/heads/hurf')
  208 + }.not_to change { Note.where(project_id: project.id, system: true).count }
  209 +
  210 + Issue.find(issue.id).should be_opened
  211 + end
  212 + end
110 end 213 end
111 214
spec/support/login_helpers.rb
@@ -18,6 +18,7 @@ module LoginHelpers @@ -18,6 +18,7 @@ module LoginHelpers
18 fill_in "user_login", with: user.email 18 fill_in "user_login", with: user.email
19 fill_in "user_password", with: "123456" 19 fill_in "user_password", with: "123456"
20 click_button "Sign in" 20 click_button "Sign in"
  21 + Thread.current[:current_user] = user
21 end 22 end
22 23
23 def logout 24 def logout
spec/support/mentionable_shared_examples.rb 0 → 100644
@@ -0,0 +1,94 @@ @@ -0,0 +1,94 @@
  1 +# Specifications for behavior common to all Mentionable implementations.
  2 +# Requires a shared context containing:
  3 +# - let(:subject) { "the mentionable implementation" }
  4 +# - let(:backref_text) { "the way that +subject+ should refer to itself in backreferences " }
  5 +# - let(:set_mentionable_text) { lambda { |txt| "block that assigns txt to the subject's mentionable_text" } }
  6 +
  7 +def common_mentionable_setup
  8 + # Avoid name collisions with let(:project) or let(:author) in the surrounding scope.
  9 + let(:mproject) { create :project }
  10 + let(:mauthor) { subject.author }
  11 +
  12 + let(:mentioned_issue) { create :issue, project: mproject }
  13 + let(:other_issue) { create :issue, project: mproject }
  14 + let(:mentioned_mr) { create :merge_request, target_project: mproject, source_branch: 'different' }
  15 + let(:mentioned_commit) { mock('commit', sha: '1234567890abcdef').as_null_object }
  16 +
  17 + # Override to add known commits to the repository stub.
  18 + let(:extra_commits) { [] }
  19 +
  20 + # A string that mentions each of the +mentioned_.*+ objects above. Mentionables should add a self-reference
  21 + # to this string and place it in their +mentionable_text+.
  22 + let(:ref_string) do
  23 + "mentions ##{mentioned_issue.iid} twice ##{mentioned_issue.iid}, !#{mentioned_mr.iid}, " +
  24 + "#{mentioned_commit.sha[0..5]} and itself as #{backref_text}"
  25 + end
  26 +
  27 + before do
  28 + # Wire the project's repository to return the mentioned commit, and +nil+ for any
  29 + # unrecognized commits.
  30 + commitmap = { '123456' => mentioned_commit }
  31 + extra_commits.each { |c| commitmap[c.sha[0..5]] = c }
  32 +
  33 + repo = mock('repository')
  34 + repo.stub(:commit) { |sha| commitmap[sha] }
  35 + mproject.stub(repository: repo)
  36 +
  37 + set_mentionable_text.call(ref_string)
  38 + end
  39 +end
  40 +
  41 +shared_examples 'a mentionable' do
  42 + common_mentionable_setup
  43 +
  44 + it 'generates a descriptive back-reference' do
  45 + subject.gfm_reference.should == backref_text
  46 + end
  47 +
  48 + it "extracts references from its reference property" do
  49 + # De-duplicate and omit itself
  50 + refs = subject.references(mproject)
  51 +
  52 + refs.should have(3).items
  53 + refs.should include(mentioned_issue)
  54 + refs.should include(mentioned_mr)
  55 + refs.should include(mentioned_commit)
  56 + end
  57 +
  58 + it 'creates cross-reference notes' do
  59 + [mentioned_issue, mentioned_mr, mentioned_commit].each do |referenced|
  60 + Note.should_receive(:create_cross_reference_note).with(referenced, subject.local_reference, mauthor, mproject)
  61 + end
  62 +
  63 + subject.create_cross_references!(mproject, mauthor)
  64 + end
  65 +
  66 + it 'detects existing cross-references' do
  67 + Note.create_cross_reference_note(mentioned_issue, subject.local_reference, mauthor, mproject)
  68 +
  69 + subject.has_mentioned?(mentioned_issue).should be_true
  70 + subject.has_mentioned?(mentioned_mr).should be_false
  71 + end
  72 +end
  73 +
  74 +shared_examples 'an editable mentionable' do
  75 + common_mentionable_setup
  76 +
  77 + it_behaves_like 'a mentionable'
  78 +
  79 + it 'creates new cross-reference notes when the mentionable text is edited' do
  80 + new_text = "this text still mentions ##{mentioned_issue.iid} and #{mentioned_commit.sha[0..5]}, " +
  81 + "but now it mentions ##{other_issue.iid}, too."
  82 +
  83 + [mentioned_issue, mentioned_commit].each do |oldref|
  84 + Note.should_not_receive(:create_cross_reference_note).with(oldref, subject.local_reference,
  85 + mauthor, mproject)
  86 + end
  87 +
  88 + Note.should_receive(:create_cross_reference_note).with(other_issue, subject.local_reference, mauthor, mproject)
  89 +
  90 + subject.save
  91 + set_mentionable_text.call(new_text)
  92 + subject.notice_added_references(mproject, mauthor)
  93 + end
  94 +end