summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-05-04 06:10:10 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-05-04 06:10:10 +0000
commit2fa68d3a97fd31bf469050e130f0fc95e8944316 (patch)
tree5c00585c55c44917765c152426cb58c803b4f57f /app
parent21be9646a94e2c145897e25d9c521523d55e1614 (diff)
downloadgitlab-ce-2fa68d3a97fd31bf469050e130f0fc95e8944316.tar.gz
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_list.vue24
-rw-r--r--app/controllers/projects/issues_controller.rb9
-rw-r--r--app/models/design_management.rb13
-rw-r--r--app/models/design_management/action.rb42
-rw-r--r--app/models/design_management/design.rb266
-rw-r--r--app/models/design_management/design_action.rb64
-rw-r--r--app/models/design_management/design_at_version.rb119
-rw-r--r--app/models/design_management/design_collection.rb30
-rw-r--r--app/models/design_management/repository.rb51
-rw-r--r--app/models/design_management/version.rb144
-rw-r--r--app/models/design_user_mention.rb6
-rw-r--r--app/models/diff_note.rb10
-rw-r--r--app/models/issue.rb10
-rw-r--r--app/models/note.rb4
-rw-r--r--app/models/project.rb12
-rw-r--r--app/services/design_management/design_user_notes_count_service.rb34
-rw-r--r--app/services/issues/related_branches_service.rb20
-rw-r--r--app/uploaders/design_management/design_v432x230_uploader.rb45
-rw-r--r--app/views/projects/graphs/charts.html.haml4
-rw-r--r--app/views/projects/issues/_related_branches.html.haml8
20 files changed, 903 insertions, 12 deletions
diff --git a/app/assets/javascripts/alert_management/components/alert_management_list.vue b/app/assets/javascripts/alert_management/components/alert_management_list.vue
index 5ea51bef496..1e0cbfbf125 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_list.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_list.vue
@@ -1,5 +1,13 @@
<script>
-import { GlEmptyState, GlDeprecatedButton, GlLoadingIcon, GlTable, GlAlert } from '@gitlab/ui';
+import {
+ GlEmptyState,
+ GlDeprecatedButton,
+ GlLoadingIcon,
+ GlTable,
+ GlAlert,
+ GlNewDropdown,
+ GlNewDropdownItem,
+} from '@gitlab/ui';
import { s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import getAlerts from '../graphql/queries/getAlerts.query.graphql';
@@ -42,6 +50,11 @@ export default {
label: s__('AlertManagement|Status'),
},
],
+ statuses: {
+ triggered: s__('AlertManagement|Triggered'),
+ acknowledged: s__('AlertManagement|Acknowledged'),
+ resolved: s__('AlertManagement|Resolved'),
+ },
components: {
GlEmptyState,
GlLoadingIcon,
@@ -49,6 +62,8 @@ export default {
GlAlert,
GlDeprecatedButton,
TimeAgo,
+ GlNewDropdown,
+ GlNewDropdownItem,
},
props: {
projectPath: {
@@ -140,6 +155,13 @@ export default {
<template #cell(title)="{ item }">
<div class="gl-max-w-full text-truncate">{{ item.title }}</div>
</template>
+ <template #cell(status)="{ item }">
+ <gl-new-dropdown class="w-100" :text="item.status">
+ <gl-new-dropdown-item v-for="(label, field) in $options.statuses" :key="field">
+ {{ label }}
+ </gl-new-dropdown-item>
+ </gl-new-dropdown>
+ </template>
<template #empty>
{{ s__('AlertManagement|No alerts to display.') }}
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 3d53ad1a29f..95b4d5e5658 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -154,7 +154,10 @@ class Projects::IssuesController < Projects::ApplicationController
end
def related_branches
- @related_branches = Issues::RelatedBranchesService.new(project, current_user).execute(issue)
+ @related_branches = Issues::RelatedBranchesService
+ .new(project, current_user)
+ .execute(issue)
+ .map { |branch| branch.merge(link: branch_link(branch)) }
respond_to do |format|
format.json do
@@ -306,6 +309,10 @@ class Projects::IssuesController < Projects::ApplicationController
private
+ def branch_link(branch)
+ project_compare_path(project, from: project.default_branch, to: branch[:name])
+ end
+
def create_rate_limit
key = :issues_create
diff --git a/app/models/design_management.rb b/app/models/design_management.rb
new file mode 100644
index 00000000000..81e170f7e59
--- /dev/null
+++ b/app/models/design_management.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ DESIGN_IMAGE_SIZES = %w(v432x230).freeze
+
+ def self.designs_directory
+ 'designs'
+ end
+
+ def self.table_name_prefix
+ 'design_management_'
+ end
+end
diff --git a/app/models/design_management/action.rb b/app/models/design_management/action.rb
new file mode 100644
index 00000000000..8fe7d7c577c
--- /dev/null
+++ b/app/models/design_management/action.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class Action < ApplicationRecord
+ include WithUploads
+
+ self.table_name = "#{DesignManagement.table_name_prefix}designs_versions"
+
+ mount_uploader :image_v432x230, DesignManagement::DesignV432x230Uploader
+
+ belongs_to :design, class_name: "DesignManagement::Design", inverse_of: :actions
+ belongs_to :version, class_name: "DesignManagement::Version", inverse_of: :actions
+
+ enum event: { creation: 0, modification: 1, deletion: 2 }
+
+ # we assume sequential ordering.
+ scope :ordered, -> { order(version_id: :asc) }
+
+ # For each design, only select the most recent action
+ scope :most_recent, -> do
+ selection = Arel.sql("DISTINCT ON (#{table_name}.design_id) #{table_name}.*")
+
+ order(arel_table[:design_id].asc, arel_table[:version_id].desc).select(selection)
+ end
+
+ # Find all records created before or at the given version, or all if nil
+ scope :up_to_version, ->(version = nil) do
+ case version
+ when nil
+ all
+ when DesignManagement::Version
+ where(arel_table[:version_id].lteq(version.id))
+ when ::Gitlab::Git::COMMIT_ID
+ versions = DesignManagement::Version.arel_table
+ subquery = versions.project(versions[:id]).where(versions[:sha].eq(version))
+ where(arel_table[:version_id].lteq(subquery))
+ else
+ raise ArgumentError, "Expected a DesignManagement::Version, got #{version}"
+ end
+ end
+ end
+end
diff --git a/app/models/design_management/design.rb b/app/models/design_management/design.rb
new file mode 100644
index 00000000000..e9b69eab7a7
--- /dev/null
+++ b/app/models/design_management/design.rb
@@ -0,0 +1,266 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class Design < ApplicationRecord
+ include Importable
+ include Noteable
+ include Gitlab::FileTypeDetection
+ include Gitlab::Utils::StrongMemoize
+ include Referable
+ include Mentionable
+ include WhereComposite
+
+ belongs_to :project, inverse_of: :designs
+ belongs_to :issue
+
+ has_many :actions
+ has_many :versions, through: :actions, class_name: 'DesignManagement::Version', inverse_of: :designs
+ # This is a polymorphic association, so we can't count on FK's to delete the
+ # data
+ has_many :notes, as: :noteable, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+ has_many :user_mentions, class_name: 'DesignUserMention', dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+
+ validates :project, :filename, presence: true
+ validates :issue, presence: true, unless: :importing?
+ validates :filename, uniqueness: { scope: :issue_id }
+ validate :validate_file_is_image
+
+ alias_attribute :title, :filename
+
+ # Pre-fetching scope to include the data necessary to construct a
+ # reference using `to_reference`.
+ scope :for_reference, -> { includes(issue: [{ project: [:route, :namespace] }]) }
+
+ # A design can be uniquely identified by issue_id and filename
+ # Takes one or more sets of composite IDs of the form:
+ # `{issue_id: Integer, filename: String}`.
+ #
+ # @see WhereComposite::where_composite
+ #
+ # e.g:
+ #
+ # by_issue_id_and_filename(issue_id: 1, filename: 'homescreen.jpg')
+ # by_issue_id_and_filename([]) # returns ActiveRecord::NullRelation
+ # by_issue_id_and_filename([
+ # { issue_id: 1, filename: 'homescreen.jpg' },
+ # { issue_id: 2, filename: 'homescreen.jpg' },
+ # { issue_id: 1, filename: 'menu.png' }
+ # ])
+ #
+ scope :by_issue_id_and_filename, ->(composites) do
+ where_composite(%i[issue_id filename], composites)
+ end
+
+ # Find designs visible at the given version
+ #
+ # @param version [nil, DesignManagement::Version]:
+ # the version at which the designs must be visible
+ # Passing `nil` is the same as passing the most current version
+ #
+ # Restricts to designs
+ # - created at least *before* the given version
+ # - not deleted as of the given version.
+ #
+ # As a query, we ascertain this by finding the last event prior to
+ # (or equal to) the cut-off, and seeing whether that version was a deletion.
+ scope :visible_at_version, -> (version) do
+ deletion = ::DesignManagement::Action.events[:deletion]
+ designs = arel_table
+ actions = ::DesignManagement::Action
+ .most_recent.up_to_version(version)
+ .arel.as('most_recent_actions')
+
+ join = designs.join(actions)
+ .on(actions[:design_id].eq(designs[:id]))
+
+ joins(join.join_sources).where(actions[:event].not_eq(deletion)).order(:id)
+ end
+
+ scope :with_filename, -> (filenames) { where(filename: filenames) }
+ scope :on_issue, ->(issue) { where(issue_id: issue) }
+
+ # Scope called by our REST API to avoid N+1 problems
+ scope :with_api_entity_associations, -> { preload(:issue) }
+
+ # A design is current if the most recent event is not a deletion
+ scope :current, -> { visible_at_version(nil) }
+
+ def status
+ if new_design?
+ :new
+ elsif deleted?
+ :deleted
+ else
+ :current
+ end
+ end
+
+ def deleted?
+ most_recent_action&.deletion?
+ end
+
+ # A design is visible_in? a version if:
+ # * it was created before that version
+ # * the most recent action before the version was not a deletion
+ def visible_in?(version)
+ map = strong_memoize(:visible_in) do
+ Hash.new do |h, k|
+ h[k] = self.class.visible_at_version(k).where(id: id).exists?
+ end
+ end
+
+ map[version]
+ end
+
+ def most_recent_action
+ strong_memoize(:most_recent_action) { actions.ordered.last }
+ end
+
+ # A reference for a design is the issue reference, indexed by the filename
+ # with an optional infix when full.
+ #
+ # e.g.
+ # #123[homescreen.png]
+ # other-project#72[sidebar.jpg]
+ # #38/designs[transition.gif]
+ # #12["filename with [] in it.jpg"]
+ def to_reference(from = nil, full: false)
+ infix = full ? '/designs' : ''
+ totally_simple = %r{ \A #{self.class.simple_file_name} \z }x
+ safe_name = if totally_simple.match?(filename)
+ filename
+ elsif filename =~ /[<>]/
+ %Q{base64:#{Base64.strict_encode64(filename)}}
+ else
+ escaped = filename.gsub(%r{[\\"]}) { |x| "\\#{x}" }
+ %Q{"#{escaped}"}
+ end
+
+ "#{issue.to_reference(from, full: full)}#{infix}[#{safe_name}]"
+ end
+
+ def self.reference_pattern
+ @reference_pattern ||= begin
+ # Filenames can be escaped with double quotes to name filenames
+ # that include square brackets, or other special characters
+ %r{
+ #{Issue.reference_pattern}
+ (\/designs)?
+ \[
+ (?<design> #{simple_file_name} | #{quoted_file_name} | #{base_64_encoded_name})
+ \]
+ }x
+ end
+ end
+
+ def self.simple_file_name
+ %r{
+ (?<simple_file_name>
+ ( \w | [_:,'-] | \. | \s )+
+ \.
+ \w+
+ )
+ }x
+ end
+
+ def self.base_64_encoded_name
+ %r{
+ base64:
+ (?<base_64_encoded_name>
+ [A-Za-z0-9+\n]+
+ =?
+ )
+ }x
+ end
+
+ def self.quoted_file_name
+ %r{
+ "
+ (?<escaped_filename>
+ (\\ \\ | \\ " | [^"\\])+
+ )
+ "
+ }x
+ end
+
+ def self.link_reference_pattern
+ @link_reference_pattern ||= begin
+ exts = SAFE_IMAGE_EXT + DANGEROUS_IMAGE_EXT
+ path_segment = %r{issues/#{Gitlab::Regex.issue}/designs}
+ filename_pattern = %r{(?<simple_file_name>[a-z0-9_=-]+\.(#{exts.join('|')}))}i
+
+ super(path_segment, filename_pattern)
+ end
+ end
+
+ def to_ability_name
+ 'design'
+ end
+
+ def description
+ ''
+ end
+
+ def new_design?
+ strong_memoize(:new_design) { actions.none? }
+ end
+
+ def full_path
+ @full_path ||= File.join(DesignManagement.designs_directory, "issue-#{issue.iid}", filename)
+ end
+
+ def diff_refs
+ strong_memoize(:diff_refs) { head_version&.diff_refs }
+ end
+
+ def clear_version_cache
+ [versions, actions].each(&:reset)
+ %i[new_design diff_refs head_sha visible_in most_recent_action].each do |key|
+ clear_memoization(key)
+ end
+ end
+
+ def repository
+ project.design_repository
+ end
+
+ def user_notes_count
+ user_notes_count_service.count
+ end
+
+ def after_note_changed(note)
+ user_notes_count_service.delete_cache unless note.system?
+ end
+ alias_method :after_note_created, :after_note_changed
+ alias_method :after_note_destroyed, :after_note_changed
+
+ private
+
+ def head_version
+ strong_memoize(:head_sha) { versions.ordered.first }
+ end
+
+ def allow_dangerous_images?
+ Feature.enabled?(:design_management_allow_dangerous_images, project)
+ end
+
+ def valid_file_extensions
+ allow_dangerous_images? ? (SAFE_IMAGE_EXT + DANGEROUS_IMAGE_EXT) : SAFE_IMAGE_EXT
+ end
+
+ def validate_file_is_image
+ unless image? || (dangerous_image? && allow_dangerous_images?)
+ message = _('does not have a supported extension. Only %{extension_list} are supported') % {
+ extension_list: valid_file_extensions.to_sentence
+ }
+ errors.add(:filename, message)
+ end
+ end
+
+ def user_notes_count_service
+ strong_memoize(:user_notes_count_service) do
+ ::DesignManagement::DesignUserNotesCountService.new(self) # rubocop: disable CodeReuse/ServiceClass
+ end
+ end
+ end
+end
diff --git a/app/models/design_management/design_action.rb b/app/models/design_management/design_action.rb
new file mode 100644
index 00000000000..22baa916296
--- /dev/null
+++ b/app/models/design_management/design_action.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ # Parameter object which is a tuple of the database record and the
+ # last gitaly call made to change it. This serves to perform the
+ # logical mapping from git action to database representation.
+ class DesignAction
+ include ActiveModel::Validations
+
+ EVENT_FOR_GITALY_ACTION = {
+ create: DesignManagement::Action.events[:creation],
+ update: DesignManagement::Action.events[:modification],
+ delete: DesignManagement::Action.events[:deletion]
+ }.freeze
+
+ attr_reader :design, :action, :content
+
+ delegate :issue_id, to: :design
+
+ validates :design, presence: true
+ validates :action, presence: true, inclusion: { in: EVENT_FOR_GITALY_ACTION.keys }
+ validates :content,
+ absence: { if: :forbids_content?,
+ message: 'this action forbids content' },
+ presence: { if: :needs_content?,
+ message: 'this action needs content' }
+
+ # Parameters:
+ # - design [DesignManagement::Design]: the design that was changed
+ # - action [Symbol]: the action that gitaly performed
+ def initialize(design, action, content = nil)
+ @design, @action, @content = design, action, content
+ validate!
+ end
+
+ def row_attrs(version)
+ { design_id: design.id, version_id: version.id, event: event }
+ end
+
+ def gitaly_action
+ { action: action, file_path: design.full_path, content: content }.compact
+ end
+
+ # This action has been performed - do any post-creation actions
+ # such as clearing method caches.
+ def performed
+ design.clear_version_cache
+ end
+
+ private
+
+ def needs_content?
+ action != :delete
+ end
+
+ def forbids_content?
+ action == :delete
+ end
+
+ def event
+ EVENT_FOR_GITALY_ACTION[action]
+ end
+ end
+end
diff --git a/app/models/design_management/design_at_version.rb b/app/models/design_management/design_at_version.rb
new file mode 100644
index 00000000000..b4cafb93c2c
--- /dev/null
+++ b/app/models/design_management/design_at_version.rb
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+
+# Tuple of design and version
+# * has a composite ID, with lazy_find
+module DesignManagement
+ class DesignAtVersion
+ include ActiveModel::Validations
+ include GlobalID::Identification
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :version
+ attr_reader :design
+
+ validates :version, presence: true
+ validates :design, presence: true
+
+ validate :design_and_version_belong_to_the_same_issue
+ validate :design_and_version_have_issue_id
+
+ def initialize(design: nil, version: nil)
+ @design, @version = design, version
+ end
+
+ def self.instantiate(attrs)
+ new(attrs).tap { |obj| obj.validate! }
+ end
+
+ # The ID, needed by GraphQL types and as part of the Lazy-fetch
+ # protocol, includes information about both the design and the version.
+ #
+ # The particular format is not interesting, and should be treated as opaque
+ # by all callers.
+ def id
+ "#{design.id}.#{version.id}"
+ end
+
+ def ==(other)
+ return false unless other && self.class == other.class
+
+ other.id == id
+ end
+
+ alias_method :eql?, :==
+
+ def self.lazy_find(id)
+ BatchLoader.for(id).batch do |ids, callback|
+ find(ids).each do |record|
+ callback.call(record.id, record)
+ end
+ end
+ end
+
+ def self.find(ids)
+ pairs = ids.map { |id| id.split('.').map(&:to_i) }
+
+ design_ids = pairs.map(&:first).uniq
+ version_ids = pairs.map(&:second).uniq
+
+ designs = ::DesignManagement::Design
+ .where(id: design_ids)
+ .index_by(&:id)
+
+ versions = ::DesignManagement::Version
+ .where(id: version_ids)
+ .index_by(&:id)
+
+ pairs.map do |(design_id, version_id)|
+ design = designs[design_id]
+ version = versions[version_id]
+
+ obj = new(design: design, version: version)
+
+ obj if obj.valid?
+ end.compact
+ end
+
+ def status
+ if not_created_yet?
+ :not_created_yet
+ elsif deleted?
+ :deleted
+ else
+ :current
+ end
+ end
+
+ def deleted?
+ action&.deletion?
+ end
+
+ def not_created_yet?
+ action.nil?
+ end
+
+ private
+
+ def action
+ strong_memoize(:most_recent_action) do
+ ::DesignManagement::Action
+ .most_recent.up_to_version(version)
+ .find_by(design: design)
+ end
+ end
+
+ def design_and_version_belong_to_the_same_issue
+ id_a, id_b = [design, version].map { |obj| obj&.issue_id }
+
+ return if id_a == id_b
+
+ errors.add(:issue, 'must be the same on design and version')
+ end
+
+ def design_and_version_have_issue_id
+ return if [design, version].all? { |obj| obj.try(:issue_id).present? }
+
+ errors.add(:issue, 'must be present on both design and version')
+ end
+ end
+end
diff --git a/app/models/design_management/design_collection.rb b/app/models/design_management/design_collection.rb
new file mode 100644
index 00000000000..18d1541e9c7
--- /dev/null
+++ b/app/models/design_management/design_collection.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class DesignCollection
+ attr_reader :issue
+
+ delegate :designs, :project, to: :issue
+
+ def initialize(issue)
+ @issue = issue
+ end
+
+ def find_or_create_design!(filename:)
+ designs.find { |design| design.filename == filename } ||
+ designs.safe_find_or_create_by!(project: project, filename: filename)
+ end
+
+ def versions
+ @versions ||= DesignManagement::Version.for_designs(designs)
+ end
+
+ def repository
+ project.design_repository
+ end
+
+ def designs_by_filename(filenames)
+ designs.current.where(filename: filenames)
+ end
+ end
+end
diff --git a/app/models/design_management/repository.rb b/app/models/design_management/repository.rb
new file mode 100644
index 00000000000..985d6317d5d
--- /dev/null
+++ b/app/models/design_management/repository.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class Repository < ::Repository
+ extend ::Gitlab::Utils::Override
+
+ # We define static git attributes for the design repository as this
+ # repository is entirely GitLab-managed rather than user-facing.
+ #
+ # Enable all uploaded files to be stored in LFS.
+ MANAGED_GIT_ATTRIBUTES = <<~GA.freeze
+ /#{DesignManagement.designs_directory}/* filter=lfs diff=lfs merge=lfs -text
+ GA
+
+ def initialize(project)
+ full_path = project.full_path + Gitlab::GlRepository::DESIGN.path_suffix
+ disk_path = project.disk_path + Gitlab::GlRepository::DESIGN.path_suffix
+
+ super(full_path, project, shard: project.repository_storage, disk_path: disk_path, repo_type: Gitlab::GlRepository::DESIGN)
+ end
+
+ # Override of a method called on Repository instances but sent via
+ # method_missing to Gitlab::Git::Repository where it is defined
+ def info_attributes
+ @info_attributes ||= Gitlab::Git::AttributesParser.new(MANAGED_GIT_ATTRIBUTES)
+ end
+
+ # Override of a method called on Repository instances but sent via
+ # method_missing to Gitlab::Git::Repository where it is defined
+ def attributes(path)
+ info_attributes.attributes(path)
+ end
+
+ # Override of a method called on Repository instances but sent via
+ # method_missing to Gitlab::Git::Repository where it is defined
+ def gitattribute(path, name)
+ attributes(path)[name]
+ end
+
+ # Override of a method called on Repository instances but sent via
+ # method_missing to Gitlab::Git::Repository where it is defined
+ def attributes_at(_ref = nil)
+ info_attributes
+ end
+
+ override :copy_gitattributes
+ def copy_gitattributes(_ref = nil)
+ true
+ end
+ end
+end
diff --git a/app/models/design_management/version.rb b/app/models/design_management/version.rb
new file mode 100644
index 00000000000..6be98fe3d44
--- /dev/null
+++ b/app/models/design_management/version.rb
@@ -0,0 +1,144 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class Version < ApplicationRecord
+ include Importable
+ include ShaAttribute
+ include AfterCommitQueue
+ include Gitlab::Utils::StrongMemoize
+ extend Gitlab::ExclusiveLeaseHelpers
+
+ NotSameIssue = Class.new(StandardError)
+
+ class CouldNotCreateVersion < StandardError
+ attr_reader :sha, :issue_id, :actions
+
+ def initialize(sha, issue_id, actions)
+ @sha, @issue_id, @actions = sha, issue_id, actions
+ end
+
+ def message
+ "could not create version from commit: #{sha}"
+ end
+
+ def sentry_extra_data
+ {
+ sha: sha,
+ issue_id: issue_id,
+ design_ids: actions.map { |a| a.design.id }
+ }
+ end
+ end
+
+ belongs_to :issue
+ belongs_to :author, class_name: 'User'
+ has_many :actions
+ has_many :designs,
+ through: :actions,
+ class_name: "DesignManagement::Design",
+ source: :design,
+ inverse_of: :versions
+
+ validates :designs, presence: true, unless: :importing?
+ validates :sha, presence: true
+ validates :sha, uniqueness: { case_sensitive: false, scope: :issue_id }
+ validates :author, presence: true
+ # We are not validating the issue object as it incurs an extra query to fetch
+ # the record from the DB. Instead, we rely on the foreign key constraint to
+ # ensure referential integrity.
+ validates :issue_id, presence: true, unless: :importing?
+
+ sha_attribute :sha
+
+ delegate :project, to: :issue
+
+ scope :for_designs, -> (designs) do
+ where(id: ::DesignManagement::Action.where(design_id: designs).select(:version_id)).distinct
+ end
+ scope :earlier_or_equal_to, -> (version) { where("(#{table_name}.id) <= ?", version) } # rubocop:disable GitlabSecurity/SqlInjection
+ scope :ordered, -> { order(id: :desc) }
+ scope :for_issue, -> (issue) { where(issue: issue) }
+ scope :by_sha, -> (sha) { where(sha: sha) }
+
+ # This is the one true way to create a Version.
+ #
+ # This method means you can avoid the paradox of versions being invalid without
+ # designs, and not being able to add designs without a saved version. Also this
+ # method inserts designs in bulk, rather than one by one.
+ #
+ # Before calling this method, callers must guard against concurrent
+ # modification by obtaining the lock on the design repository. See:
+ # `DesignManagement::Version.with_lock`.
+ #
+ # Parameters:
+ # - design_actions [DesignManagement::DesignAction]:
+ # the actions that have been performed in the repository.
+ # - sha [String]:
+ # the SHA of the commit that performed them
+ # - author [User]:
+ # the user who performed the commit
+ # returns [DesignManagement::Version]
+ def self.create_for_designs(design_actions, sha, author)
+ issue_id, not_uniq = design_actions.map(&:issue_id).compact.uniq
+ raise NotSameIssue, 'All designs must belong to the same issue!' if not_uniq
+
+ transaction do
+ version = new(sha: sha, issue_id: issue_id, author: author)
+ version.save(validate: false) # We need it to have an ID. Validate later when designs are present
+
+ rows = design_actions.map { |action| action.row_attrs(version) }
+
+ Gitlab::Database.bulk_insert(::DesignManagement::Action.table_name, rows)
+ version.designs.reset
+ version.validate!
+ design_actions.each(&:performed)
+
+ version
+ end
+ rescue
+ raise CouldNotCreateVersion.new(sha, issue_id, design_actions)
+ end
+
+ CREATION_TTL = 5.seconds
+ RETRY_DELAY = ->(num) { 0.2.seconds * num**2 }
+
+ def self.with_lock(project_id, repository, &block)
+ key = "with_lock:#{name}:{#{project_id}}"
+
+ in_lock(key, ttl: CREATION_TTL, retries: 5, sleep_sec: RETRY_DELAY) do |_retried|
+ repository.create_if_not_exists
+ yield
+ end
+ end
+
+ def designs_by_event
+ actions
+ .includes(:design)
+ .group_by(&:event)
+ .transform_values { |group| group.map(&:design) }
+ end
+
+ def author
+ super || (commit_author if persisted?)
+ end
+
+ def diff_refs
+ strong_memoize(:diff_refs) { commit&.diff_refs }
+ end
+
+ def reset
+ %i[diff_refs commit].each { |k| clear_memoization(k) }
+ super
+ end
+
+ private
+
+ def commit_author
+ commit&.author
+ end
+
+ def commit
+ strong_memoize(:commit) { issue.project.design_repository.commit(sha) }
+ end
+ end
+end
diff --git a/app/models/design_user_mention.rb b/app/models/design_user_mention.rb
new file mode 100644
index 00000000000..baf4db29a0f
--- /dev/null
+++ b/app/models/design_user_mention.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class DesignUserMention < UserMention
+ belongs_to :design, class_name: 'DesignManagement::Design'
+ belongs_to :note
+end
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index e3df61dadae..ff39dbb59f3 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -9,7 +9,7 @@ class DiffNote < Note
include Gitlab::Utils::StrongMemoize
def self.noteable_types
- %w(MergeRequest Commit)
+ %w(MergeRequest Commit DesignManagement::Design)
end
validates :original_position, presence: true
@@ -60,6 +60,8 @@ class DiffNote < Note
# Returns the diff file from `position`
def latest_diff_file
strong_memoize(:latest_diff_file) do
+ next if for_design?
+
position.diff_file(repository)
end
end
@@ -67,6 +69,8 @@ class DiffNote < Note
# Returns the diff file from `original_position`
def diff_file
strong_memoize(:diff_file) do
+ next if for_design?
+
enqueue_diff_file_creation_job if should_create_diff_file?
fetch_diff_file
@@ -145,7 +149,7 @@ class DiffNote < Note
end
def supported?
- for_commit? || self.noteable.has_complete_diff_refs?
+ for_commit? || for_design? || self.noteable.has_complete_diff_refs?
end
def set_line_code
@@ -184,5 +188,3 @@ class DiffNote < Note
noteable.respond_to?(:repository) ? noteable.repository : project.repository
end
end
-
-DiffNote.prepend_if_ee('::EE::DiffNote')
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 7e303bc257a..82643d8f5d6 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -49,6 +49,12 @@ class Issue < ApplicationRecord
has_many :zoom_meetings
has_many :user_mentions, class_name: "IssueUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :sent_notifications, as: :noteable
+ has_many :designs, class_name: 'DesignManagement::Design', inverse_of: :issue
+ has_many :design_versions, class_name: 'DesignManagement::Version', inverse_of: :issue do
+ def most_recent
+ ordered.first
+ end
+ end
has_one :sentry_issue
has_one :alert_management_alert, class_name: 'AlertManagement::Alert'
@@ -334,6 +340,10 @@ class Issue < ApplicationRecord
previous_changes['updated_at']&.first || updated_at
end
+ def design_collection
+ @design_collection ||= ::DesignManagement::DesignCollection.new(self)
+ end
+
private
def ensure_metrics
diff --git a/app/models/note.rb b/app/models/note.rb
index a2a711c987f..9d0cd30f5dc 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -279,6 +279,10 @@ class Note < ApplicationRecord
!for_personal_snippet?
end
+ def for_design?
+ noteable_type == DesignManagement::Design.name
+ end
+
def for_issuable?
for_issue? || for_merge_request?
end
diff --git a/app/models/project.rb b/app/models/project.rb
index 502d3391d63..bd1785bc620 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -215,6 +215,7 @@ class Project < ApplicationRecord
has_many :protected_branches
has_many :protected_tags
has_many :repository_languages, -> { order "share DESC" }
+ has_many :designs, inverse_of: :project, class_name: 'DesignManagement::Design'
has_many :project_authorizations
has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User'
@@ -791,6 +792,11 @@ class Project < ApplicationRecord
Feature.enabled?(:jira_issue_import, self, default_enabled: true)
end
+ # LFS and hashed repository storage are required for using Design Management.
+ def design_management_enabled?
+ lfs_enabled? && hashed_storage?(:repository)
+ end
+
def team
@team ||= ProjectTeam.new(self)
end
@@ -799,6 +805,12 @@ class Project < ApplicationRecord
@repository ||= Repository.new(full_path, self, shard: repository_storage, disk_path: disk_path)
end
+ def design_repository
+ strong_memoize(:design_repository) do
+ DesignManagement::Repository.new(self)
+ end
+ end
+
def cleanup
@repository = nil
end
diff --git a/app/services/design_management/design_user_notes_count_service.rb b/app/services/design_management/design_user_notes_count_service.rb
new file mode 100644
index 00000000000..e49914ea6d3
--- /dev/null
+++ b/app/services/design_management/design_user_notes_count_service.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ # Service class for counting and caching the number of unresolved
+ # notes of a Design
+ class DesignUserNotesCountService < ::BaseCountService
+ # The version of the cache format. This should be bumped whenever the
+ # underlying logic changes. This removes the need for explicitly flushing
+ # all caches.
+ VERSION = 1
+
+ def initialize(design)
+ @design = design
+ end
+
+ def relation_for_count
+ design.notes.user
+ end
+
+ def raw?
+ # Since we're storing simple integers we don't need all of the
+ # additional Marshal data Rails includes by default.
+ true
+ end
+
+ def cache_key
+ ['designs', 'notes_count', VERSION, design.id]
+ end
+
+ private
+
+ attr_reader :design
+ end
+end
diff --git a/app/services/issues/related_branches_service.rb b/app/services/issues/related_branches_service.rb
index 76af482b7ac..46076218857 100644
--- a/app/services/issues/related_branches_service.rb
+++ b/app/services/issues/related_branches_service.rb
@@ -5,11 +5,29 @@
module Issues
class RelatedBranchesService < Issues::BaseService
def execute(issue)
- branches_with_iid_of(issue) - branches_with_merge_request_for(issue)
+ branch_names = branches_with_iid_of(issue) - branches_with_merge_request_for(issue)
+ branch_names.map { |branch_name| branch_data(branch_name) }
end
private
+ def branch_data(branch_name)
+ {
+ name: branch_name,
+ pipeline_status: pipeline_status(branch_name)
+ }
+ end
+
+ def pipeline_status(branch_name)
+ branch = project.repository.find_branch(branch_name)
+ target = branch&.dereferenced_target
+
+ return unless target
+
+ pipeline = project.pipeline_for(branch_name, target.sha)
+ pipeline.detailed_status(current_user) if can?(current_user, :read_pipeline, pipeline)
+ end
+
def branches_with_merge_request_for(issue)
Issues::ReferencedMergeRequestsService
.new(project, current_user)
diff --git a/app/uploaders/design_management/design_v432x230_uploader.rb b/app/uploaders/design_management/design_v432x230_uploader.rb
new file mode 100644
index 00000000000..ba48f381bbd
--- /dev/null
+++ b/app/uploaders/design_management/design_v432x230_uploader.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ # This Uploader is used to generate and serve the smaller versions of
+ # the design files.
+ #
+ # The original (full-sized) design files are stored in Git LFS, and so
+ # have a different uploader, `LfsObjectUploader`.
+ class DesignV432x230Uploader < GitlabUploader
+ include CarrierWave::MiniMagick
+ include RecordsUploads::Concern
+ include ObjectStorage::Concern
+ prepend ObjectStorage::Extension::RecordsUploads
+
+ # We choose not to resize `image/ico` as we assume there will be no
+ # benefit in generating an 432x230 sized icon.
+ #
+ # We currently cannot resize `image/tiff`.
+ # See https://gitlab.com/gitlab-org/gitlab/issues/207740
+ #
+ # We currently choose not to resize `image/svg+xml` for security reasons.
+ # See https://gitlab.com/gitlab-org/gitlab/issues/207740#note_302766171
+ MIME_TYPE_WHITELIST = %w(image/png image/jpeg image/bmp image/gif).freeze
+
+ process resize_to_fit: [432, 230]
+
+ # Allow CarrierWave to reject files without correct mimetypes.
+ def content_type_whitelist
+ MIME_TYPE_WHITELIST
+ end
+
+ # Override `GitlabUploader` and always return false, otherwise local
+ # `LfsObject` files would be deleted.
+ # https://github.com/carrierwaveuploader/carrierwave/blob/f84672a/lib/carrierwave/uploader/cache.rb#L131-L135
+ def move_to_cache
+ false
+ end
+
+ private
+
+ def dynamic_segment
+ File.join(model.class.underscore, mounted_as.to_s, model.id.to_s)
+ end
+ end
+end
diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml
index 19fe7ba4360..f820d3f43cb 100644
--- a/app/views/projects/graphs/charts.html.haml
+++ b/app/views/projects/graphs/charts.html.haml
@@ -1,5 +1,9 @@
- page_title _("Repository Analytics")
+.mb-3
+ %h3
+ = _("Repository Analytics")
+
.repo-charts
%h4.sub-header
= _("Programming languages used in this repository")
diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml
index 69b030ed76a..0604e89be6e 100644
--- a/app/views/projects/issues/_related_branches.html.haml
+++ b/app/views/projects/issues/_related_branches.html.haml
@@ -4,11 +4,9 @@
%ul.unstyled-list.related-merge-requests
- @related_branches.each do |branch|
%li
- - target = @project.repository.find_branch(branch).dereferenced_target
- - pipeline = @project.pipeline_for(branch, target.sha) if target
- - if can?(current_user, :read_pipeline, pipeline)
+ - if branch[:pipeline_status].present?
%span.related-branch-ci-status
- = render 'ci/status/icon', status: pipeline.detailed_status(current_user)
+ = render 'ci/status/icon', status: branch[:pipeline_status]
%span.related-branch-info
%strong
- = link_to branch, project_compare_path(@project, from: @project.default_branch, to: branch), class: "ref-name"
+ = link_to branch[:name], branch[:link], class: "ref-name"