Commit 843d8e08 authored by Mario de la Ossa's avatar Mario de la Ossa Committed by Heinrich Lee Yu

Create SystemNote when Issue Iteration is changed

Add the creation of SystemNotes when an Issue gets their iteration
changed or removed.
parent c34113e5
# frozen_string_literal: true
module MilestonesRoutingHelper
module TimeboxesRoutingHelper
def milestone_path(milestone, *args)
if milestone.group_milestone?
group_milestone_path(milestone.group, milestone, *args)
......@@ -17,3 +17,5 @@ module MilestonesRoutingHelper
end
end
end
TimeboxesRoutingHelper.prepend_if_ee('EE::TimeboxesRoutingHelper')
......@@ -7,6 +7,7 @@ module Timebox
include CacheMarkdownField
include Gitlab::SQL::Pattern
include IidRoutes
include Referable
include StripAttribute
TimeboxStruct = Struct.new(:title, :name, :id) do
......@@ -122,6 +123,35 @@ module Timebox
end
end
##
# Returns the String necessary to reference a Timebox in Markdown. Group
# timeboxes only support name references, and do not support cross-project
# references.
#
# format - Symbol format to use (default: :iid, optional: :name)
#
# Examples:
#
# Milestone.first.to_reference # => "%1"
# Iteration.first.to_reference(format: :name) # => "*iteration:\"goal\""
# Milestone.first.to_reference(cross_namespace_project) # => "gitlab-org/gitlab-foss%1"
# Iteration.first.to_reference(same_namespace_project) # => "gitlab-foss*iteration:1"
#
def to_reference(from = nil, format: :name, full: false)
format_reference = timebox_format_reference(format)
reference = "#{self.class.reference_prefix}#{format_reference}"
if project
"#{project.to_reference_base(from, full: full)}#{reference}"
else
reference
end
end
def reference_link_text(from = nil)
self.class.reference_prefix + self.title
end
def title=(value)
write_attribute(:title, sanitize_title(value)) if value.present?
end
......@@ -162,6 +192,20 @@ module Timebox
private
def timebox_format_reference(format = :iid)
raise ArgumentError, _('Unknown format') unless [:iid, :name].include?(format)
if group_timebox? && format == :iid
raise ArgumentError, _('Cannot refer to a group %{timebox_type} by an internal id!') % { timebox_type: timebox_name }
end
if format == :name && !name.include?('"')
%("#{name}")
else
iid
end
end
# Timebox titles must be unique across project and group timeboxes
def uniqueness_of_title
if project
......
# frozen_string_literal: true
class Iteration < ApplicationRecord
include Timebox
self.table_name = 'sprints'
attr_accessor :skip_future_date_validation
......@@ -15,9 +13,6 @@ class Iteration < ApplicationRecord
include AtomicInternalId
has_many :issues, foreign_key: 'sprint_id'
has_many :merge_requests, foreign_key: 'sprint_id'
belongs_to :project
belongs_to :group
......@@ -62,6 +57,14 @@ class Iteration < ApplicationRecord
else iterations.upcoming
end
end
def reference_prefix
'*iteration:'
end
def reference_pattern
nil
end
end
def state
......@@ -98,3 +101,5 @@ class Iteration < ApplicationRecord
end
end
end
Iteration.prepend_if_ee('EE::Iteration')
......@@ -2,7 +2,6 @@
class Milestone < ApplicationRecord
include Sortable
include Referable
include Timebox
include Milestoneish
include FromUnion
......@@ -122,35 +121,6 @@ class Milestone < ApplicationRecord
}
end
##
# Returns the String necessary to reference a Milestone in Markdown. Group
# milestones only support name references, and do not support cross-project
# references.
#
# format - Symbol format to use (default: :iid, optional: :name)
#
# Examples:
#
# Milestone.first.to_reference # => "%1"
# Milestone.first.to_reference(format: :name) # => "%\"goal\""
# Milestone.first.to_reference(cross_namespace_project) # => "gitlab-org/gitlab-foss%1"
# Milestone.first.to_reference(same_namespace_project) # => "gitlab-foss%1"
#
def to_reference(from = nil, format: :name, full: false)
format_reference = milestone_format_reference(format)
reference = "#{self.class.reference_prefix}#{format_reference}"
if project
"#{project.to_reference_base(from, full: full)}#{reference}"
else
reference
end
end
def reference_link_text(from = nil)
self.class.reference_prefix + self.title
end
def for_display
self
end
......@@ -185,20 +155,6 @@ class Milestone < ApplicationRecord
private
def milestone_format_reference(format = :iid)
raise ArgumentError, _('Unknown format') unless [:iid, :name].include?(format)
if group_milestone? && format == :iid
raise ArgumentError, _('Cannot refer to a group milestone by an internal id!')
end
if format == :name && !name.include?('"')
%("#{name}")
else
iid
end
end
def issues_finder_params
{ project_id: project_id, group_id: group_id, include_subgroups: group_id.present? }.compact
end
......
......@@ -316,7 +316,7 @@ module Gitlab
# conflict with the methods defined in `project_url_helpers`, and we want
# these methods available in the same places.
Gitlab::Routing.add_helpers(project_url_helpers)
Gitlab::Routing.add_helpers(MilestonesRoutingHelper)
Gitlab::Routing.add_helpers(TimeboxesRoutingHelper)
end
end
end
# frozen_string_literal: true
module EE
module TimeboxesRoutingHelper
def iteration_path(iteration, *args)
if iteration.group_timebox?
group_iteration_path(iteration.group, iteration, *args)
elsif iteration.project_timebox?
# We don't have project iteration routes yet, so for now send users to the project itself
project_path(iteration.project, *args)
end
end
def iteration_url(iteration, *args)
if iteration.group_timebox?
group_iteration_url(iteration.group, iteration, *args)
elsif iteration.project_timebox?
# We don't have project iteration routes yet, so for now send users to the project itself
project_url(iteration.project, *args)
end
end
end
end
# frozen_string_literal: true
module EE
module Iteration
extend ActiveSupport::Concern
prepended do
include Timebox
has_many :issues, foreign_key: 'sprint_id'
has_many :merge_requests, foreign_key: 'sprint_id'
end
class_methods do
def reference_pattern
# NOTE: The id pattern only matches when all characters on the expression
# are digits, so it will match *iteration:2 but not *iteration:2.1 because that's probably a
# iteration name and we want it to be matched as such.
@reference_pattern ||= %r{
(#{::Project.reference_pattern})?
#{::Regexp.escape(reference_prefix)}
(?:
(?<iteration_id>
\d+(?!\S\w)\b # Integer-based iteration id, or
) |
(?<iteration_name>
[^"\s]+\b | # String-based single-word iteration title, or
"[^"]+" # String-based multi-word iteration surrounded in quotes
)
)
}x.freeze
end
def link_reference_pattern
@link_reference_pattern ||= super("iterations", /(?<iteration>\d+)/)
end
end
# Show just the title when we manage to find an iteration, without the reference pattern,
# since it's long and unsightly.
def reference_link_text(from = nil)
self.title
end
private
def timebox_format_reference(format = :id)
raise ::ArgumentError, _('Unknown format') unless [:id, :name].include?(format)
if format == :name
super
else
id
end
end
end
end
......@@ -9,12 +9,14 @@ module EE
epic_issue_added issue_added_to_epic epic_issue_removed issue_removed_from_epic
epic_issue_moved issue_changed_epic epic_date_changed relate_epic unrelate_epic
vulnerability_confirmed vulnerability_dismissed vulnerability_resolved
iteration
].freeze
EE_TYPES_WITH_CROSS_REFERENCES = %w[
relate unrelate
epic_issue_added issue_added_to_epic epic_issue_removed issue_removed_from_epic
epic_issue_moved issue_changed_epic relate_epic unrelate_epic
iteration
].freeze
override :icon_types
......
......@@ -7,11 +7,12 @@ module EE
attr_reader :issuable
override :execute
def execute(_issuable, old_labels: [], old_milestone: nil, is_update: true)
def execute(issuable, old_labels: [], old_milestone: nil, is_update: true)
super
ActiveRecord::Base.no_touching do
handle_weight_change(is_update)
handle_iteration_change
if is_update
handle_date_change_note
......@@ -32,6 +33,12 @@ module EE
end
end
def handle_iteration_change
return unless issuable.previous_changes.include?('sprint_id')
::SystemNoteService.change_iteration(issuable, current_user, issuable.iteration)
end
def handle_weight_change(is_update)
return unless issuable.previous_changes.include?('weight')
......
......@@ -44,6 +44,10 @@ module EE
epics_service(epic, user).issue_epic_change(issue)
end
def change_iteration(noteable, author, iteration)
issuables_service(noteable, noteable.project, author).change_iteration(iteration)
end
# Called when the merge request is approved by user
#
# noteable - Noteable object
......
......@@ -74,6 +74,23 @@ module EE
create_note(NoteSummary.new(noteable, project, author, body, action: 'published'))
end
# Called when the iteration of a Noteable is changed
#
# iteration - Iteration being assigned, or nil
#
# Example Note text:
#
# "removed iteration"
#
# "changed iteration to 7.11"
#
# Returns the created Note object
def change_iteration(iteration)
body = iteration.nil? ? 'removed iteration' : "changed iteration to #{iteration.to_reference(project, format: :id)}"
create_note(NoteSummary.new(noteable, project, author, body, action: 'iteration'))
end
end
end
end
# frozen_string_literal: true
module EE
module Banzai
module Filter
# HTML filter that replaces iteration references with links.
module IterationReferenceFilter
include ::Gitlab::Utils::StrongMemoize
def find_object(parent, id)
return unless valid_context?(parent)
find_iteration_with_finder(parent, id: id)
end
def valid_context?(parent)
group_context?(parent) || project_context?(parent)
end
def group_context?(parent)
strong_memoize(:group_context) do
parent.is_a?(Group)
end
end
def project_context?(parent)
strong_memoize(:project_context) do
parent.is_a?(Project)
end
end
def references_in(text, pattern = ::Iteration.reference_pattern)
# We'll handle here the references that follow the `reference_pattern`.
# Other patterns (for example, the link pattern) are handled by the
# default implementation.
return super(text, pattern) if pattern != ::Iteration.reference_pattern
iterations = {}
unescaped_html = unescape_html_entities(text).gsub(pattern) do |match|
iteration = find_iteration($~[:project], $~[:namespace], $~[:iteration_id], $~[:iteration_name])
if iteration
iterations[iteration.id] = yield match, iteration.id, $~[:project], $~[:namespace], $~
"#{::Banzai::Filter::AbstractReferenceFilter::REFERENCE_PLACEHOLDER}#{iteration.id}"
else
match
end
end
return text if iterations.empty?
escape_with_placeholders(unescaped_html, iterations)
end
def find_iteration(project_ref, namespace_ref, iteration_id, iteration_name)
project_path = full_project_path(namespace_ref, project_ref)
# Returns group if project is not found by path
parent = parent_from_ref(project_path)
return unless parent
iteration_params = iteration_params(iteration_id, iteration_name)
find_iteration_with_finder(parent, iteration_params)
end
def iteration_params(id, name)
if name
{ name: name.tr('"', '') }
else
{ id: id.to_i }
end
end
# rubocop:disable CodeReuse/ActiveRecord
def find_iteration_with_finder(parent, params)
finder_params = iteration_finder_params(parent)
::IterationsFinder.new(finder_params).find_by(params)
end
# rubocop:enable CodeReuse/ActiveRecord
def iteration_finder_params(parent)
{ order: nil, state: 'all' }.tap do |params|
params[:project_ids] = parent.id if project_context?(parent)
params[:group_ids] = self_and_ancestors_ids(parent)
end
end
def self_and_ancestors_ids(parent)
if group_context?(parent)
parent.self_and_ancestors.select(:id)
elsif project_context?(parent)
parent.group&.self_and_ancestors&.select(:id)
end
end
def url_for_object(iteration, _parent)
::Gitlab::Routing
.url_helpers
.iteration_url(iteration, only_path: context[:only_path])
end
def object_link_text(object, matches)
iteration_link = escape_once(super)
reference = object.project&.to_reference_base(project)
if reference.present?
"#{iteration_link} <i>in #{reference}</i>".html_safe
else
iteration_link
end
end
def object_link_title(_object, _matches)
'Iteration'
end
end
end
end
end
......@@ -18,6 +18,7 @@ module EE
def reference_filters
[
::Banzai::Filter::EpicReferenceFilter,
::Banzai::Filter::IterationReferenceFilter,
*super
]
end
......
......@@ -10,6 +10,7 @@ module EE
def reference_filters
[
::Banzai::Filter::EpicReferenceFilter,
::Banzai::Filter::IterationReferenceFilter,
*super
]
end
......
# frozen_string_literal: true
module EE
module Banzai
module ReferenceParser
module IterationParser
private
def can_read_reference?(user, ref_project, node)
can?(user, :read_iteration, ref_project)
end
end
end
end
end
This diff is collapsed.
# frozen_string_literal: true
require 'spec_helper'
describe Banzai::ReferenceParser::IterationParser do
include ReferenceParserHelpers
let(:project) { create(:project, :public) }
let(:user) { create(:user) }
let(:iteration) { create(:iteration, project: project) }
subject { described_class.new(Banzai::RenderContext.new(project, user)) }
let(:link) { empty_html_link }
describe '#nodes_visible_to_user' do
context 'when the link has a data-iteration attribute' do
before do
link['data-iteration'] = iteration.id.to_s
end
it_behaves_like "referenced feature visibility", "issues", "merge_requests"
end
end
describe '#referenced_by' do
describe 'when the link has a data-iteration attribute' do
context 'using an existing iteration ID' do
it 'returns an Array of iterations' do
link['data-iteration'] = iteration.id.to_s
expect(subject.referenced_by([link])).to eq([iteration])
end
end
context 'using a non-existing iteration ID' do
it 'returns an empty Array' do
link['data-iteration'] = ''
expect(subject.referenced_by([link])).to eq([])
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Iteration do
let_it_be(:project) { create(:project) }
let_it_be(:group) { create(:group) }
it_behaves_like 'a timebox', :iteration do
let(:timebox_table_name) { described_class.table_name.to_sym }
end
end
......@@ -79,7 +79,7 @@ describe 'Creating an Iteration' do
let(:attributes) { { title: '' } }
it_behaves_like 'a mutation that returns errors in the response',
errors: ["Title can't be blank", "Start date can't be blank", "Due date can't be blank"]
errors: ["Start date can't be blank", "Due date can't be blank", "Title can't be blank"]
it 'does not create the iteration' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Iteration, :count)
......
......@@ -71,6 +71,33 @@ describe Issues::UpdateService do
end
end
context 'assigning iteration' do
before do
stub_licensed_features(iterations: true)
group.add_maintainer(user)
end
context 'group iterations' do
it 'creates a system note' do
group_iteration = create(:iteration, group: group)
expect do
update_issue(iteration: group_iteration)
end.to change { Note.system.count }.by(1)
end
end
context 'project iterations' do
it 'creates a system note' do
project_iteration = create(:iteration, project: project)
expect do
update_issue(iteration: project_iteration)
end.to change { Note.system.count }.by(1)
end
end
end
context 'assigning epic' do
before do
stub_licensed_features(epics: true)
......
......@@ -118,4 +118,58 @@ describe ::SystemNotes::IssuablesService do
expect(subject.note).to eq 'published this issue to the status page'
end
end
describe '#change_iteration' do
subject { service.change_iteration(iteration) }
context 'for a project iteration' do
let(:iteration) { create(:iteration, project: project) }
it_behaves_like 'a system note' do
let(:action) { 'iteration' }
end
it_behaves_like 'a note with overridable created_at'
context 'when iteration added' do
it 'sets the note text' do
reference = iteration.to_reference(format: :id)
expect(subject.note).to eq "changed iteration to #{reference}"
end
end
context 'when iteration removed' do
let(:iteration) { nil }
it 'sets the note text' do
expect(subject.note).to eq 'removed iteration'
end
end
end
context 'for a group iteration' do
let(:iteration) { create(:iteration, group: group) }
it_behaves_like 'a system note' do
let(:action) { 'iteration' }
end
it_behaves_like 'a note with overridable created_at'
context 'when iteration added' do
it 'sets the note text to use the iteration id' do
expect(subject.note).to eq "changed iteration to #{iteration.to_reference(format: :id)}"
end
end
context 'when iteration removed' do
let(:iteration) { nil }
it 'sets the note text' do
expect(subject.note).to eq 'removed iteration'
end
end
end
end
end
......@@ -225,4 +225,14 @@ describe SystemNoteService do
described_class.publish_issue_to_status_page(noteable, project, author)
end
end
describe '.change_iteration' do
it 'calls IssuablesService' do
expect_next_instance_of(::SystemNotes::IssuablesService) do |service|
expect(service).to receive(:change_iteration)
end
described_class.change_iteration(noteable, author, nil)
end
end
end
# frozen_string_literal: true
module Banzai
module Filter
# The actual filter is implemented in the EE mixin
class IterationReferenceFilter < AbstractReferenceFilter
self.reference_type = :iteration
def self.object_class
Iteration
end
end
end
end
Banzai::Filter::IterationReferenceFilter.prepend_if_ee('EE::Banzai::Filter::IterationReferenceFilter')
# frozen_string_literal: true
module Banzai
module ReferenceParser
# The actual parser is implemented in the EE mixin
class IterationParser < BaseParser
self.reference_type = :iteration
def references_relation
Iteration
end
private
def can_read_reference?(_user, _ref_project, _node)
false
end
end
end
end
Banzai::ReferenceParser::IterationParser.prepend_if_ee('::EE::Banzai::ReferenceParser::IterationParser')
......@@ -4,7 +4,7 @@ module Gitlab
# Extract possible GFM references from an arbitrary String for further processing.
class ReferenceExtractor < Banzai::ReferenceExtractor
REFERABLES = %i(user issue label milestone mentioned_user mentioned_group mentioned_project
merge_request snippet commit commit_range directly_addressed_user epic).freeze
merge_request snippet commit commit_range directly_addressed_user epic iteration).freeze
attr_accessor :project, :current_user, :author
# This counter is increased by a number of references filtered out by
# banzai reference exctractor. Note that this counter is stateful and
......
......@@ -3761,7 +3761,7 @@ msgstr ""
msgid "Cannot promote issue due to insufficient permissions."
msgstr ""
msgid "Cannot refer to a group milestone by an internal id!"
msgid "Cannot refer to a group %{timebox_type} by an internal id!"
msgstr ""
msgid "Cannot set confidential epic for not-confidential issue"
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
describe MilestonesRoutingHelper do
describe TimeboxesRoutingHelper do
let(:project) { build_stubbed(:project) }
let(:group) { build_stubbed(:group) }
......
......@@ -296,7 +296,7 @@ describe Gitlab::ReferenceExtractor do
end
it 'returns all supported prefixes' do
expect(prefixes.keys.uniq).to match_array(%w(@ # ~ % ! $ &))
expect(prefixes.keys.uniq).to match_array(%w(@ # ~ % ! $ & *iteration:))
end
it 'does not allow one prefix for multiple referables if not allowed specificly' do
......
......@@ -6,10 +6,6 @@ describe Iteration do
let_it_be(:project) { create(:project) }
let_it_be(:group) { create(:group) }
it_behaves_like 'a timebox', :iteration do
let(:timebox_table_name) { described_class.table_name.to_sym }
end
describe "#iid" do
it "is properly scoped on project and group" do
iteration1 = create(:iteration, project: project)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment