Commit 551946a34e60195c44f293febaa8a0e77f0a23c7

Authored by Dmitriy Zaporozhets
2 parents 2b36dee6 c8a115c0

Merge pull request #4507 from smashwilson/linking-issues

Linking objects from GFM references
CHANGELOG
  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 5 v 6.0.0
2 6 - Feature: Replace teams with group membership
3 7 We introduce group membership in 6.0 as a replacement for teams.
... ... @@ -54,7 +58,7 @@ v 5.4.0
54 58 - Notify mentioned users with email
55 59  
56 60 v 5.3.0
57   - - Refactored services
  61 + - Refactored services
58 62 - Campfire service added
59 63 - HipChat service added
60 64 - Fixed bug with LDAP + git over http
... ...
app/controllers/projects/merge_requests_controller.rb
... ... @@ -3,6 +3,7 @@ require 'gitlab/satellite/satellite'
3 3 class Projects::MergeRequestsController < Projects::ApplicationController
4 4 before_filter :module_enabled
5 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 7 before_filter :validates_merge_request, only: [:show, :diffs]
7 8 before_filter :define_show_vars, only: [:show, :diffs]
8 9  
... ... @@ -135,6 +136,10 @@ class Projects::MergeRequestsController &lt; Projects::ApplicationController
135 136 @merge_request ||= @project.merge_requests.find_by_iid!(params[:id])
136 137 end
137 138  
  139 + def closes_issues
  140 + @closes_issues ||= @merge_request.closes_issues
  141 + end
  142 +
138 143 def authorize_modify_merge_request!
139 144 return render_404 unless can?(current_user, :modify_merge_request, @merge_request)
140 145 end
... ...
app/models/commit.rb
... ... @@ -2,6 +2,9 @@ class Commit
2 2 include ActiveModel::Conversion
3 3 include StaticModel
4 4 extend ActiveModel::Naming
  5 + include Mentionable
  6 +
  7 + attr_mentionable :safe_message
5 8  
6 9 # Safe amount of files with diffs in one commit to render
7 10 # Used to prevent 500 error on huge commits by suppressing diff
... ... @@ -65,6 +68,29 @@ class Commit
65 68 end
66 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 94 def method_missing(m, *args, &block)
69 95 @raw.send(m, *args, &block)
70 96 end
... ...
app/models/concerns/mentionable.rb
1 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 8 module Mentionable
8 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 45 def mentioned_users
11 46 users = []
12 47 return users if mentionable_text.blank?
... ... @@ -24,14 +59,39 @@ module Mentionable
24 59 users.uniq
25 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 75 end
35 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 97 end
... ...
app/models/issue.rb
... ... @@ -32,6 +32,7 @@ class Issue &lt; ActiveRecord::Base
32 32 attr_accessible :title, :assignee_id, :position, :description,
33 33 :milestone_id, :label_list, :author_id_of_changes,
34 34 :state_event
  35 + attr_mentionable :title, :description
35 36  
36 37 acts_as_taggable_on :labels
37 38  
... ... @@ -56,4 +57,10 @@ class Issue &lt; ActiveRecord::Base
56 57  
57 58 # Both open and reopened issues should be listed as opened
58 59 scope :opened, -> { with_state(:opened, :reopened) }
  60 +
  61 + # Mentionable overrides.
  62 +
  63 + def gfm_reference
  64 + "issue ##{iid}"
  65 + end
59 66 end
... ...
app/models/merge_request.rb
... ... @@ -31,7 +31,7 @@ class MergeRequest &lt; ActiveRecord::Base
31 31 belongs_to :source_project, foreign_key: :source_project_id, class_name: "Project"
32 32  
33 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 36 attr_accessor :should_remove_source_branch
37 37  
... ... @@ -255,6 +255,20 @@ class MergeRequest &lt; ActiveRecord::Base
255 255 target_project
256 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 272 private
259 273  
260 274 def dump_commits(commits)
... ...
app/models/note.rb
... ... @@ -24,6 +24,7 @@ class Note &lt; ActiveRecord::Base
24 24  
25 25 attr_accessible :note, :noteable, :noteable_id, :noteable_type, :project_id,
26 26 :attachment, :line_code, :commit_id
  27 + attr_mentionable :note
