GitLab steht aufgrund wichtiger Wartungsarbeiten am Montag, den 8. März, zwischen 17:00 und 19:00 Uhr nicht zur Verfügung.

Commit ab91f76e authored by Douwe Maan's avatar Douwe Maan

Add system note with link to diff comparison when MR discussion becomes outdated

parent 52527be4
......@@ -5,7 +5,7 @@
.note-text {
p:last-child {
margin-bottom: 0;
margin-bottom: 0 !important;
}
}
......
......@@ -164,10 +164,6 @@
.discussion-body,
.diff-file {
.notes .note {
padding: 10px 15px;
}
.discussion-reply-holder {
background-color: $white-light;
padding: 10px 16px;
......
......@@ -80,10 +80,6 @@ ul.notes {
&.timeline-entry {
padding: 14px 10px;
}
.system-note {
padding: 0;
}
}
&.is-editing {
......@@ -380,6 +376,10 @@ ul.notes {
padding-bottom: 5px;
}
.system-note .note-header-info {
padding-bottom: 0;
}
.note-headline-light {
display: inline;
......@@ -582,6 +582,17 @@ ul.notes {
}
}
.discussion-body,
.diff-file {
.notes .note {
padding: 10px 15px;
&.system-note {
padding: 0;
}
}
}
.diff-file {
.is-over {
.add-diff-note {
......
......@@ -17,7 +17,8 @@ module SystemNoteHelper
'visible' => 'icon_eye',
'milestone' => 'icon_clock_o',
'discussion' => 'icon_comment_o',
'moved' => 'icon_arrow_circle_o_right'
'moved' => 'icon_arrow_circle_o_right',
'outdated' => 'icon_edit'
}.freeze
def icon_for_system_note(note)
......
......@@ -19,21 +19,9 @@ def legacy_diff_discussion?
def merge_request_version_params
return unless for_merge_request?
return {} if active?
if active?
{}
else
diff_refs = position.diff_refs
if diff = noteable.merge_request_diff_for(diff_refs)
{ diff_id: diff.id }
elsif diff = noteable.merge_request_diff_for(diff_refs.head_sha)
{
diff_id: diff.id,
start_sha: diff_refs.start_sha
}
end
end
noteable.version_params_for(position.diff_refs)
end
def reply_attributes
......
......@@ -8,6 +8,7 @@ class DiffNote < Note
serialize :original_position, Gitlab::Diff::Position
serialize :position, Gitlab::Diff::Position
serialize :change_position, Gitlab::Diff::Position
validates :original_position, presence: true
validates :position, presence: true
......@@ -25,7 +26,7 @@ def discussion_class(*)
DiffDiscussion
end
%i(original_position position).each do |meth|
%i(original_position position change_position).each do |meth|
define_method "#{meth}=" do |new_position|
if new_position.is_a?(String)
new_position = JSON.parse(new_position) rescue nil
......@@ -36,6 +37,8 @@ def discussion_class(*)
new_position = Gitlab::Diff::Position.new(new_position)
end
return if new_position == read_attribute(meth)
super(new_position)
end
end
......@@ -45,7 +48,7 @@ def diff_file
end
def diff_line
@diff_line ||= diff_file.line_for_position(self.original_position) if diff_file
@diff_line ||= diff_file&.line_for_position(self.original_position)
end
def for_line?(line)
......
......@@ -416,13 +416,24 @@ def merge_request_diff_for(diff_refs_or_sha)
@merge_request_diffs_by_diff_refs_or_sha[diff_refs_or_sha]
end
def version_params_for(diff_refs)
if diff = merge_request_diff_for(diff_refs)
{ diff_id: diff.id }
elsif diff = merge_request_diff_for(diff_refs.head_sha)
{
diff_id: diff.id,
start_sha: diff_refs.start_sha
}
end
end
def reload_diff_if_branch_changed
if source_branch_changed? || target_branch_changed?
reload_diff
end
end
def reload_diff
def reload_diff(current_user = nil)
return unless open?
old_diff_refs = self.diff_refs
......@@ -432,7 +443,8 @@ def reload_diff
update_diff_notes_positions(
old_diff_refs: old_diff_refs,
new_diff_refs: new_diff_refs
new_diff_refs: new_diff_refs,
current_user: current_user
)
end
......@@ -861,7 +873,7 @@ def has_complete_diff_refs?
diff_sha_refs && diff_sha_refs.complete?
end
def update_diff_notes_positions(old_diff_refs:, new_diff_refs:)
def update_diff_notes_positions(old_diff_refs:, new_diff_refs:, current_user: nil)
return unless has_complete_diff_refs?
return if new_diff_refs == old_diff_refs
......@@ -875,7 +887,7 @@ def update_diff_notes_positions(old_diff_refs:, new_diff_refs:)
service = Notes::DiffPositionUpdateService.new(
self.project,
nil,
current_user,
old_diff_refs: old_diff_refs,
new_diff_refs: new_diff_refs,
paths: paths
......
......@@ -175,12 +175,11 @@ def latest?
self == merge_request.merge_request_diff
end
def compare_with(sha, straight: true)
def compare_with(sha)
# When compare merge request versions we want diff A..B instead of A...B
# so we handle cases when user does squash and rebase of the commits between versions.
# For this reason we set straight to true by default.
CompareService.new(project, head_commit_sha)
.execute(project, sha, straight: straight)
CompareService.new(project, head_commit_sha).execute(project, sha, straight: true)
end
def commits_count
......
......@@ -121,16 +121,17 @@ def find_discussion(discussion_id)
end
def grouped_diff_discussions(diff_refs = nil)
groups = {}
groups = Hash.new { |h, k| h[k] = [] }
diff_notes.fresh.discussions.each do |discussion|
if discussion.active?(diff_refs)
discussions = groups[discussion.line_code] ||= []
elsif diff_refs && discussion.created_at_diff?(diff_refs)
discussions = groups[discussion.original_line_code] ||= []
end
discussions << discussion if discussions
line_code =
if discussion.active?(diff_refs)
discussion.line_code
elsif diff_refs && discussion.created_at_diff?(diff_refs)
discussion.original_line_code
end
groups[line_code] << discussion if line_code
end
groups
......
......@@ -2,6 +2,7 @@ class SystemNoteMetadata < ActiveRecord::Base
ICON_TYPES = %w[
commit description merge confidential visible label assignee cross_reference
title time_tracking branch milestone discussion task moved opened closed merged
outdated
].freeze
validates :note, presence: true
......
......@@ -66,12 +66,12 @@ def reload_merge_requests
filter_merge_requests(merge_requests).each do |merge_request|
if merge_request.source_branch == @branch_name || force_push?
merge_request.reload_diff
merge_request.reload_diff(current_user)
else
mr_commit_ids = merge_request.commits_sha
push_commit_ids = @commits.map(&:id)
matches = mr_commit_ids & push_commit_ids
merge_request.reload_diff if matches.any?
merge_request.reload_diff(current_user) if matches.any?
end
merge_request.mark_as_unchecked
......
......@@ -8,7 +8,7 @@ def execute(merge_request)
create_note(merge_request)
notification_service.reopen_mr(merge_request, current_user)
execute_hooks(merge_request, 'reopen')
merge_request.reload_diff
merge_request.reload_diff(current_user)
merge_request.mark_as_unchecked
end
......
module Notes
class DiffPositionUpdateService < BaseService
def execute(note)
new_position = tracer.trace(note.position)
results = tracer.trace(note.position)
return unless results
# Don't update the position if the type doesn't match, since that means
# the diff line commented on was changed, and the comment is now outdated
old_position = note.position
if new_position &&
new_position != old_position &&
new_position.type == old_position.type
position = results[:position]
outdated = results[:outdated]
note.position = new_position
end
if outdated
note.change_position = position
note
if note.persisted? && current_user
SystemNoteService.diff_discussion_outdated(note.to_discussion, project, current_user, position)
end
else
note.position = position
note.change_position = nil
end
end
private
def tracer
@tracer ||= Gitlab::Diff::PositionTracer.new(
repository: project.repository,
project: project,
old_diff_refs: params[:old_diff_refs],
new_diff_refs: params[:new_diff_refs],
paths: params[:paths]
......
......@@ -258,7 +258,7 @@ def add_merge_request_wip_from_commit(noteable, project, author, commit)
create_note(NoteSummary.new(noteable, project, author, body, action: 'title'))
end
def self.resolve_all_discussions(merge_request, project, author)
def resolve_all_discussions(merge_request, project, author)
body = "resolved all discussions"
create_note(NoteSummary.new(merge_request, project, author, body, action: 'discussion'))
......@@ -274,6 +274,28 @@ def discussion_continued_in_issue(discussion, project, author, issue)
note
end
def diff_discussion_outdated(discussion, project, author, change_position)
merge_request = discussion.noteable
diff_refs = change_position.diff_refs
version_index = merge_request.merge_request_diffs.viewable.count
body = "changed this line in"
if version_params = merge_request.version_params_for(diff_refs)
line_code = change_position.line_code(project.repository)
url = url_helpers.diffs_namespace_project_merge_request_url(project.namespace, project, merge_request, version_params.merge(anchor: line_code))
body << " [version #{version_index} of the diff](#{url})"
else
body << " version #{version_index} of the diff"
end
note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body)
note = Note.create(note_attributes.merge(system: true))
note.system_note_metadata = SystemNoteMetadata.new(action: 'outdated')
note
end
# Called when the title of a Noteable is changed
#
# noteable - Noteable object that responds to `title`
......
......@@ -32,10 +32,9 @@
- elsif discussion.diff_discussion?
on
= conditional_link_to url.present?, url do
- if discussion.active?
the diff
- else
an outdated diff
- unless discussion.active?
an old version of
the diff
= time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago")
= render "discussions/headline", discussion: discussion
......
......@@ -91,7 +91,7 @@
comparing two versions
- else
viewing an old version
of this merge request.
of the diff.
.pull-right
= link_to 'Show latest version', diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn btn-sm'
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddChangePositionToNotes < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
# When a migration requires downtime you **must** uncomment the following
# constant and define a short and easy to understand explanation as to why the
# migration requires downtime.
# DOWNTIME_REASON = ''
# When using the methods "add_concurrent_index", "remove_concurrent_index" or
# "add_column_with_default" you must disable the use of transactions
# as these methods can not run in an existing transaction.
# When using "add_concurrent_index" or "remove_concurrent_index" methods make sure
# that either of them is the _only_ method called in the migration,
# any other changes should go in a separate migration.
# This ensures that upon failure _only_ the index creation or removing fails
# and can be retried or reverted easily.
#
# To disable transactions uncomment the following line and remove these
# comments:
# disable_ddl_transaction!
def change
add_column :notes, :change_position, :text
end
end
......@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20170518231126) do
ActiveRecord::Schema.define(version: 20170521184006) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
......@@ -794,6 +794,7 @@
t.string "discussion_id"
t.text "note_html"
t.integer "cached_markdown_version"
t.text "change_position"
end
add_index "notes", ["author_id"], name: "index_notes_on_author_id", using: :btree
......
......@@ -24,6 +24,14 @@ def diff_files
@diff_files ||= @diffs.decorate! { |diff| decorate_diff!(diff) }
end
def diff_file_with_old_path(old_path)
diff_files.find { |diff_file| diff_file.old_path == old_path }
end
def diff_file_with_new_path(new_path)
diff_files.find { |diff_file| diff_file.new_path == new_path }
end
private
def decorate_diff!(diff)
......
......@@ -12,20 +12,26 @@ class Position
attr_reader :head_sha
def initialize(attrs = {})
if diff_file = attrs[:diff_file]
attrs[:diff_refs] = diff_file.diff_refs
attrs[:old_path] = diff_file.old_path
attrs[:new_path] = diff_file.new_path
end
if diff_refs = attrs[:diff_refs]
attrs[:base_sha] = diff_refs.base_sha
attrs[:start_sha] = diff_refs.start_sha
attrs[:head_sha] = diff_refs.head_sha
end
@old_path = attrs[:old_path]
@new_path = attrs[:new_path]
@base_sha = attrs[:base_sha]
@start_sha = attrs[:start_sha]
@head_sha = attrs[:head_sha]
@old_line = attrs[:old_line]
@new_line = attrs[:new_line]
if attrs[:diff_refs]
@base_sha = attrs[:diff_refs].base_sha
@start_sha = attrs[:diff_refs].start_sha
@head_sha = attrs[:diff_refs].head_sha
else
@base_sha = attrs[:base_sha]
@start_sha = attrs[:start_sha]
@head_sha = attrs[:head_sha]
end
end
# `Gitlab::Diff::Position` objects are stored as serialized attributes in
......@@ -129,11 +135,11 @@ def diff_file(repository)
end
def diff_line(repository)
@diff_line ||= diff_file(repository).line_for_position(self)
@diff_line ||= diff_file(repository)&.line_for_position(self)
end
def line_code(repository)
@line_code ||= diff_file(repository).line_code_for_position(self)
@line_code ||= diff_file(repository)&.line_code_for_position(self)
end
private
......
......@@ -3,21 +3,21 @@
module Gitlab
module Diff
class PositionTracer
attr_accessor :repository
attr_accessor :project
attr_accessor :old_diff_refs
attr_accessor :new_diff_refs
attr_accessor :paths
def initialize(repository:, old_diff_refs:, new_diff_refs:, paths: nil)
@repository = repository
def initialize(project:, old_diff_refs:, new_diff_refs:, paths: nil)
@project = project
@old_diff_refs = old_diff_refs
@new_diff_refs = new_diff_refs
@paths = paths
end
def trace(old_position)
def trace(ab_position)
return unless old_diff_refs&.complete? && new_diff_refs&.complete?
return unless old_position.diff_refs == old_diff_refs
return unless ab_position.diff_refs == old_diff_refs
# Suppose we have an MR with source branch `feature` and target branch `master`.
# When the MR was created, the head of `master` was commit A, and the
......@@ -44,14 +44,14 @@ def trace(old_position)
#
# For diff notes for diff A->B, the position looks like this:
# Position
# base_sha - ID of commit A
# start_sha - ID of commit A
# head_sha - ID of commit B
# old_path - path as of A (nil if file was newly created)
# new_path - path as of B (nil if file was deleted)
# old_line - line number as of A (nil if file was newly created)
# new_line - line number as of B (nil if file was deleted)
#
# We can easily update `base_sha` and `head_sha` to hold the IDs of commits C and D,
# We can easily update `start_sha` and `head_sha` to hold the IDs of commits C and D,
# but need to find the paths and line numbers as of C and D.
#
# If the file was unchanged or newly created in A->B, the path as of D can be found
......@@ -68,107 +68,162 @@ def trace(old_position)
# by generating diff A->C ("base to base"), finding the diff file with
# `diff_file.old_path == position.old_path`, and taking `diff_file.new_path`.
# The path as of D can be found by taking diff C->D, finding the diff file
# with that same `old_path` and taking `diff_file.new_path`.
# with `old_path` set to that `diff_file.new_path` and taking `diff_file.new_path`.
# The line number as of C can be found by using the LineMapper on diff A->C
# and providing the line number as of A.
# The line number as of D can be found by using the LineMapper on diff C->D
# and providing the line number as of C.
results = nil
results ||= trace_added_line(old_position) if old_position.added? || old_position.unchanged?
results ||= trace_removed_line(old_position) if old_position.removed? || old_position.unchanged?
return unless results
file_diff, old_line, new_line = results
new_position = Position.new(
old_path: file_diff.old_path,
new_path: file_diff.new_path,
head_sha: new_diff_refs.head_sha,
start_sha: new_diff_refs.start_sha,
base_sha: new_diff_refs.base_sha,
old_line: old_line,
new_line: new_line
)
# If a position is found, but is not actually contained in the diff, for example
# because it was an unchanged line in the context of a change that was undone,
# we cannot return this as a successful trace.
return unless new_position.diff_line(repository)
new_position
if ab_position.added?
trace_added_line(ab_position)
elsif ab_position.removed?
trace_removed_line(ab_position)
else # unchanged
trace_unchanged_line(ab_position)
end
end
private
def trace_added_line(old_position)
file_path = old_position.new_path
return unless diff_head_to_head
file_head_to_head = diff_head_to_head.find { |diff_file| diff_file.old_path == file_path }
file_path = file_head_to_head.new_path if file_head_to_head
new_line = LineMapper.new(file_head_to_head).old_to_new(old_position.new_line)
return unless new_line
file_diff = new_diffs.find { |diff_file| diff_file.new_path == file_path }
return unless file_diff
old_line = LineMapper.new(file_diff).new_to_old(new_line)
[file_diff, old_line, new_line]
def trace_added_line(ab_position)
b_path = ab_position.new_path
b_line = ab_position.new_line
bd_diff = bd_diffs.diff_file_with_old_path(b_path)
d_path = bd_diff&.new_path || b_path
d_line = LineMapper.new(bd_diff).old_to_new(b_line)
if d_line
cd_diff = cd_diffs.diff_file_with_new_path(d_path)
c_path = cd_diff&.old_path || d_path
c_line = LineMapper.new(cd_diff).new_to_old(d_line)
if c_line
# If the line is still in D but also in C, it has turned from an
# added line into an unchanged one.
new_position = position(cd_diff, c_line, d_line)
if valid_position?(new_position)
# If the line is still in the MR, we don't treat this as outdated.
{ position: new_position, outdated: false }
else
# If the line is no longer in the MR, we unfortunately cannot show
# the current state on the CD diff, so we treat it as outdated.
ac_diff = ac_diffs.diff_file_with_new_path(c_path)
{ position: position(ac_diff, nil, c_line), outdated: true }
end
else
# If the line is still in D and not in C, it is still added.
{ position: position(cd_diff, nil, d_line), outdated: false }
end
else
# If the line is no longer in D, it has been removed from the MR.
{ position: position(bd_diff, b_line, nil), outdated: true }
end
end
def trace_removed_line(old_position)
file_path = old_position.old_path
def trace_removed_line(ab_position)
a_path = ab_position.old_path