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 | 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 < 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 < 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 < 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 < 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 < 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 < 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 < 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 < 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
app/observers/activity_observer.rb
... | ... | @@ -5,8 +5,8 @@ class ActivityObserver < 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
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 < 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 < 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 < 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: &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? | ... | ... |
... | ... | @@ -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 | 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" | ... | ... |
... | ... | @@ -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 @@ |
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 'spec_helper' |
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
... | ... | @@ -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 | ... | ... |