27 28  
28 29 belongs_to :project
29 30 belongs_to :noteable, polymorphic: true
... ... @@ -54,15 +55,36 @@ class Note &lt; ActiveRecord::Base
54 55 serialize :st_diff
55 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 73 create({
59 74 noteable: noteable,
  75 + commit_id: (noteable.sha if noteable.respond_to? :sha),
60 76 project: project,
61 77 author: author,
62   - note: "_Status changed to #{status}_"
  78 + note: "_mentioned in #{mentioner.gfm_reference}_",
  79 + system: true
63 80 }, without_protection: true)
64 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 88 def commit_author
67 89 @commit_author ||=
68 90 project.users.find_by_email(noteable.author_email) ||
... ... @@ -191,6 +213,16 @@ class Note &lt; ActiveRecord::Base
191 213 for_issue? || (for_merge_request? && !for_diff_line?)
192 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 226 def noteable_type_name
195 227 if noteable_type.present?
196 228 noteable_type.downcase
... ...
app/models/repository.rb
... ... @@ -18,6 +18,7 @@ class Repository
18 18 end
19 19  
20 20 def commit(id = nil)
  21 + return nil unless raw_repository
21 22 commit = Gitlab::Git::Commit.find(raw_repository, id)
22 23 commit = Commit.new(commit) if commit
23 24 commit
... ...
app/observers/activity_observer.rb
... ... @@ -5,8 +5,8 @@ class ActivityObserver &lt; BaseObserver
5 5 event_author_id = record.author_id
6 6  
7 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 11 # Skip wall notes to prevent spamming of dashboard
12 12 return true if record.noteable_type.blank?
... ...
app/observers/base_observer.rb
... ... @@ -10,4 +10,8 @@ class BaseObserver &lt; ActiveRecord::Observer
10 10 def current_user
11 11 Thread.current[:current_user]
12 12 end
  13 +
  14 + def current_commit
  15 + Thread.current[:current_commit]
  16 + end
13 17 end
... ...
app/observers/issue_observer.rb
1 1 class IssueObserver < BaseObserver
2 2 def after_create(issue)
3 3 notification.new_issue(issue, current_user)
  4 +
  5 + issue.create_cross_references!(issue.project, current_user)
4 6 end
5 7  
6 8 def after_close(issue, transition)
... ... @@ -17,12 +19,14 @@ class IssueObserver &lt; BaseObserver
17 19 if issue.is_being_reassigned?
18 20 notification.reassigned_issue(issue, current_user)
19 21 end
  22 +
  23 + issue.notice_added_references(issue.project, current_user)
20 24 end
21 25  
22 26 protected
23 27  
24 28 # Create issue note with service comment like 'Status changed to closed'
25 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 31 end
28 32 end
... ...
app/observers/merge_request_observer.rb
... ... @@ -7,11 +7,13 @@ class MergeRequestObserver &lt; ActivityObserver
7 7 end
8 8  
9 9 notification.new_merge_request(merge_request, current_user)
  10 +
  11 + merge_request.create_cross_references!(merge_request.project, current_user)
10 12 end
11 13  
12 14 def after_close(merge_request, transition)
13 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 18 notification.close_mr(merge_request, current_user)
17 19 end
... ... @@ -33,11 +35,13 @@ class MergeRequestObserver &lt; ActivityObserver
33 35  
34 36 def after_reopen(merge_request, transition)
35 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 39 end
38 40  
39 41 def after_update(merge_request)
40 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 45 end
42 46  
43 47 def create_event(record, status)
... ...
app/observers/note_observer.rb
1 1 class NoteObserver < BaseObserver
2 2 def after_create(note)
3 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 16 end
5 17 end
... ...
app/services/git_push_service.rb
1 1 class GitPushService
2   - attr_accessor :project, :user, :push_data
  2 + attr_accessor :project, :user, :push_data, :push_commits
