Commit 0c4a25f3 authored by Douwe Maan's avatar Douwe Maan

Merge branch 'bvl-initialize-design-repo' into 'master'

Upload versioned designs through GraphQL

See merge request gitlab-org/gitlab-ee!10462
parents 6513028e 68b0e357
...@@ -82,6 +82,7 @@ gem 'rack-cors', '~> 1.0.0', require: 'rack/cors' ...@@ -82,6 +82,7 @@ gem 'rack-cors', '~> 1.0.0', require: 'rack/cors'
# GraphQL API # GraphQL API
gem 'graphql', '~> 1.8.0' gem 'graphql', '~> 1.8.0'
gem 'graphiql-rails', '~> 1.4.10' gem 'graphiql-rails', '~> 1.4.10'
gem 'apollo_upload_server', '~> 2.0.0.beta3'
# Disable strong_params so that Mash does not respond to :permitted? # Disable strong_params so that Mash does not respond to :permitted?
gem 'hashie-forbidden_attributes' gem 'hashie-forbidden_attributes'
......
...@@ -52,6 +52,9 @@ GEM ...@@ -52,6 +52,9 @@ GEM
public_suffix (>= 2.0.2, < 4.0) public_suffix (>= 2.0.2, < 4.0)
aes_key_wrap (1.0.1) aes_key_wrap (1.0.1)
akismet (2.0.0) akismet (2.0.0)
apollo_upload_server (2.0.0.beta.3)
graphql (>= 1.8)
rails (>= 4.2)
arel (8.0.0) arel (8.0.0)
asana (0.8.1) asana (0.8.1)
faraday (~> 0.9) faraday (~> 0.9)
...@@ -1021,6 +1024,7 @@ DEPENDENCIES ...@@ -1021,6 +1024,7 @@ DEPENDENCIES
acts-as-taggable-on (~> 6.0) acts-as-taggable-on (~> 6.0)
addressable (~> 2.5.2) addressable (~> 2.5.2)
akismet (~> 2.0) akismet (~> 2.0)
apollo_upload_server (~> 2.0.0.beta3)
asana (~> 0.8.1) asana (~> 0.8.1)
asciidoctor (~> 1.5.8) asciidoctor (~> 1.5.8)
asciidoctor-plantuml (= 0.0.8) asciidoctor-plantuml (= 0.0.8)
......
...@@ -44,6 +44,12 @@ module Resolvers ...@@ -44,6 +44,12 @@ module Resolvers
alias_method :project, :object alias_method :project, :object
def resolve(**args) def resolve(**args)
# The project could have been loaded in batch by `BatchLoader`.
# At this point we need the `id` of the project to query for issues, so
# make sure it's loaded and not `nil` before continueing.
project.sync if project.respond_to?(:sync)
return Issue.none if project.nil?
# Will need to be be made group & namespace aware with # Will need to be be made group & namespace aware with
# https://gitlab.com/gitlab-org/gitlab-ce/issues/54520 # https://gitlab.com/gitlab-org/gitlab-ce/issues/54520
args[:project_id] = project.id args[:project_id] = project.id
......
...@@ -43,3 +43,5 @@ module Types ...@@ -43,3 +43,5 @@ module Types
field :updated_at, Types::TimeType, null: false field :updated_at, Types::TimeType, null: false
end end
end end
Types::IssueType.prepend(::EE::Types::IssueType)
...@@ -9,3 +9,5 @@ module Types ...@@ -9,3 +9,5 @@ module Types
mount_mutation Mutations::MergeRequests::SetWip mount_mutation Mutations::MergeRequests::SetWip
end end
end end
::Types::MutationType.prepend(::EE::Types::MutationType)
...@@ -1065,6 +1065,19 @@ class Repository ...@@ -1065,6 +1065,19 @@ class Repository
blob.data blob.data
end end
def create_if_not_exists
return if exists?
raw.create_repository
after_create
end
def blobs_metadata(paths, ref = 'HEAD')
references = Array.wrap(paths).map { |path| [ref, path] }
Gitlab::Git::Blob.batch_metadata(raw, references).map { |raw_blob| Blob.decorate(raw_blob) }
end
private private
# TODO Generice finder, later split this on finders by Ref or Oid # TODO Generice finder, later split this on finders by Ref or Oid
......
...@@ -21,8 +21,10 @@ class PostReceive ...@@ -21,8 +21,10 @@ class PostReceive
if repo_type.wiki? if repo_type.wiki?
process_wiki_changes(post_received) process_wiki_changes(post_received)
else elsif repo_type.project?
process_project_changes(post_received) process_project_changes(post_received)
else
# Other repos don't have hooks for now
end end
end end
......
# frozen_string_literal: true
module EE
module Types
module IssueType
extend ActiveSupport::Concern
prepended do
field :designs, ::Types::DesignManagement::DesignCollectionType,
null: true, method: :design_collection
end
end
end
end
# frozen_string_literal: true
module EE
module Types
module MutationType
extend ActiveSupport::Concern
prepended do
mount_mutation ::Mutations::DesignManagement::Upload
end
end
end
end
# frozen_string_literal: true
module Mutations
module DesignManagement
class Base < ::Mutations::BaseMutation
include Gitlab::Graphql::Authorize::AuthorizeResource
include Mutations::ResolvesProject
argument :project_path, GraphQL::ID_TYPE,
required: true,
description: "The project where the issue is to upload designs for"
argument :iid, GraphQL::ID_TYPE,
required: true,
description: "The iid of the issue to modify designs for"
field :designs, [Types::DesignManagement::DesignType],
null: false,
description: "The designs that were updated by the mutation"
private
def find_object(project_path:, iid:)
project = resolve_project(full_path: project_path)
Resolvers::IssuesResolver.single.new(object: project, context: context)
.resolve(iid: iid)
end
end
end
end
# frozen_string_literal: true
module Mutations
module DesignManagement
class Upload < Base
graphql_name "DesignManagementUpload"
argument :files, [ApolloUploadServer::Upload],
required: true,
description: "The files to upload"
authorize :create_design
def resolve(project_path:, iid:, files:)
issue = authorized_find!(project_path: project_path, iid: iid)
project = issue.project
result = ::DesignManagement::SaveDesignsService.new(project, current_user, issue: issue, files: files)
.execute
{
designs: Array.wrap(result[:designs]),
errors: Array.wrap(result[:message])
}
end
end
end
end
# frozen_string_literal: true
module Resolvers
module DesignManagement
class VersionResolver < BaseResolver
type Types::DesignManagement::VersionType.connection_type, null: false
alias_method :design_or_collection, :object
def resolve(*_args)
unless Ability.allowed?(context[:current_user], :read_design, design_or_collection)
return ::DesignManagement::Version.none
end
design_or_collection.versions.ordered
end
end
end
end
# frozen_string_literal: true
module Types
module DesignManagement
class DesignCollectionType < BaseObject
graphql_name 'DesignCollection'
authorize :read_design
field :project, Types::ProjectType, null: false
field :issue, Types::IssueType, null: false
field :designs,
Types::DesignManagement::DesignType.connection_type,
null: false,
description: "All visible designs for this collection"
# TODO: allow getting a single design by filename
# TODO: when we allow hiding designs, we will also expose a relation
# exposing all designs
field :versions,
Types::DesignManagement::VersionType.connection_type,
resolver: Resolvers::DesignManagement::VersionResolver,
description: "All versions related to all designs ordered newest first"
end
end
end
# frozen_string_literal: true
module Types
module DesignManagement
class DesignType < BaseObject
graphql_name 'Design'
authorize :read_design
field :id, GraphQL::ID_TYPE, null: false
field :project, Types::ProjectType, null: false
field :issue, Types::IssueType, null: false
field :filename, GraphQL::STRING_TYPE, null: false
field :versions,
Types::DesignManagement::VersionType.connection_type,
resolver: Resolvers::DesignManagement::VersionResolver,
description: "All versions related to this design ordered newest first"
end
end
end
# frozen_string_literal: true
module Types
module DesignManagement
class VersionType < BaseObject
# Just `Version` might be a bit to general to expose globally so adding
# a `Design` prefix to specify the class exposed in GraphQL
graphql_name 'DesignVersion'
authorize :read_design
field :sha, GraphQL::ID_TYPE, null: false
field :designs,
Types::DesignManagement::DesignType.connection_type,
null: false,
description: "All designs that were changed in this version"
end
end
end
# frozen_string_literal: true # frozen_string_literal: true
module DesignManagement module DesignManagement
def self.designs_directory
'designs'
end
def self.table_name_prefix def self.table_name_prefix
'design_management_' 'design_management_'
end end
......
...@@ -2,11 +2,35 @@ ...@@ -2,11 +2,35 @@
module DesignManagement module DesignManagement
class Design < ApplicationRecord class Design < ApplicationRecord
include Gitlab::FileTypeDetection
belongs_to :project belongs_to :project
belongs_to :issue belongs_to :issue
has_and_belongs_to_many :versions, class_name: 'DesignManagement::Version', inverse_of: :designs
has_many :design_versions
has_many :versions, through: :design_versions, class_name: 'DesignManagement::Version', inverse_of: :designs
validates :project, :issue, :filename, presence: true validates :project, :issue, :filename, presence: true
validates :filename, uniqueness: { scope: :issue_id } validates :filename, uniqueness: { scope: :issue_id }
validate :validate_file_is_image
def new_design?
versions.none?
end
def full_path
@full_path ||= File.join(DesignManagement.designs_directory, "issue-#{issue.iid}", filename)
end
private
def validate_file_is_image
unless image?
message = _("Only these extensions are supported: %{extension_list}") % {
extension_list: Gitlab::FileTypeDetection::IMAGE_EXT.join(", ")
}
errors.add(:filename, message)
end
end
end end
end end
# 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
@repository ||= ::DesignManagement::Repository.new(project)
end
end
end
# frozen_string_literal: true
module DesignManagement
class DesignVersion < ApplicationRecord
self.table_name = "#{DesignManagement.table_name_prefix}designs_versions"
belongs_to :design, class_name: "DesignManagement::Design", inverse_of: :design_versions
belongs_to :version, class_name: "DesignManagement::Version", inverse_of: :design_versions
end
end
# frozen_string_literal: true
module DesignManagement
class Repository < ::Repository
def initialize(project)
full_path = project.full_path + EE::Gitlab::GlRepository::DESIGN.path_suffix
disk_path = project.disk_path + EE::Gitlab::GlRepository::DESIGN.path_suffix
super(full_path, project, disk_path: disk_path, repo_type: EE::Gitlab::GlRepository::DESIGN)
end
end
end
...@@ -4,9 +4,12 @@ module DesignManagement ...@@ -4,9 +4,12 @@ module DesignManagement
class Version < ApplicationRecord class Version < ApplicationRecord
include ShaAttribute include ShaAttribute
has_and_belongs_to_many :designs, has_many :design_versions
class_name: "DesignManagement::Design", has_many :designs,
inverse_of: :versions through: :design_versions,
class_name: "DesignManagement::Design",
source: :design,
inverse_of: :versions
# This is a polymorphic association, so we can't count on FK's to delete the # This is a polymorphic association, so we can't count on FK's to delete the
# data # data
...@@ -16,5 +19,27 @@ module DesignManagement ...@@ -16,5 +19,27 @@ module DesignManagement
validates :sha, uniqueness: { case_sensitive: false } validates :sha, uniqueness: { case_sensitive: false }
sha_attribute :sha sha_attribute :sha
scope :for_designs, -> (designs) do
where(id: DesignVersion.where(design_id: designs).select(:version_id)).distinct
end
scope :ordered, -> { order(id: :desc) }
def self.create_for_designs(designs, sha)
version = safe_find_or_create_by!(sha: sha)
rows = designs.map do |design|
{ design_id: design.id, version_id: version.id }
end
Gitlab::Database.bulk_insert(DesignVersion.table_name, rows)
version
end
def issue
designs.take.issue
end
end end
end end
...@@ -18,6 +18,7 @@ module EE ...@@ -18,6 +18,7 @@ module EE
has_one :epic_issue has_one :epic_issue
has_one :epic, through: :epic_issue has_one :epic, through: :epic_issue
has_many :designs, class_name: "DesignManagement::Design", inverse_of: :issue
validates :weight, allow_nil: true, numericality: { greater_than_or_equal_to: 0 } validates :weight, allow_nil: true, numericality: { greater_than_or_equal_to: 0 }
end end
...@@ -91,6 +92,10 @@ module EE ...@@ -91,6 +92,10 @@ module EE
@group ||= project.group @group ||= project.group
end end
def design_collection
@design_collection ||= ::DesignManagement::DesignCollection.new(self)
end
class_methods do class_methods do
# override # override
def sort_by_attribute(method, excluded_labels: []) def sort_by_attribute(method, excluded_labels: [])
......
# frozen_string_literal: true
module DesignManagement
class DesignCollectionPolicy < DesignPolicy
# Delegates everything to the `issue` just like the `DesignPolicy`
end
end
# frozen_string_literal: true
module DesignManagement
class VersionPolicy < ::BasePolicy
# The IssuePolicy will delegate to the ProjectPolicy
delegate { @subject.issue }
end
end
...@@ -74,10 +74,6 @@ module EE ...@@ -74,10 +74,6 @@ module EE
rule { admin }.enable :change_repository_storage rule { admin }.enable :change_repository_storage
rule { can?(:public_access) }.policy do
enable :read_design
end
rule { support_bot }.enable :guest_access rule { support_bot }.enable :guest_access
rule { support_bot & ~service_desk_enabled }.policy do rule { support_bot & ~service_desk_enabled }.policy do
prevent :create_note prevent :create_note
...@@ -96,7 +92,10 @@ module EE ...@@ -96,7 +92,10 @@ module EE
prevent :admin_issue_link prevent :admin_issue_link
end end
rule { can?(:read_issue) }.enable :read_issue_link rule { can?(:read_issue) }.policy do
enable :read_issue_link
enable :read_design
end
rule { can?(:reporter_access) }.policy do rule { can?(:reporter_access) }.policy do
enable :admin_board enable :admin_board
......
# frozen_string_literal: true
module DesignManagement
class SaveDesignsService < ::BaseService
MAX_FILES = 10
def initialize(project, user, params = {})
super
@issue = params.fetch(:issue)
@files = params.fetch(:files)
end
def execute
return error("Not allowed!") unless can_create_designs?
return error("Only #{MAX_FILES} files are allowed simultaneously") if files.size > MAX_FILES
save_designs!
success({ designs: updated_designs })
rescue Gitlab::Git::BaseError, ActiveRecord::RecordInvalid => e
error(e.message)
end
private
attr_reader :files, :issue
def save_designs!
commit_sha = create_and_commit_designs!
DesignManagement::Version.create_for_designs(updated_designs, commit_sha)
end
def create_and_commit_designs!
repository.create_if_not_exists
repository_actions = files.map do |file|
design = collection.find_or_create_design!(filename: file.original_filename)
build_repository_action(file, design)
end
repository.multi_action(current_user,
branch_name: target_branch,
message: commit_message,
actions: repository_actions)
end
def build_repository_action(file, design)
{
action: new_file?(design) ? :create : :update,
file_path: design.full_path,
content: file.to_io
}
end
def collection
issue.design_collection
end
def repository
collection.repository
end
def project
issue.project
end
def target_branch
repository.root_ref || "master"
end
def commit_message
<<~MSG
Updated #{files.size} #{'designs'.pluralize(files.size)}
#{formatted_file_list}
MSG
end
def formatted_file_list
filenames.map { |name| "- #{name}" }.join("\n")
end
def filenames
@filenames ||= files.map(&:original_filename)
end
def updated_designs
@updated_designs ||= collection.designs.select { |design| filenames.include?(design.filename) }
end
def can_create_designs?
Ability.allowed?(current_user, :create_design, issue)
end
def new_file?(design)
design.new_design? && existing_metadata.none? { |blob| blob.path == design.full_path }
end
def existing_metadata
@existing_metadata ||= begin
paths = updated_designs.map(&:full_path)
repository.blobs_metadata(paths)
end
end
end
end
# frozen_string_literal: true
module EE
module Gitlab
module GlRepository
extend ActiveSupport::Concern
DESIGN = ::Gitlab::GlRepository::RepoType.new(
name: :design,
access_checker_class: ::Gitlab::GitAccessDesign,
repository_accessor: -> (project) { ::DesignManagement::Repository.new(project) }
)
EE_TYPES = {
DESIGN.name.to_s => DESIGN
}.freeze
class_methods do
def types
super.merge(EE_TYPES)
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
class GitAccessDesign < GitAccess
def check(cmd, _changes)
check_protocol!
check_can_create_design!
success_result(cmd)
end
private
def check_protocol!
if protocol != 'web'
raise ::Gitlab::GitAccess::UnauthorizedError, "Designs are only accessible using the web interface"
end
end
def check_can_create_design!
unless user&.can?(:create_design, project)
raise ::Gitlab::GitAccess::UnauthorizedError, "You are not allowed to manage designs of this project"
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Mutations::DesignManagement::Upload do
let(:issue) { create(:issue) }
let(:user) { issue.author }
let(:project) { issue.project }
subject(:mutation) do
described_class.new(object: nil, context: { current_user: user })
end
describe "#resolve" do
let(:files) { [fixture_file_upload('spec/fixtures/dk.png')] }
subject(:resolve) do
mutation.resolve(project_path: project.full_path, iid: issue.iid, files: files)
end
shared_examples "resource not available" do
it "raises an error" do
expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context "when the feature is not available" do
before do
stub_licensed_features(design_management: false)
end
it_behaves_like "resource not available"
end
context "when the feature is available" do
before do
stub_licensed_features(design_management: true)
end
context "when the user is not allowed to upload designs" do
let(:user) { create(:user) }
it_behaves_like "resource not available"
end
context "a valid design" do
it "returns the updated designs" do
expect(resolve[:errors]).to eq []
expect(resolve[:designs].map(&:filename)).to contain_exactly("dk.png")
end
end
context "context when passing an invalid project" do
let(:project) { build(:project) }
it_behaves_like "resource not available"
end
context "context when passing an invalid issue" do
let(:issue) { build(:issue) }
it_behaves_like "resource not available"
end
context "when creating designs causes errors" do
before do
fake_service = double(::DesignManagement::SaveDesignsService)
allow(fake_service).to receive(:execute).and_return(status: :error, message: "Something failed")
allow(::DesignManagement::SaveDesignsService).to receive(:new).and_return(fake_service)
end
it "wraps the errors" do
expect(resolve[:errors]).to eq(["Something failed"])
expect(resolve[:designs]).to eq([])
end
end
end
end
end
require 'spec_helper'
describe Resolvers::DesignManagement::VersionResolver do
include GraphqlHelpers
before do
stub_licensed_features(design_management: true)
end
describe "#resolve" do
set(:issue) { create(:issue) }
set(:project) { issue.project }
set(:first_version) { create(:design_version) }
set(:first_design) { create(:design, issue: issue, versions: [first_version]) }
let(:current_user) { create(:user) }
before do
project.add_developer(current_user)
end
context "for a design collection" do
let(:collection) { DesignManagement::DesignCollection.new(issue) }
it "returns the ordered versions" do
second_version = create(:design_version)
create(:design, issue: issue, versions: [second_version])
expect(resolve_versions(collection)).to eq([second_version, first_version])
end
end
context "for a design" do
it "returns the versions" do
expect(resolve_versions(first_design)).to eq([first_version])
end
end
context "when the is anonymous" do
let(:current_user) { nil }
it "returns nothing" do
expect(resolve_versions(first_design)).to be_empty
end
end
context "when the is cannot see designs" do
it "returns nothing" do
expect(resolve_versions(first_design, {}, current_user: create(:user))).to be_empty
end
end
end
def resolve_versions(obj, args = {}, context = { current_user: current_user })
resolve(described_class, obj: obj, args: args, ctx: context)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['DesignCollection'] do
it { expect(described_class).to require_graphql_authorizations(:read_design) }
it { expect(described_class).to have_graphql_fields(:project, :issue, :designs, :versions) }
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['Design'] do
it { expect(described_class).to require_graphql_authorizations(:read_design) }
it { expect(described_class).to have_graphql_fields(:id, :project, :issue, :filename, :versions) }
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['DesignVersion'] do
it { expect(described_class).to require_graphql_authorizations(:read_design) }
it { expect(described_class).to have_graphql_fields(:sha, :designs) }
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['Issue'] do
it { expect(described_class).to have_graphql_field(:designs) }
end
# frozen_string_literal: true
require 'spec_helper'
describe ::EE::Gitlab::GlRepository do
describe "DESIGN" do
it "uses the design access checker" do
expect(described_class::DESIGN.access_checker_class).to eq(::Gitlab::GitAccessDesign)
end
it "builds a design repository" do
expect(described_class::DESIGN.repository_accessor.call(create(:project)))
.to be_a(::DesignManagement::Repository)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::GitAccessDesign do
set(:project) { create(:project) }
set(:user) { project.owner }
let(:protocol) { 'web' }
subject(:access) do
described_class.new(user, project, protocol, authentication_abilities: [:read_project, :download_code, :push_code])
end
describe "#check!" do
subject { access.check('git-receive-pack', ::Gitlab::GitAccess::ANY) }
before do
stub_licensed_features(design_management: true)
stub_feature_flags(design_managment: true)
end
context "when the user is allowed to manage designs" do
it { is_expected.to be_a(::Gitlab::GitAccessResult::Success) }
end
context "when the user is not allowed to manage designs" do
set(:user) { create(:user) }
it "raises an error " do
expect { subject }.to raise_error(::Gitlab::GitAccess::UnauthorizedError)
end
end
context "when the protocol is not web" do
let(:protocol) { 'https' }
it "raises an error " do
expect { subject }.to raise_error(::Gitlab::GitAccess::UnauthorizedError)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::GlRepository do
describe '.parse' do
set(:project) { create(:project, :repository) }
it 'parses a design gl_repository' do
expect(described_class.parse("design-#{project.id}")).to eq([project, EE::Gitlab::GlRepository::DESIGN])
end
end
end
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
issues: issues:
- epic_issue - epic_issue
- epic - epic
- designs
milestone: milestone:
- boards - boards
merge_requests: merge_requests:
......
# frozen_string_literal: true
require 'spec_helper'
describe DesignManagement::DesignCollection do
let(:issue) { create(:issue) }
subject(:collection) { described_class.new(issue) }
describe ".find_or_create_design!" do
it "finds an existing design" do
design = create(:design, issue: issue, filename: 'world.png')
expect(collection.find_or_create_design!(filename: 'world.png')).to eq(design)
end
it "creates a new design if one didn't exist" do
expect(issue.designs.size).to eq(0)
new_design = collection.find_or_create_design!(filename: 'world.png')
expect(issue.designs.size).to eq(1)
expect(new_design.filename).to eq('world.png')
expect(new_design.issue).to eq(issue)
end
it "only queries the designs once" do
create(:design, issue: issue, filename: 'hello.png')
create(:design, issue: issue, filename: 'world.jpg')
expect do
collection.find_or_create_design!(filename: 'hello.png')
collection.find_or_create_design!(filename: 'world.jpg')
end.not_to exceed_query_limit(1)
end
end
describe "#versions" do
it "includes versions for all designs" do
version_1 = create(:design_version)
version_2 = create(:design_version)
other_version = create(:design_version)
create(:design, issue: issue, versions: [version_1])
create(:design, issue: issue, versions: [version_2])
create(:design, versions: [other_version])
expect(collection.versions).to contain_exactly(version_1, version_2)
end
end
describe "#repository" do
it "builds a design repository" do
expect(collection.repository).to be_a(DesignManagement::Repository)
end
end
end
...@@ -6,7 +6,8 @@ describe DesignManagement::Design do ...@@ -6,7 +6,8 @@ describe DesignManagement::Design do
describe 'relations' do describe 'relations' do
it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:issue) } it { is_expected.to belong_to(:issue) }
it { is_expected.to have_and_belong_to_many(:versions) } it { is_expected.to have_many(:design_versions) }
it { is_expected.to have_many(:versions) }
end end
describe 'validations' do describe 'validations' do
...@@ -17,5 +18,47 @@ describe DesignManagement::Design do ...@@ -17,5 +18,47 @@ describe DesignManagement::Design do
it { is_expected.to validate_presence_of(:issue) } it { is_expected.to validate_presence_of(:issue) }
it { is_expected.to validate_presence_of(:filename) } it { is_expected.to validate_presence_of(:filename) }
it { is_expected.to validate_uniqueness_of(:filename).scoped_to(:issue_id) } it { is_expected.to validate_uniqueness_of(:filename).scoped_to(:issue_id) }
it "validates that the file is an image" do
design.filename = "thing.txt"
expect(design).not_to be_valid
expect(design.errors[:filename].first)
.to match /Only these extensions are supported/
end
end
describe "#new_design?" do
set(:versions) { create(:design_version) }
set(:design) { create(:design, versions: [versions]) }
it "is false when there are versions" do
expect(design.new_design?).to be_falsy
end
it "is true when there are no versions" do
expect(build(:design).new_design?).to be_truthy
end
it "does not cause extra queries when versions are loaded" do
design.versions.map(&:id)
expect { design.new_design? }.not_to exceed_query_limit(0)
end
it "causes a single query when there versions are not loaded" do
design.reload
expect { design.new_design? }.not_to exceed_query_limit(1)
end
end
describe "#full_path" do
it "builds the full path for a design" do
design = build(:design, filename: "hello.jpg")
expected_path = "#{DesignManagement.designs_directory}/issue-#{design.issue.iid}/hello.jpg"
expect(design.full_path).to eq(expected_path)
end
end end
end end
# frozen_string_literal: true
require 'rails_helper'
describe DesignManagement::DesignVersion do
describe 'relations' do
it { is_expected.to belong_to(:design) }
it { is_expected.to belong_to(:version) }
end
end
...@@ -3,7 +3,8 @@ require 'rails_helper' ...@@ -3,7 +3,8 @@ require 'rails_helper'
describe DesignManagement::Version do describe DesignManagement::Version do
describe 'relations' do describe 'relations' do
it { is_expected.to have_and_belong_to_many(:designs) } it { is_expected.to have_many(:design_versions) }
it { is_expected.to have_many(:designs).through(:design_versions) }
it 'constrains the designs relation correctly' do it 'constrains the designs relation correctly' do
design = create(:design) design = create(:design)
...@@ -32,4 +33,38 @@ describe DesignManagement::Version do ...@@ -32,4 +33,38 @@ describe DesignManagement::Version do
it { is_expected.to validate_presence_of(:sha) } it { is_expected.to validate_presence_of(:sha) }
it { is_expected.to validate_uniqueness_of(:sha).case_insensitive } it { is_expected.to validate_uniqueness_of(:sha).case_insensitive }
end end
describe "scopes" do
describe ".for_designs" do
it "only returns versions related to the specified designs" do
version_1 = create(:design_version)
version_2 = create(:design_version)
_other_version = create(:design_version)
designs = [create(:design, versions: [version_1]),
create(:design, versions: [version_2])]
expect(described_class.for_designs(designs))
.to contain_exactly(version_1, version_2)
end
end
end
describe ".bulk_create" do
it "creates a version and links it to multiple designs" do
designs = create_list(:design, 2)
version = described_class.create_for_designs(designs, "abc")
expect(version.designs).to contain_exactly(*designs)
end
end
describe "#issue" do
it "gets the issue for the linked design" do
version = create(:design_version)
design = create(:design, versions: [version])
expect(version.issue).to eq(design.issue)
end
end
end end
...@@ -28,6 +28,10 @@ describe Issue do ...@@ -28,6 +28,10 @@ describe Issue do
end end
end end
describe "relations" do
it { is_expected.to have_many(:designs) }
end
it_behaves_like 'an editable mentionable with EE-specific mentions' do it_behaves_like 'an editable mentionable with EE-specific mentions' do
subject { create(:issue, project: create(:project, :repository)) } subject { create(:issue, project: create(:project, :repository)) }
...@@ -354,4 +358,14 @@ describe Issue do ...@@ -354,4 +358,14 @@ describe Issue do
end end
end end
end end
describe "#design_collection" do
it "returns a design collection" do
issue = build(:issue)
collection = issue.design_collection
expect(collection).to be_a(DesignManagement::DesignCollection)
expect(collection.issue).to eq(issue)
end
end
end end
...@@ -118,14 +118,15 @@ describe DesignManagement::DesignPolicy do ...@@ -118,14 +118,15 @@ describe DesignManagement::DesignPolicy do
it_behaves_like "design abilities available for members" it_behaves_like "design abilities available for members"
context "for guests" do context "for guests in private projects" do
let(:project) { create(:project, :private) }
let(:current_user) { guest } let(:current_user) { guest }
it { is_expected.to be_allowed(*guest_design_abilities) } it { is_expected.to be_allowed(*guest_design_abilities) }
it { is_expected.to be_disallowed(*developer_design_abilities) } it { is_expected.to be_disallowed(*developer_design_abilities) }
end end
context "for anonymous users" do context "for anonymous users in public projects" do
let(:current_user) { nil } let(:current_user) { nil }
it { is_expected.to be_allowed(*guest_design_abilities) } it { is_expected.to be_allowed(*guest_design_abilities) }
......
# frozen_string_literal: true
require "spec_helper"
describe "uploading designs" do
include GraphqlHelpers
let(:current_user) { create(:user) }
let(:issue) { create(:issue) }
let(:project) { issue.project }
let(:files) { [fixture_file_upload('spec/fixtures/dk.png')] }
let(:variables) { {} }
let(:mutation) do
input = {
project_path: project.full_path,
iid: issue.iid,
files: files
}.merge(variables)
graphql_mutation(:design_management_upload, input)
end
let(:mutation_response) { graphql_mutation_response(:design_management_upload) }
before do
stub_feature_flags(design_mangement: true)
stub_licensed_features(design_management: true)
project.add_developer(current_user)
end
it "returns an error if the user is not allowed to upload designs" do
post_graphql_mutation(mutation, current_user: create(:user))
expect(graphql_errors).not_to be_empty
end
it "responds with the created designs" do
post_graphql_mutation(mutation, current_user: current_user)
designs = mutation_response["designs"]
expect(designs.size).to eq(1)
expect(designs.first["filename"]).to eq("dk.png")
end
context "when the issue does not exist" do
let(:variables) { { iid: "123" } }
it "returns an error" do
post_graphql_mutation(mutation, current_user: create(:user))
expect(graphql_errors).not_to be_empty
end
end
context "when saving the designs raises an error" do
it "responds with errors" do
expect_next_instance_of(::DesignManagement::SaveDesignsService) do |service|
expect(service).to receive(:execute).and_return({ status: :error, message: "Something went wrong" })
end
post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response["errors"].first).to eq("Something went wrong")
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe "Getting designs related to an issue" do
include GraphqlHelpers
set(:design) { create(:design) }
set(:current_user) { design.project.owner }
let(:query) do
design_node = <<~NODE
designs {
edges {
node {
filename
versions {
edges {
node {
sha
}
}
}
}
}
}
NODE
graphql_query_for(
"project",
{ "fullPath" => design.project.full_path },
query_graphql_field(
"issue",
{ iid: design.issue.iid },
query_graphql_field(
"designs", {}, design_node
)
)
)
end
let(:design_collection) do
graphql_data["project"]["issue"]["designs"]
end
let(:design_response) do
design_collection["designs"]["edges"].first["node"]
end
context "when the feature is not available" do
before do
stub_licensed_features(design_management: false)
stub_feature_flags(design_managment: false)
end
it_behaves_like "a working graphql query" do
before do
post_graphql(query, current_user: current_user)
end
end
it "returns no designs" do
post_graphql(query, current_user: current_user)
expect(design_collection).to be_nil
end
end
context "when the feature is available" do
before do
stub_licensed_features(design_management: true)
stub_feature_flags(deesign_managment: true)
end
it "returns the design filename" do
post_graphql(query, current_user: current_user)
expect(design_response["filename"]).to eq(design.filename)
end
context "with versions" do
let(:version) { create(:design_version) }
before do
design.versions << version
end
it "includes the version" do
post_graphql(query, current_user: current_user)
version_sha = design_response["versions"]["edges"].first["node"]["sha"]
expect(version_sha).to eq(version.sha)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe API::Internal do
describe "POST /internal/allowed" do
context "for design repositories" do
set(:user) { create(:user) }
set(:project) { create(:project) }
set(:key) { create(:key, user: user) }
let(:secret_token) { Gitlab::Shell.secret_token }
let(:gl_repository) { EE::Gitlab::GlRepository::DESIGN.identifier_for_subject(project) }
it "does not allow access" do
post(api("/internal/allowed"),
params: {
key_id: key.id,
project: project.full_path,
gl_repository: gl_repository,
secret_token: secret_token,
protocol: 'ssh'
})
expect(response).to have_gitlab_http_status(401)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe DesignManagement::SaveDesignsService do
let(:issue) { create(:issue, project: project) }
let(:project) { create(:project) }
let(:user) { project.owner }
let(:files) { [fixture_file_upload('spec/fixtures/rails_sample.jpg')] }
let(:design_repository) { EE::Gitlab::GlRepository::DESIGN.repository_accessor.call(project) }
let(:design_collection) { DesignManagement::DesignCollection.new(issue) }
subject(:service) { described_class.new(project, user, issue: issue, files: files) }
shared_examples 'a service error' do
it 'returns an error', :aggregate_failures do
expect(service.execute).to match(a_hash_including(status: :error))
end
end
describe "#execute" do
context "when the feature is not available" do
before do
stub_licensed_features(design_management: false)
end
it_behaves_like "a service error"
end
context "when the feature is available" do
before do
stub_licensed_features(design_management: true)
end
it "creates a design repository when it didn't exist" do
repository_exists = -> do
# Expire the memoized value as the service creates it's own instance
design_repository.expire_exists_cache
design_repository.exists?
end
expect { service.execute }.to change { repository_exists.call }.from(false).to(true)
end
it "creates a nice commit in the repository" do
service.execute
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.jpg')
end
it 'creates a design & a version for the filename if it did not exist' do
expect(issue.designs.size).to eq(0)
updated_designs = service.execute[:designs]
expect(updated_designs.size).to eq(1)
expect(updated_designs.first.versions.size).to eq(1)
end
context 'when a design already exists' do
before do
# This makes sure the file is created in the repository.
# otherwise we'd have a database & repository that are not in sync.
service.execute
end
it 'creates a new version for the existing design and updates the file' do
expect(issue.designs.size).to eq(1)
expect(DesignManagement::Version.for_designs(issue.designs).size).to eq(1)
updated_designs = service.execute[:designs]
expect(updated_designs.size).to eq(1)
expect(updated_designs.first.versions.size).to eq(2)
end
context 'when uploading a new design' do
it 'does not link the new version to the existing design' do
existing_design = issue.designs.first
updated_designs = described_class.new(project, user, issue: issue, files: [fixture_file_upload('spec/fixtures/dk.png')])
.execute[:designs]
expect(existing_design.versions.reload.size).to eq(1)
expect(updated_designs.size).to eq(1)
expect(updated_designs.first.versions.size).to eq(1)
end
end
end
context 'when uploading multiple files' do
let(:files) do
[
fixture_file_upload('spec/fixtures/rails_sample.jpg'),
fixture_file_upload('spec/fixtures/dk.png')
]
end
it 'creates 2 designs with a single version' do
expect { service.execute }.to change { issue.designs.count }.from(0).to(2)
expect(DesignManagement::Version.for_designs(issue.designs).size).to eq(1)
end
it 'creates a single commit' do
commit_count = -> do
design_repository.expire_all_method_caches
design_repository.commit_count
end
expect { service.execute }.to change { commit_count.call }.by(1)
end
it 'only does 3 gitaly calls', :request_store do
# Some unrelated calls that are usually cached or happen only once
service.__send__(:repository).create_if_not_exists
service.__send__(:repository).has_visible_content?
# An exists?, a check for existing blobs, default branch, and the creation of commits
expect { service.execute }.to change { Gitlab::GitalyClient.get_request_count }.by(4)
end
context 'when uploading too many files' do
let(:files) { Array.new(11) { fixture_file_upload('spec/fixtures/dk.png') } }
it "returns the correct error" do
expect(service.execute[:message]).to match(/only \d+ files are allowed simultaneously/i)
end
end
end
context 'when the user is not allowed to upload designs' do
let(:user) { create(:user) }
it_behaves_like "a service error"
end
context 'when creating the commit fails' do
before do
expect(service).to receive(:save_designs!).and_raise(Gitlab::Git::BaseError)
end
it_behaves_like "a service error"
end
context 'when creating the versions fails' do
before do
expect(service).to receive(:save_designs!).and_raise(ActiveRecord::RecordInvalid)
end
it_behaves_like "a service error"
end
context "when a design already existed in the repo but we didn't know about it in the database" do
before do
path = File.join(build(:design, issue: issue, filename: "rails_sample.jpg").full_path)
design_repository.create_if_not_exists
design_repository.create_file(user, path, "something fake",
branch_name: "master",
message: "Somehow created without being tracked in db")
end
it "creates the design and a new version for it" do
updated_designs = service.execute[:designs]
expect(updated_designs.first.filename).to eq("rails_sample.jpg")
expect(updated_designs.first.versions.size).to eq(1)
end
end
end
end
end
...@@ -127,4 +127,17 @@ describe PostReceive do ...@@ -127,4 +127,17 @@ describe PostReceive do
end end
end end
end end
describe 'processing design changes' do
let(:gl_repository) { "design-#{project.id}" }
it 'does not do anything' do
worker = described_class.new
expect(worker).not_to receive(:process_wiki_changes)
expect(worker).not_to receive(:process_project_changes)
described_class.new.perform(gl_repository, key_id, base64_changes)
end
end
end end
...@@ -118,6 +118,12 @@ module Gitlab ...@@ -118,6 +118,12 @@ module Gitlab
gitaly_repository_client.exists? gitaly_repository_client.exists?
end end
def create_repository
wrapped_gitaly_errors do
gitaly_repository_client.create_repository
end
end
# Returns an Array of branch names # Returns an Array of branch names
# sorted by name ASC # sorted by name ASC
def branch_names def branch_names
......
...@@ -85,7 +85,7 @@ module Gitlab ...@@ -85,7 +85,7 @@ module Gitlab
check_push_access! check_push_access!
end end
::Gitlab::GitAccessResult::Success.new(console_messages: check_for_console_messages(cmd)) success_result(cmd)
end end
def guest_can_download_code? def guest_can_download_code?
...@@ -365,6 +365,10 @@ module Gitlab ...@@ -365,6 +365,10 @@ module Gitlab
protected protected
def success_result(cmd)
::Gitlab::GitAccessResult::Success.new(console_messages: check_for_console_messages(cmd))
end
def changes_list def changes_list
@changes_list ||= Gitlab::ChangesList.new(changes == ANY ? [] : changes) @changes_list ||= Gitlab::ChangesList.new(changes == ANY ? [] : changes)
end end
......
...@@ -41,3 +41,5 @@ module Gitlab ...@@ -41,3 +41,5 @@ module Gitlab
end end
end end
end end
::Gitlab::GlRepository.prepend(::EE::Gitlab::GlRepository)
...@@ -8223,6 +8223,9 @@ msgstr "" ...@@ -8223,6 +8223,9 @@ msgstr ""
msgid "Only project members will be imported. Group members will be skipped." msgid "Only project members will be imported. Group members will be skipped."
msgstr "" msgstr ""
msgid "Only these extensions are supported: %{extension_list}"
msgstr ""
msgid "Oops, are you sure?" msgid "Oops, are you sure?"
msgstr "" msgstr ""
......
...@@ -4,104 +4,119 @@ describe Resolvers::IssuesResolver do ...@@ -4,104 +4,119 @@ describe Resolvers::IssuesResolver do
include GraphqlHelpers include GraphqlHelpers
let(:current_user) { create(:user) } let(:current_user) { create(:user) }
set(:project) { create(:project) }
set(:issue1) { create(:issue, project: project, state: :opened, created_at: 3.hours.ago, updated_at: 3.hours.ago) }
set(:issue2) { create(:issue, project: project, state: :closed, title: 'foo', created_at: 1.hour.ago, updated_at: 1.hour.ago, closed_at: 1.hour.ago) }
set(:label1) { create(:label, project: project) }
set(:label2) { create(:label, project: project) }
before do
project.add_developer(current_user)
create(:label_link, label: label1, target: issue1)
create(:label_link, label: label1, target: issue2)
create(:label_link, label: label2, target: issue2)
end
describe '#resolve' do
it 'finds all issues' do
expect(resolve_issues).to contain_exactly(issue1, issue2)
end
it 'filters by state' do context "with a project" do
expect(resolve_issues(state: 'opened')).to contain_exactly(issue1) set(:project) { create(:project) }
expect(resolve_issues(state: 'closed')).to contain_exactly(issue2) set(:issue1) { create(:issue, project: project, state: :opened, created_at: 3.hours.ago, updated_at: 3.hours.ago) }
set(:issue2) { create(:issue, project: project, state: :closed, title: 'foo', created_at: 1.hour.ago, updated_at: 1.hour.ago, closed_at: 1.hour.ago) }
set(:label1) { create(:label, project: project) }
set(:label2) { create(:label, project: project) }
before do
project.add_developer(current_user)
create(:label_link, label: label1, target: issue1)
create(:label_link, label: label1, target: issue2)
create(:label_link, label: label2, target: issue2)
end end
it 'filters by labels' do describe '#resolve' do
expect(resolve_issues(label_name: [label1.title])).to contain_exactly(issue1, issue2) it 'finds all issues' do
expect(resolve_issues(label_name: [label1.title, label2.title])).to contain_exactly(issue2) expect(resolve_issues).to contain_exactly(issue1, issue2)
end end
describe 'filters by created_at' do it 'filters by state' do
it 'filters by created_before' do expect(resolve_issues(state: 'opened')).to contain_exactly(issue1)
expect(resolve_issues(created_before: 2.hours.ago)).to contain_exactly(issue1) expect(resolve_issues(state: 'closed')).to contain_exactly(issue2)
end end
it 'filters by created_after' do it 'filters by labels' do
expect(resolve_issues(created_after: 2.hours.ago)).to contain_exactly(issue2) expect(resolve_issues(label_name: [label1.title])).to contain_exactly(issue1, issue2)
expect(resolve_issues(label_name: [label1.title, label2.title])).to contain_exactly(issue2)
end end
end
describe 'filters by updated_at' do describe 'filters by created_at' do
it 'filters by updated_before' do it 'filters by created_before' do
expect(resolve_issues(updated_before: 2.hours.ago)).to contain_exactly(issue1) expect(resolve_issues(created_before: 2.hours.ago)).to contain_exactly(issue1)
end
it 'filters by created_after' do
expect(resolve_issues(created_after: 2.hours.ago)).to contain_exactly(issue2)
end
end end
it 'filters by updated_after' do describe 'filters by updated_at' do
expect(resolve_issues(updated_after: 2.hours.ago)).to contain_exactly(issue2) it 'filters by updated_before' do
expect(resolve_issues(updated_before: 2.hours.ago)).to contain_exactly(issue1)
end
it 'filters by updated_after' do
expect(resolve_issues(updated_after: 2.hours.ago)).to contain_exactly(issue2)
end
end end
end
describe 'filters by closed_at' do describe 'filters by closed_at' do
let!(:issue3) { create(:issue, project: project, state: :closed, closed_at: 3.hours.ago) } let!(:issue3) { create(:issue, project: project, state: :closed, closed_at: 3.hours.ago) }
it 'filters by closed_before' do it 'filters by closed_before' do
expect(resolve_issues(closed_before: 2.hours.ago)).to contain_exactly(issue3) expect(resolve_issues(closed_before: 2.hours.ago)).to contain_exactly(issue3)
end
it 'filters by closed_after' do
expect(resolve_issues(closed_after: 2.hours.ago)).to contain_exactly(issue2)
end
end end
it 'filters by closed_after' do it 'searches issues' do
expect(resolve_issues(closed_after: 2.hours.ago)).to contain_exactly(issue2) expect(resolve_issues(search: 'foo')).to contain_exactly(issue2)
end end
end
it 'searches issues' do it 'sort issues' do
expect(resolve_issues(search: 'foo')).to contain_exactly(issue2) expect(resolve_issues(sort: 'created_desc')).to eq [issue2, issue1]
end end
it 'sort issues' do it 'returns issues user can see' do
expect(resolve_issues(sort: 'created_desc')).to eq [issue2, issue1] project.add_guest(current_user)
end
it 'returns issues user can see' do create(:issue, confidential: true)
project.add_guest(current_user)
create(:issue, confidential: true) expect(resolve_issues).to contain_exactly(issue1, issue2)
end
expect(resolve_issues).to contain_exactly(issue1, issue2) it 'finds a specific issue with iid' do
end expect(resolve_issues(iid: issue1.iid)).to contain_exactly(issue1)
end
it 'finds a specific issue with iid' do it 'finds a specific issue with iids' do
expect(resolve_issues(iid: issue1.iid)).to contain_exactly(issue1) expect(resolve_issues(iids: issue1.iid)).to contain_exactly(issue1)
end end
it 'finds a specific issue with iids' do it 'finds multiple issues with iids' do
expect(resolve_issues(iids: issue1.iid)).to contain_exactly(issue1) expect(resolve_issues(iids: [issue1.iid, issue2.iid]))
end .to contain_exactly(issue1, issue2)
end
it 'finds multiple issues with iids' do it 'finds only the issues within the project we are looking at' do
expect(resolve_issues(iids: [issue1.iid, issue2.iid])) another_project = create(:project)
.to contain_exactly(issue1, issue2) iids = [issue1, issue2].map(&:iid)
end
iids.each do |iid|
create(:issue, project: another_project, iid: iid)
end
it 'finds only the issues within the project we are looking at' do expect(resolve_issues(iids: iids)).to contain_exactly(issue1, issue2)
another_project = create(:project) end
iids = [issue1, issue2].map(&:iid) end
end
iids.each do |iid| context "when passing a non existent, batch loaded project" do
create(:issue, project: another_project, iid: iid) let(:project) do
BatchLoader.for("non-existent-path").batch do |_fake_paths, loader, _|
loader.call("non-existent-path", nil)
end end
end
expect(resolve_issues(iids: iids)).to contain_exactly(issue1, issue2) it "returns nil without breaking" do
expect(resolve_issues(iids: ["don't", "break"])).to be_empty
end end
end end
......
...@@ -95,6 +95,12 @@ describe Gitlab::Git::Repository, :seed_helper do ...@@ -95,6 +95,12 @@ describe Gitlab::Git::Repository, :seed_helper do
end end
end end
describe '#create_repository' do
it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::RepositoryService, :create_repository do
subject { repository.create_repository }
end
end
describe '#branch_names' do describe '#branch_names' do
subject { repository.branch_names } subject { repository.branch_names }
......
...@@ -2487,4 +2487,69 @@ describe Repository do ...@@ -2487,4 +2487,69 @@ describe Repository do
repository.merge_base('master', 'fix') repository.merge_base('master', 'fix')
end end
end end
describe '#create_if_not_exists' do
let(:project) { create(:project) }
let(:repository) { project.repository }
it 'creates the repository if it did not exist' do
expect { repository.create_if_not_exists }.to change { repository.exists? }.from(false).to(true)
end
it 'calls out to the repository client to create a repo' do
expect(repository.raw.gitaly_repository_client).to receive(:create_repository)
repository.create_if_not_exists
end
context 'it does nothing if the repository already existed' do
let(:project) { create(:project, :repository) }
it 'does nothing if the repository already existed' do
expect(repository.raw.gitaly_repository_client).not_to receive(:create_repository)
repository.create_if_not_exists
end
end
context 'when the repository exists but the cache is not up to date' do
let(:project) { create(:project, :repository) }
it 'does not raise errors' do
allow(repository).to receive(:exists?).and_return(false)
expect(repository.raw).to receive(:create_repository).and_call_original
expect { repository.create_if_not_exists }.not_to raise_error
end
end
end
describe "#blobs_metadata" do
set(:project) { create(:project, :repository) }
let(:repository) { project.repository }
def expect_metadata_blob(thing)
expect(thing).to be_a(Blob)
expect(thing.data).to be_empty
end
it "returns blob metadata in batch for HEAD" do
result = repository.blobs_metadata(["bar/branch-test.txt", "README.md", "does/not/exist"])
expect_metadata_blob(result.first)
expect_metadata_blob(result.second)
expect(result.size).to eq(2)
end
it "returns blob metadata for a specified ref" do
result = repository.blobs_metadata(["files/ruby/feature.rb"], "feature")
expect_metadata_blob(result.first)
end
it "performs a single gitaly call", :request_store do
expect { repository.blobs_metadata(["bar/branch-test.txt", "readme.txt", "does/not/exist"]) }
.to change { Gitlab::GitalyClient.get_request_count }.by(1)
end
end
end end
...@@ -61,7 +61,14 @@ module GraphqlHelpers ...@@ -61,7 +61,14 @@ module GraphqlHelpers
def variables_for_mutation(name, input) def variables_for_mutation(name, input)
graphql_input = input.map { |name, value| [GraphqlHelpers.fieldnamerize(name), value] }.to_h graphql_input = input.map { |name, value| [GraphqlHelpers.fieldnamerize(name), value] }.to_h
{ input_variable_name_for_mutation(name) => graphql_input }.to_json result = { input_variable_name_for_mutation(name) => graphql_input }
# Avoid trying to serialize multipart data into JSON
if graphql_input.values.none? { |value| io_value?(value) }
result.to_json
else
result
end
end end
def input_variable_name_for_mutation(mutation_name) def input_variable_name_for_mutation(mutation_name)
...@@ -162,6 +169,10 @@ module GraphqlHelpers ...@@ -162,6 +169,10 @@ module GraphqlHelpers
field.arguments.values.any? { |argument| argument.type.non_null? } field.arguments.values.any? { |argument| argument.type.non_null? }
end end
def io_value?(value)
Array.wrap(value).any? { |v| v.respond_to?(:to_io) }
end
def field_type(field) def field_type(field)
field_type = field.type field_type = field.type
......
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