Commit 13eddac0 authored by Thong Kuah's avatar Thong Kuah 🌴

Merge branch 'ajk-designs-system-notes' into 'master'

Create system notes for design events

See merge request gitlab-org/gitlab!14791
parents eb82ce60 606135e1
......@@ -11,9 +11,11 @@ module Boards
# rubocop: disable CodeReuse/ActiveRecord
def metadata
issues = Issue.arel_table
keys = metadata_fields.keys
columns = metadata_fields.values_at(*keys).join(', ')
results = Issue.where(id: fetch_issues.select('issues.id')).pluck(columns)
# TODO: eliminate need for SQL literal fragment
columns = Arel.sql(metadata_fields.values_at(*keys).join(', '))
results = Issue.where(id: fetch_issues.select(issues[:id])).pluck(columns)
Hash[keys.zip(results.flatten)]
end
......
......@@ -117,3 +117,4 @@
- [jira_connect, 1]
- [update_external_pull_requests, 3]
- [refresh_license_compliance_checks, 2]
- [design_management_new_version, 1]
......@@ -18,7 +18,10 @@ module EE
'epic_date_changed' => 'calendar',
'weight' => 'weight',
'relate_epic' => 'epic',
'unrelate_epic' => 'epic'
'unrelate_epic' => 'epic',
'design_added' => 'doc-image',
'design_modified' => 'doc-image',
'design_removed' => 'doc-image'
}.freeze
override :system_note_icon_name
......
......@@ -44,6 +44,8 @@ module DesignManagement
sha_attribute :sha
delegate :project, to: :issue
scope :for_designs, -> (designs) do
where(id: Action.where(design_id: designs).select(:version_id)).distinct
end
......@@ -83,5 +85,22 @@ module DesignManagement
rescue
raise CouldNotCreateVersion.new(sha, issue_id, design_actions)
end
def designs_by_event
actions
.includes(:design)
.group_by(&:event)
.transform_values { |group| group.map(&:design) }
end
def author
commit&.author
end
private
def commit
@commit ||= issue.project.design_repository.commit(sha)
end
end
end
......@@ -8,6 +8,7 @@ module EE
weight approved unapproved relate unrelate
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
designs_added designs_modified designs_removed
].freeze
EE_TYPES_WITH_CROSS_REFERENCES = %w[
......
......@@ -33,7 +33,10 @@ module DesignManagement
def upload_designs!
actions = build_actions
run_actions(actions) unless actions.empty?
return [] if actions.empty?
version = run_actions(actions)
::DesignManagement::NewVersionWorker.perform_async(version.id)
actions.map(&:design)
end
......
......@@ -25,7 +25,7 @@ module EE
override :boards
# rubocop: disable CodeReuse/ActiveRecord
def boards
super.order('LOWER(name) ASC')
super.order(Arel.sql('LOWER(name) ASC'))
end
# rubocop: enable CodeReuse/ActiveRecord
end
......
......@@ -47,6 +47,33 @@ module EE
create_note(NoteSummary.new(noteable, noteable.project, user, body, action: 'unrelate'))
end
# Parameters:
# - version [DesignManagement::Version]
#
# Example Note text:
#
# "added [1 designs](link-to-version)"
# "changed [2 designs](link-to-version)"
#
# Returns [Array<Note>]: the created Note objects
def design_version_added(version)
events = DesignManagement::Action.events
issue = version.issue
project = issue.project
user = version.author
link_href = design_version_path(version)
version.designs_by_event.map do |(event_name, designs)|
note_data = design_event_note_data(events[event_name])
icon_name = note_data[:icon]
n = designs.size
body = "%s [%d %s](%s)" % [note_data[:past_tense], n, 'design'.pluralize(n), link_href]
create_note(NoteSummary.new(issue, project, user, body, action: icon_name))
end
end
def epic_issue(epic, issue, user, type)
return unless validate_epic_issue_action_type(type)
......@@ -243,5 +270,40 @@ module EE
# See https://gitlab.com/gitlab-org/gitlab-foss/issues/63187.
create_note(NoteSummary.new(noteable, project, author, body, action: 'merge'))
end
private
# We do not have a named route for DesignManagement::Version, instead
# we route to `/designs`, with the version in the query parameters.
# This is because this route is not managed by Rails, but Vue:
def design_version_path(version)
::Gitlab::Routing.url_helpers.designs_project_issue_path(
version.project,
version.issue,
version: version.id
)
end
# Take one of the `DesignManagement::Action.events` and
# return:
# * an English past-tense verb.
# * the name of an icon used in renderin a system note
#
# We do not currently internationalize our system notes,
# instead we just produce English-language descriptions.
# See: https://gitlab.com/gitlab-org/gitlab-ce/issues/65076
# See: https://gitlab.com/gitlab-org/gitlab-ee/issues/14056
def design_event_note_data(event)
case event
when DesignManagement::Action.events[:creation]
{ icon: 'designs_added', past_tense: 'added' }
when DesignManagement::Action.events[:modification]
{ icon: 'designs_modified', past_tense: 'updated' }
when DesignManagement::Action.events[:deletion]
{ icon: 'designs_removed', past_tense: 'removed' }
else
raise "Unknown event: #{event}"
end
end
end
end
......@@ -57,6 +57,7 @@
- admin_emails
- create_github_webhook
- design_management_new_version
- elastic_batch_project_indexer
- elastic_namespace_indexer
- elastic_commit_indexer
......
# frozen_string_literal: true
module DesignManagement
class NewVersionWorker
include ApplicationWorker
def perform(version_id)
version = DesignManagement::Version.find(version_id)
add_system_note(version)
rescue ActiveRecord::RecordNotFound => e
Sidekiq.logger.warn(e)
end
private
def add_system_note(version)
SystemNoteService.design_version_added(version)
end
end
end
---
title: Create system notes for design events
merge_request: 14791
author:
type: added
......@@ -5,17 +5,109 @@ FactoryBot.define do
sequence(:sha) { |n| Digest::SHA1.hexdigest("commit-like-#{n}") }
issue { designs.first&.issue || create(:issue) }
transient do
designs_count 1
created_designs []
modified_designs []
deleted_designs []
end
# Warning: this will intentionally result in an invalid version!
trait :empty do
transient do
no_designs true
end
designs_count 0
end
after(:build) do |version, evaluator|
unless evaluator.try(:no_designs) || version.designs.present?
# By default all designs are created_designs, so just add them.
specific_designs = [].concat(
evaluator.created_designs,
evaluator.modified_designs,
evaluator.deleted_designs
)
version.designs += specific_designs
unless evaluator.designs_count.zero? || version.designs.present?
version.designs << create(:design, issue: version.issue)
end
end
after :create do |version, evaluator|
# FactoryBot does not like methods, so we use lambdas instead
events = DesignManagement::Action.events
version.actions
.where(design_id: evaluator.modified_designs.map(&:id))
.update_all(event: events[:modification])
version.actions
.where(design_id: evaluator.deleted_designs.map(&:id))
.update_all(event: events[:deletion])
version.designs.reload
# Ensure version.issue == design.issue for all version.designs
version.designs.update_all(issue_id: version.issue_id)
needed = evaluator.designs_count
have = version.designs.size
create_list(:design, [0, needed - have].max, issue: version.issue).each do |d|
version.designs << d
end
version.actions.reset
end
# This trait is for versions that must be present in the git repository.
# This might be needed if, for instance, you need to make use of Version#author
trait :committed do
transient do
author { create(:user) }
file File.join(Rails.root, 'spec/fixtures/dk.png')
end
after :create do |version, evaluator|
project = version.issue.project
repository = project.design_repository
repository.create_if_not_exists
designs = version.designs_by_event
base_change = { content: evaluator.file }
actions = %w[modification deletion].flat_map { |k| designs.fetch(k, []) }.map do |design|
base_change.merge(action: :create, file_path: design.full_path)
end
if actions.present?
repository.multi_action(
evaluator.author,
branch_name: 'master',
message: "created #{actions.size} files",
actions: actions
)
end
mapping = {
'creation' => :create,
'modification' => :update,
'deletion' => :delete
}
version_actions = designs.flat_map do |(event, designs)|
base = event == 'deletion' ? {} : base_change
designs.map do |design|
base.merge(action: mapping[event], file_path: design.full_path)
end
end
sha = repository.multi_action(
evaluator.author,
branch_name: 'master',
message: "edited #{version_actions.size} files",
actions: version_actions
)
version.update(sha: sha)
end
end
end
end
......@@ -204,4 +204,53 @@ describe DesignManagement::Version do
end.to change { current_version_id(design_a) }
end
end
describe '#author' do
let(:author) { create(:user) }
subject(:version) { create(:design_version, :committed, author: author) }
it { is_expected.to have_attributes(author: author) }
end
describe '#designs_by_event' do
context 'there is a single design' do
set(:design) { create(:design) }
shared_examples :a_correctly_categorised_design do |kind, category|
let(:version) { create(:design_version, kind => [design]) }
it 'returns a hash with a single key and the single design in that bucket' do
expect(version.designs_by_event).to eq(category => [design])
end
end
it_behaves_like :a_correctly_categorised_design, :created_designs, 'creation'
it_behaves_like :a_correctly_categorised_design, :modified_designs, 'modification'
it_behaves_like :a_correctly_categorised_design, :deleted_designs, 'deletion'
end
context 'there are a bunch of different designs in a variety of states' do
let(:version) do
create(:design_version,
created_designs: create_list(:design, 3),
modified_designs: create_list(:design, 4),
deleted_designs: create_list(:design, 5))
end
it 'puts them in the right buckets' do
expect(version.designs_by_event).to match(
a_hash_including(
'creation' => have_attributes(size: 3),
'modification' => have_attributes(size: 4),
'deletion' => have_attributes(size: 5)
)
)
end
it 'does not suffer from N+1 queries' do
version.designs.map(&:id) # we don't care about the set-up queries
expect { version.designs_by_event }.not_to exceed_query_limit(2)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe EE::SystemNoteMetadata do
%i[designs_added designs_modified designs_removed].each do |action|
context 'when action type is valid' do
subject do
build(:system_note_metadata, note: build(:note), action: action )
end
it { is_expected.to be_valid }
end
end
end
......@@ -18,6 +18,18 @@ describe DesignManagement::SaveDesignsService do
before do
project.add_developer(developer)
allow(::DesignManagement::NewVersionWorker).to receive(:perform_async)
end
RSpec::Matchers.define :enqueue_worker do
match do |action|
expect(::DesignManagement::NewVersionWorker)
.to receive(:perform_async).once.with(Integer)
action.call
end
supports_block_expectations
end
def run_service(files_to_upload = nil)
......@@ -98,11 +110,10 @@ describe DesignManagement::SaveDesignsService do
it 'creates a commit in the repository' do
run_service
commit = design_repository.commit # Get the HEAD
expect(commit).not_to be_nil
expect(commit.author).to eq(user)
expect(commit.message).to include(rails_sample_name)
expect(design_repository.commit).to have_attributes(
author: user,
message: include(rails_sample_name)
)
end
it 'causes diff_refs not to be nil' do
......@@ -215,6 +226,10 @@ describe DesignManagement::SaveDesignsService do
expect { run_service }.to change { commit_count.call }.by(1)
end
it 'enqueues just one new version worker' do
expect { run_service }.to enqueue_worker
end
end
context 'when uploading multiple files' do
......@@ -235,6 +250,10 @@ describe DesignManagement::SaveDesignsService do
expect { run_service }.to change { counter.read(:create) }.by 2
end
it 'enqueues a new version worker' do
expect { run_service }.to enqueue_worker
end
it 'creates a single commit' do
commit_count = -> do
design_repository.expire_all_method_caches
......
......@@ -6,6 +6,7 @@ describe SystemNoteService do
include ProjectForksHelper
include Gitlab::Routing
include RepoHelpers
include DesignManagementTestHelpers
set(:group) { create(:group) }
set(:project) { create(:project, :repository, group: group) }
......@@ -70,6 +71,91 @@ describe SystemNoteService do
end
end
describe '.design_version_added' do
subject { described_class.design_version_added(version) }
# default (valid) parameters:
set(:issue) { create(:issue) }
let(:n_designs) { 3 }
let(:designs) { create_list(:design, n_designs, issue: issue) }
let(:user) { build(:user) }
let(:version) do
create(:design_version, issue: issue, designs: designs)
end
before do
# Avoid needing to call into gitaly
allow(version).to receive(:author).and_return(user)
end
context 'with one kind of event' do
before do
DesignManagement::Action
.where(design: designs).update_all(event: :modification)
end
it 'makes just one note' do
expect(subject).to contain_exactly(Note)
end
it 'adds a new system note' do
expect { subject }.to change { Note.system.count }.by(1)
end
end
context 'with a mixture of events' do
let(:n_designs) { DesignManagement::Action.events.size }
before do
designs.each_with_index do |design, i|
design.actions.update_all(event: i)
end
end
it 'makes one note for each kind of event' do
expect(subject).to have_attributes(size: n_designs)
end
it 'adds a system note for each kind of event' do
expect { subject }.to change { Note.system.count }.by(n_designs)
end
end
context 'it succeeds' do
where(:action, :icon, :human_description) do
[
[:creation, 'designs_added', 'added'],
[:modification, 'designs_modified', 'updated'],
[:deletion, 'designs_removed', 'removed']
]
end
with_them do
before do
version.actions.update_all(event: action)
end
let(:anchor_tag) { %r{ <a[^>]*>#{link}</a>} }
let(:href) { described_class.send(:design_version_path, version) }
let(:link) { "#{n_designs} designs" }
subject(:note) { described_class.design_version_added(version).first }
it 'has the correct data' do
expect(note)
.to be_system
.and have_attributes(
system_note_metadata: have_attributes(action: icon),
note: include(human_description)
.and(include link)
.and(include href),
note_html: a_string_matching(anchor_tag)
)
end
end
end
end
describe '.approve_mr' do
let(:noteable) { create(:merge_request, source_project: project) }
subject { described_class.approve_mr(noteable, author) }
......@@ -191,7 +277,6 @@ describe SystemNoteService do
describe '.epic_issue' do
let(:noteable) { epic }
let(:project) { nil }
context 'issue added to an epic' do
subject { described_class.epic_issue(epic, issue, author, :added) }
......
# frozen_string_literal: true
require 'spec_helper'
describe DesignManagement::NewVersionWorker do
describe '#perform' do
let(:worker) { described_class.new }
context 'the id is wrong or out-of-date' do
let(:version_id) { -1 }
it 'does not create system notes' do
expect(SystemNoteService).not_to receive(:design_version_added)
worker.perform(version_id)
end
it 'logs the reason for this failure' do
expect(Sidekiq.logger).to receive(:warn)
.with(an_instance_of(ActiveRecord::RecordNotFound))
worker.perform(version_id)
end
end
context 'the version id is valid' do
set(:version) { create(:design_version, :committed, designs_count: 2) }
it 'creates a system note' do
expect { worker.perform(version.id) }.to change { Note.system.count }.by(1)
end
it 'does not log anything' do
expect(Sidekiq.logger).not_to receive(:warn)
worker.perform(version.id)
end
end
context 'the version includes multiple types of action' do
set(:version) do
create(:design_version, :committed,
created_designs: create_list(:design, 1),
modified_designs: create_list(:design, 1))
end
it 'creates two system notes' do
expect { worker.perform(version.id) }.to change { Note.system.count }.by(2)
end
it 'calls design_version_added' do
expect(SystemNoteService).to receive(:design_version_added).with(version)
worker.perform(version.id)
end
end
end
end
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