3 3  
4 4 # This method will be called after each git update
5 5 # and only if the provided user and project is present in GitLab.
6 6 #
7 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 17 def execute(project, user, oldrev, newrev, ref)
17 18 @project, @user = project, user
18 19  
19 20 # Collect data for this git push
  21 + @push_commits = project.repository.commits_between(oldrev, newrev)
20 22 @push_data = post_receive_data(oldrev, newrev, ref)
21 23  
22 24 create_push_event
... ... @@ -25,11 +27,27 @@ class GitPushService
25 27 project.discover_default_branch
26 28 project.repository.expire_cache
27 29  
28   - if push_to_branch?(ref, oldrev)
  30 + if push_to_existing_branch?(ref, oldrev)
29 31 project.update_merge_requests(oldrev, newrev, ref, @user)
  32 + process_commit_messages(ref)
30 33 project.execute_hooks(@push_data.dup)
31 34 project.execute_services(@push_data.dup)
32 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 51 end
34 52  
35 53 # This method provide a sample data
... ... @@ -45,7 +63,7 @@ class GitPushService
45 63 protected
46 64  
47 65 def create_push_event
48   - Event.create(
  66 + Event.create!(
49 67 project: project,
50 68 action: Event::PUSHED,
51 69 data: push_data,
... ... @@ -53,6 +71,36 @@ class GitPushService
53 71 )
54 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 104 # Produce a hash of post-receive data
57 105 #
58 106 # data = {
... ... @@ -72,8 +120,6 @@ class GitPushService
72 120 # }
73 121 #
74 122 def post_receive_data(oldrev, newrev, ref)
75   - push_commits = project.repository.commits_between(oldrev, newrev)
76   -
77 123 # Total commits count
78 124 push_commits_count = push_commits.size
79 125  
... ... @@ -116,10 +162,26 @@ class GitPushService
116 162 data
117 163 end
118 164  
119   - def push_to_branch? ref, oldrev
  165 + def push_to_existing_branch? ref, oldrev
120 166 ref_parts = ref.split('/')
121 167  
122 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 186 end
125 187 end
... ...
app/views/projects/merge_requests/show/_mr_box.html.haml
... ... @@ -33,4 +33,10 @@
33 33 %i.icon-ok
34 34 Merged by #{link_to_member(@project, @merge_request.merge_event.author)}
35 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 46 ## Users management
47 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 54 ## Default project features settings
50 55 default_projects_features:
51 56 issues: true
... ...
config/initializers/1_settings.rb
... ... @@ -68,6 +68,7 @@ rescue ArgumentError # no user configured
68 68 end
69 69 Settings.gitlab['signup_enabled'] ||= false
70 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 72 Settings.gitlab['default_projects_features'] ||= {}
72 73 Settings.gitlab.default_projects_features['issues'] = true if Settings.gitlab.default_projects_features['issues'].nil?
73 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 @@
  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
... ...
db/schema.rb
... ... @@ -152,6 +152,7 @@ ActiveRecord::Schema.define(:version =&gt; 20130821090531) do
152 152 t.string "commit_id"
153 153 t.integer "noteable_id"
154 154 t.text "st_diff"
  155 + t.boolean "system", :default => false, :null => false
155 156 end
156 157  
157 158 add_index "notes", ["author_id"], :name => "index_notes_on_author_id"
... ...
lib/gitlab/reference_extractor.rb 0 → 100644
... ... @@ -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 @@
  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 1 require 'spec_helper'
2 2  
3 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 7 describe '#title' do
7 8 it "returns no_commit_message when safe_message is blank" do
... ... @@ -46,4 +47,23 @@ describe Commit do
46 47 it { should respond_to(:id) }
47 48 it { should respond_to(:to_patch) }
48 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 69 end
... ...
spec/models/issue_spec.rb
... ... @@ -56,4 +56,10 @@ describe Issue do
56 56 Issue.open_for(user).count.should eq 2
57 57 end
58 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 65 end
... ...
spec/models/merge_request_spec.rb
... ... @@ -43,7 +43,6 @@ describe MergeRequest do
43 43 it { should include_module(Issuable) }
44 44 end
45 45  
46   -
47 46 describe "#mr_and_commit_notes" do
48 47 let!(:merge_request) { create(:merge_request) }
49 48  
... ... @@ -104,4 +103,34 @@ describe MergeRequest do
104 103 end
105 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 136 end
... ...
spec/models/note_spec.rb
... ... @@ -72,7 +72,6 @@ describe Note do
72 72 end
73 73  
74 74 let(:project) { create(:project) }
75   - let(:commit) { project.repository.commit }
76 75  
77 76 describe "Commit notes" do
78 77 let!(:note) { create(:note_on_commit, note: "+1 from me") }
... ... @@ -131,7 +130,7 @@ describe Note do
131 130 describe "Merge request notes" do
132 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 134 note.should be_votable
136 135 end
137 136 end
... ... @@ -150,7 +149,7 @@ describe Note do
150 149 let(:author) { create(:user) }
151 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 154 it 'creates and saves a Note' do
156 155 should be_a Note
... ... @@ -161,6 +160,102 @@ describe Note do
161 160 its(:project) { should == thing.project }
162 161 its(:author) { should == author }
163 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 259 end
165 260  
166 261 describe :authorization do
... ... @@ -208,4 +303,11 @@ describe Note do
208 303 it { @abilities.allowed?(@u3, :admin_note, @p1).should be_false }
209 304 end
210 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 313 end
... ...
spec/observers/activity_observer_spec.rb
... ... @@ -3,6 +3,8 @@ require &#39;spec_helper&#39;
3 3 describe ActivityObserver do
4 4 let(:project) { create(:project) }
5 5  
  6 + before { Thread.current[:current_user] = create(:user) }
  7 +
6 8 def self.it_should_be_valid_event
7 9 it { @event.should_not be_nil }
8 10 it { @event.project.should == project }
... ... @@ -34,4 +36,26 @@ describe ActivityObserver do
34 36 it { @event.action.should == Event::COMMENTED }
35 37 it { @event.target.should == @note }
36 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 61 end
... ...
spec/observers/issue_observer_spec.rb
... ... @@ -8,8 +8,9 @@ describe IssueObserver do
8 8  
9 9  
10 10 before { subject.stub(:current_user).and_return(some_user) }
  11 + before { subject.stub(:current_commit).and_return(nil) }
11 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 15 subject { IssueObserver.instance }
15 16  
... ... @@ -19,6 +20,15 @@ describe IssueObserver do
19 20  
20 21 subject.after_create(mock_issue)
21 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 32 end
23 33  
24 34 context '#after_close' do
... ... @@ -26,7 +36,7 @@ describe IssueObserver do
26 36 before { mock_issue.stub(state: 'closed') }
27 37  
28 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 41 subject.after_close(mock_issue, nil)
32 42 end
... ... @@ -35,13 +45,23 @@ describe IssueObserver do
35 45 subject.notification.should_receive(:close_issue).with(mock_issue, some_user)
36 46 subject.after_close(mock_issue, nil)
37 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 57 end
39 58  
40 59 context 'a status "reopened"' do
41 60 before { mock_issue.stub(state: 'reopened') }
42 61  
43 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 65 subject.after_reopen(mock_issue, nil)
46 66 end
47 67 end
... ... @@ -67,5 +87,13 @@ describe IssueObserver do
67 87 subject.after_update(mock_issue)
68 88 end
69 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 98 end
71 99 end
... ...
spec/observers/merge_request_observer_spec.rb
... ... @@ -5,15 +5,20 @@ describe MergeRequestObserver do
5 5 let(:assignee) { create :user }
6 6 let(:author) { create :user }
7 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 13 before { subject.stub(:current_user).and_return(some_user) }
14 14 before { subject.stub(notification: mock('NotificationService').as_null_object) }
15 15 before { mr_mock.stub(:author_id) }
16 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 22 before(:each) { enable_observers }
18 23 after(:each) { disable_observers }
19 24  
... ... @@ -24,11 +29,20 @@ describe MergeRequestObserver do
24 29 subject.should_receive(:notification)
25 30 subject.after_create(mr_mock)
26 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 40 end
28 41  
29 42 context '#after_update' do
30 43 before(:each) do
31 44 mr_mock.stub(:is_being_reassigned?).and_return(false)
  45 + mr_mock.stub(:notice_added_references)
32 46 end
33 47  
34 48 it 'is called when a merge request is changed' do
... ... @@ -41,6 +55,12 @@ describe MergeRequestObserver do
41 55 end
42 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 64 context 'a notification' do
45 65 it 'is sent if the merge request is being reassigned' do
46 66 mr_mock.should_receive(:is_being_reassigned?).and_return(true)
... ... @@ -61,13 +81,13 @@ describe MergeRequestObserver do
61 81 context '#after_close' do
62 82 context 'a status "closed"' do
63 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 86 assigned_mr.close
67 87 end
68 88  
69 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 92 unassigned_mr.close
73 93 end
... ... @@ -77,13 +97,13 @@ describe MergeRequestObserver do
77 97 context '#after_reopen' do
78 98 context 'a status "reopened"' do
79 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 102 closed_assigned_mr.reopen
83 103 end
84 104  
85 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 108 closed_unassigned_mr.reopen
89 109 end
... ...
spec/observers/note_observer_spec.rb
... ... @@ -5,9 +5,9 @@ describe NoteObserver do
5 5 before { subject.stub(notification: mock('NotificationService').as_null_object) }
6 6  
7 7 let(:team_without_author) { (1..2).map { |n| double :user, id: n } }
  8 + let(:note) { double(:note).as_null_object }
8 9  
9 10 describe '#after_create' do
10   - let(:note) { double :note }
11 11  
12 12 it 'is called after a note is created' do
13 13 subject.should_receive :after_create
... ... @@ -22,5 +22,35 @@ describe NoteObserver do
22 22  
23 23 subject.after_create(note)
24 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 55 end
26 56 end
... ...
spec/services/git_push_service_spec.rb
... ... @@ -6,6 +6,7 @@ describe GitPushService do
6 6 let (:service) { GitPushService.new }
7 7  
8 8 before do
  9 + @blankrev = '0000000000000000000000000000000000000000'
9 10 @oldrev = 'b98a310def241a6fd9c9a9a3e7934c48e498fe81'
10 11 @newrev = 'b19a04f53caeebf4fe5ec2327cb83e9253dc91bb'
11 12 @ref = 'refs/heads/master'
... ... @@ -98,7 +99,7 @@ describe GitPushService do
98 99  
99 100 it "when pushing a branch for the first time" do
100 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 103 end
103 104  
104 105 it "when pushing tags" do
... ... @@ -107,5 +108,107 @@ describe GitPushService do
107 108 end
108 109 end
109 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 213 end
111 214  
... ...
spec/support/login_helpers.rb
... ... @@ -18,6 +18,7 @@ module LoginHelpers
18 18 fill_in "user_login", with: user.email
19 19 fill_in "user_password", with: "123456"
20 20 click_button "Sign in"
  21 + Thread.current[:current_user] = user
21 22 end
22 23  
23 24 def logout
... ...
spec/support/mentionable_shared_examples.rb 0 → 100644
... ... @@ -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
... ...