Commit 551946a34e60195c44f293febaa8a0e77f0a23c7
Exists in
master
and in
4 other branches
Merge pull request #4507 from smashwilson/linking-issues
Linking objects from GFM references
Showing
32 changed files
with
925 additions
and
50 deletions
Show diff stats
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 | 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 < Projects::ApplicationController | @@ -135,6 +136,10 @@ class Projects::MergeRequestsController < 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 < ActiveRecord::Base | @@ -32,6 +32,7 @@ class Issue < 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 < ActiveRecord::Base | @@ -56,4 +57,10 @@ class Issue < 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 < ActiveRecord::Base | @@ -31,7 +31,7 @@ class MergeRequest < 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 < ActiveRecord::Base | @@ -255,6 +255,20 @@ class MergeRequest < 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 < ActiveRecord::Base | @@ -24,6 +24,7 @@ class Note < 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 < ActiveRecord::Base | @@ -54,15 +55,36 @@ class Note < 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 < ActiveRecord::Base | @@ -191,6 +213,16 @@ class Note < 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 < BaseObserver | @@ -5,8 +5,8 @@ class ActivityObserver < 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 < ActiveRecord::Observer | @@ -10,4 +10,8 @@ class BaseObserver < 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 < BaseObserver | @@ -17,12 +19,14 @@ class IssueObserver < 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 < ActivityObserver | @@ -7,11 +7,13 @@ class MergeRequestObserver < 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 < ActivityObserver | @@ -33,11 +35,13 @@ class MergeRequestObserver < 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: &base | @@ -46,6 +46,11 @@ production: &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? |
@@ -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 |
db/schema.rb
@@ -152,6 +152,7 @@ ActiveRecord::Schema.define(:version => 20130821090531) do | @@ -152,6 +152,7 @@ ActiveRecord::Schema.define(:version => 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" |
@@ -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 |
@@ -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 'spec_helper' | @@ -3,6 +3,8 @@ require 'spec_helper' | ||
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 |
@@ -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 |