From c8a115c0e3a9c8242c2a422572d47a49e0cb2874 Mon Sep 17 00:00:00 2001 From: ash wilson Date: Thu, 30 May 2013 23:16:49 +0000 Subject: Link issues from comments and automatically close them Any mention of Issues, MergeRequests, or Commits via GitLab-flavored markdown references in descriptions, titles, or attached Notes creates a back-reference Note that links to the original referencer. Furthermore, pushing commits with commit messages that match a (configurable) regexp to a project's default branch will close any issues mentioned by GFM in the matched closing phrase. If accepting a merge request would close any Issues in this way, a banner is appended to the merge request's main panel to indicate this. --- app/models/commit.rb | 26 +++++++++++++ app/models/concerns/mentionable.rb | 78 +++++++++++++++++++++++++++++++++----- app/models/issue.rb | 7 ++++ app/models/merge_request.rb | 16 +++++++- app/models/note.rb | 36 +++++++++++++++++- app/models/repository.rb | 1 + 6 files changed, 152 insertions(+), 12 deletions(-) (limited to 'app/models') diff --git a/app/models/commit.rb b/app/models/commit.rb index da80c2940ff..f8ca6a5fe82 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -2,6 +2,9 @@ class Commit include ActiveModel::Conversion include StaticModel extend ActiveModel::Naming + include Mentionable + + attr_mentionable :safe_message # Safe amount of files with diffs in one commit to render # Used to prevent 500 error on huge commits by suppressing diff @@ -65,6 +68,29 @@ class Commit end end + # Regular expression that identifies commit message clauses that trigger issue closing. + def issue_closing_regex + @issue_closing_regex ||= Regexp.new(Gitlab.config.gitlab.issue_closing_pattern) + end + + # Discover issues should be closed when this commit is pushed to a project's + # default branch. + def closes_issues project + md = issue_closing_regex.match(safe_message) + if md + extractor = Gitlab::ReferenceExtractor.new + extractor.analyze(md[0]) + extractor.issues_for(project) + else + [] + end + end + + # Mentionable override. + def gfm_reference + "commit #{sha[0..5]}" + end + def method_missing(m, *args, &block) @raw.send(m, *args, &block) end diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index f22070f8504..27e39339ae8 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -1,12 +1,47 @@ # == Mentionable concern # -# Contains common functionality shared between Issues and Notes +# Contains functionality related to objects that can mention Users, Issues, MergeRequests, or Commits by +# GFM references. # -# Used by Issue, Note +# Used by Issue, Note, MergeRequest, and Commit. # module Mentionable extend ActiveSupport::Concern + module ClassMethods + # Indicate which attributes of the Mentionable to search for GFM references. + def attr_mentionable *attrs + mentionable_attrs.concat(attrs.map(&:to_s)) + end + + # Accessor for attributes marked mentionable. + def mentionable_attrs + @mentionable_attrs ||= [] + end + end + + # Generate a GFM back-reference that will construct a link back to this Mentionable when rendered. Must + # be overridden if this model object can be referenced directly by GFM notation. + def gfm_reference + raise NotImplementedError.new("#{self.class} does not implement #gfm_reference") + end + + # Construct a String that contains possible GFM references. + def mentionable_text + self.class.mentionable_attrs.map { |attr| send(attr) || '' }.join + end + + # The GFM reference to this Mentionable, which shouldn't be included in its #references. + def local_reference + self + end + + # Determine whether or not a cross-reference Note has already been created between this Mentionable and + # the specified target. + def has_mentioned? target + Note.cross_reference_exists?(target, local_reference) + end + def mentioned_users users = [] return users if mentionable_text.blank? @@ -24,14 +59,39 @@ module Mentionable users.uniq end - def mentionable_text - if self.class == Issue - description - elsif self.class == Note - note - else - nil + # Extract GFM references to other Mentionables from this Mentionable. Always excludes its #local_reference. + def references p = project, text = mentionable_text + return [] if text.blank? + ext = Gitlab::ReferenceExtractor.new + ext.analyze(text) + (ext.issues_for(p) + ext.merge_requests_for(p) + ext.commits_for(p)).uniq - [local_reference] + end + + # Create a cross-reference Note for each GFM reference to another Mentionable found in +mentionable_text+. + def create_cross_references! p = project, a = author, without = [] + refs = references(p) - without + refs.each do |ref| + Note.create_cross_reference_note(ref, local_reference, a, p) end end + # If the mentionable_text field is about to change, locate any *added* references and create cross references for + # them. Invoke from an observer's #before_save implementation. + def notice_added_references p = project, a = author + ch = changed_attributes + original, mentionable_changed = "", false + self.class.mentionable_attrs.each do |attr| + if ch[attr] + original << ch[attr] + mentionable_changed = true + end + end + + # Only proceed if the saved changes actually include a chance to an attr_mentionable field. + return unless mentionable_changed + + preexisting = references(p, original) + create_cross_references!(p, a, preexisting) + end + end diff --git a/app/models/issue.rb b/app/models/issue.rb index 03c1c166137..ffe9681fc83 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -32,6 +32,7 @@ class Issue < ActiveRecord::Base attr_accessible :title, :assignee_id, :position, :description, :milestone_id, :label_list, :author_id_of_changes, :state_event + attr_mentionable :title, :description acts_as_taggable_on :labels @@ -56,4 +57,10 @@ class Issue < ActiveRecord::Base # Both open and reopened issues should be listed as opened scope :opened, -> { with_state(:opened, :reopened) } + + # Mentionable overrides. + + def gfm_reference + "issue ##{iid}" + end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 0bb4b231f62..514fc79f7c5 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -31,7 +31,7 @@ class MergeRequest < ActiveRecord::Base belongs_to :source_project, foreign_key: :source_project_id, class_name: "Project" attr_accessible :title, :assignee_id, :source_project_id, :source_branch, :target_project_id, :target_branch, :milestone_id, :author_id_of_changes, :state_event - + attr_mentionable :title attr_accessor :should_remove_source_branch @@ -255,6 +255,20 @@ class MergeRequest < ActiveRecord::Base target_project end + # Return the set of issues that will be closed if this merge request is accepted. + def closes_issues + if target_branch == project.default_branch + unmerged_commits.map { |c| c.closes_issues(project) }.flatten.uniq.sort_by(&:id) + else + [] + end + end + + # Mentionable override. + def gfm_reference + "merge request !#{iid}" + end + private def dump_commits(commits) diff --git a/app/models/note.rb b/app/models/note.rb index 7598978ad4d..e819a5516b5 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -24,6 +24,7 @@ class Note < ActiveRecord::Base attr_accessible :note, :noteable, :noteable_id, :noteable_type, :project_id, :attachment, :line_code, :commit_id + attr_mentionable :note belongs_to :project belongs_to :noteable, polymorphic: true @@ -54,15 +55,36 @@ class Note < ActiveRecord::Base serialize :st_diff before_create :set_diff, if: ->(n) { n.line_code.present? } - def self.create_status_change_note(noteable, project, author, status) + def self.create_status_change_note(noteable, project, author, status, source) + body = "_Status changed to #{status}#{' by ' + source.gfm_reference if source}_" + + create({ + noteable: noteable, + project: project, + author: author, + note: body, + system: true + }, without_protection: true) + end + + # +noteable+ was referenced from +mentioner+, by including GFM in either +mentioner+'s description or an associated Note. + # Create a system Note associated with +noteable+ with a GFM back-reference to +mentioner+. + def self.create_cross_reference_note(noteable, mentioner, author, project) create({ noteable: noteable, + commit_id: (noteable.sha if noteable.respond_to? :sha), project: project, author: author, - note: "_Status changed to #{status}_" + note: "_mentioned in #{mentioner.gfm_reference}_", + system: true }, without_protection: true) end + # Determine whether or not a cross-reference note already exists. + def self.cross_reference_exists?(noteable, mentioner) + where(noteable_id: noteable.id, system: true, note: "_mentioned in #{mentioner.gfm_reference}_").any? + end + def commit_author @commit_author ||= project.users.find_by_email(noteable.author_email) || @@ -191,6 +213,16 @@ class Note < ActiveRecord::Base for_issue? || (for_merge_request? && !for_diff_line?) end + # Mentionable override. + def gfm_reference + noteable.gfm_reference + end + + # Mentionable override. + def local_reference + noteable + end + def noteable_type_name if noteable_type.present? noteable_type.downcase diff --git a/app/models/repository.rb b/app/models/repository.rb index 3d649519d8f..aeec48ee5cc 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -18,6 +18,7 @@ class Repository end def commit(id = nil) + return nil unless raw_repository commit = Gitlab::Git::Commit.find(raw_repository, id) commit = Commit.new(commit) if commit commit -- cgit v1.2.1