From 27c6c4bf061c3a2289ce4808b1b354535994d09d Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Tue, 26 May 2020 09:08:06 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .../components/related_items_list.scss | 18 +- .../concerns/check_codeowner_rules.rb | 9 - app/controllers/projects/blob_controller.rb | 17 +- app/finders/ci/runners_finder.rb | 4 +- app/finders/issuable_finder.rb | 2 +- .../mutations/merge_requests/create.rb | 63 +++++ app/graphql/types/milestone_type.rb | 12 + app/graphql/types/mutation_type.rb | 1 + app/models/concerns/featurable.rb | 99 ++++++++ app/models/concerns/issuable.rb | 9 +- app/models/milestone.rb | 4 + app/models/project_feature.rb | 106 +------- changelogs/unreleased/208412-featurable.yml | 5 + .../215619_add_mutation_to_create_mr.yml | 5 + ...6160-fix-label-any-with-custom-sorting.yml | 5 + changelogs/unreleased/Fix-spelling-error.yml | 5 + .../graphql/reference/gitlab_schema.graphql | 71 ++++++ doc/api/graphql/reference/gitlab_schema.json | 235 ++++++++++++++++++ doc/api/graphql/reference/index.md | 13 + .../filter/external_issue_reference_filter.rb | 19 +- locale/gitlab.pot | 9 + .../projects/blob_controller_spec.rb | 56 ----- .../mutations/merge_requests/create_spec.rb | 87 +++++++ spec/models/concerns/featurable_spec.rb | 184 ++++++++++++++ spec/models/concerns/issuable_spec.rb | 16 ++ spec/models/milestone_spec.rb | 19 ++ spec/models/project_feature_spec.rb | 131 ---------- .../mutations/merge_requests/create_spec.rb | 51 ++++ 28 files changed, 929 insertions(+), 326 deletions(-) delete mode 100644 app/controllers/concerns/check_codeowner_rules.rb create mode 100644 app/graphql/mutations/merge_requests/create.rb create mode 100644 app/models/concerns/featurable.rb create mode 100644 changelogs/unreleased/208412-featurable.yml create mode 100644 changelogs/unreleased/215619_add_mutation_to_create_mr.yml create mode 100644 changelogs/unreleased/216160-fix-label-any-with-custom-sorting.yml create mode 100644 changelogs/unreleased/Fix-spelling-error.yml create mode 100644 spec/graphql/mutations/merge_requests/create_spec.rb create mode 100644 spec/models/concerns/featurable_spec.rb create mode 100644 spec/requests/api/graphql/mutations/merge_requests/create_spec.rb diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss index 61f971a3185..fd1108a7305 100644 --- a/app/assets/stylesheets/components/related_items_list.scss +++ b/app/assets/stylesheets/components/related_items_list.scss @@ -23,6 +23,11 @@ $item-remove-button-space: 42px; .sortable-link { white-space: normal; } + + .item-assignees .avatar { + height: $gl-padding; + width: $gl-padding; + } } .item-body { @@ -276,10 +281,6 @@ $item-remove-button-space: 42px; /* Small devices (landscape phones, 768px and up) */ @include media-breakpoint-up(md) { - .item-body .item-contents { - max-width: 95%; - } - .related-items-tree .item-contents, .item-body .item-title { max-width: 100%; @@ -348,6 +349,11 @@ $item-remove-button-space: 42px; } .item-assignees { + .avatar { + height: $gl-padding-24; + width: $gl-padding-24; + } + .avatar-counter { height: $gl-padding-24; min-width: $gl-padding-24; @@ -366,6 +372,10 @@ $item-remove-button-space: 42px; .sortable-link { line-height: 1.3; } + + .item-info-area { + flex-basis: auto; + } } @media only screen and (min-width: 1500px) { diff --git a/app/controllers/concerns/check_codeowner_rules.rb b/app/controllers/concerns/check_codeowner_rules.rb deleted file mode 100644 index 87947a356f7..00000000000 --- a/app/controllers/concerns/check_codeowner_rules.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module CheckCodeownerRules - extend ActiveSupport::Concern - - def codeowners_check_error(project, branch_name, paths) - ::Gitlab::CodeOwners::Validator.new(project, branch_name, paths).execute - end -end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index d044548e147..d35498260c6 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -9,7 +9,6 @@ class Projects::BlobController < Projects::ApplicationController include ActionView::Helpers::SanitizeHelper include RedirectsForMissingPathOnTree include SourcegraphDecorator - include CheckCodeownerRules prepend_before_action :authenticate_user!, only: [:edit] @@ -29,7 +28,6 @@ class Projects::BlobController < Projects::ApplicationController before_action :editor_variables, except: [:show, :preview, :diff] before_action :validate_diff_params, only: :diff before_action :set_last_commit_sha, only: [:edit, :update] - before_action :validate_codeowner_rules, only: [:create, :update] before_action only: :show do push_frontend_feature_flag(:code_navigation, @project) @@ -118,18 +116,7 @@ class Projects::BlobController < Projects::ApplicationController private - def validate_codeowner_rules - return if params[:file_path].blank? - - codeowners_error = codeowners_check_error(@project, @branch_name, params[:file_path]) - - if codeowners_error.present? - flash.now[:alert] = codeowners_error - view = params[:action] == 'update' ? :edit : :new - - render view - end - end + attr_reader :branch_name def blob @blob ||= @repository.blob_at(@commit.id, @path) @@ -270,3 +257,5 @@ class Projects::BlobController < Projects::ApplicationController params.permit(:full, :since, :to, :bottom, :unfold, :offset, :indent) end end + +Projects::BlobController.prepend_if_ee('EE::Projects::BlobController') diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb index 54d9d9522e0..1b76211c524 100644 --- a/app/finders/ci/runners_finder.rb +++ b/app/finders/ci/runners_finder.rb @@ -52,9 +52,9 @@ module Ci raise Gitlab::Access::AccessDeniedError unless can?(@current_user, :admin_group, @group) # Getting all runners from the group itself and all its descendants - descentant_projects = Project.for_group_and_its_subgroups(@group) + descendant_projects = Project.for_group_and_its_subgroups(@group) - @runners = Ci::Runner.belonging_to_group_or_project(@group.self_and_descendants, descentant_projects) + @runners = Ci::Runner.belonging_to_group_or_project(@group.self_and_descendants, descendant_projects) end def filter_by_status! diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 7014f2ec205..5cdc22fd873 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -451,7 +451,7 @@ class IssuableFinder if params.filter_by_no_label? items.without_label elsif params.filter_by_any_label? - items.any_label + items.any_label(params[:sort]) else items.with_label(params.label_names, params[:sort]) end diff --git a/app/graphql/mutations/merge_requests/create.rb b/app/graphql/mutations/merge_requests/create.rb new file mode 100644 index 00000000000..95d6fb100e7 --- /dev/null +++ b/app/graphql/mutations/merge_requests/create.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class Create < BaseMutation + include Mutations::ResolvesProject + + graphql_name 'MergeRequestCreate' + + argument :project_path, GraphQL::ID_TYPE, + required: true, + description: 'Project full path the merge request is associated with' + + argument :title, GraphQL::STRING_TYPE, + required: true, + description: copy_field_description(Types::MergeRequestType, :title) + + argument :source_branch, GraphQL::STRING_TYPE, + required: true, + description: copy_field_description(Types::MergeRequestType, :source_branch) + + argument :target_branch, GraphQL::STRING_TYPE, + required: true, + description: copy_field_description(Types::MergeRequestType, :target_branch) + + argument :description, GraphQL::STRING_TYPE, + required: false, + description: copy_field_description(Types::MergeRequestType, :description) + + field :merge_request, + Types::MergeRequestType, + null: true, + description: 'The merge request after mutation' + + authorize :create_merge_request_from + + def resolve(project_path:, title:, source_branch:, target_branch:, description: nil) + project = authorized_find!(full_path: project_path) + + attributes = { + title: title, + source_branch: source_branch, + target_branch: target_branch, + author_id: current_user.id, + description: description + } + + merge_request = ::MergeRequests::CreateService.new(project, current_user, attributes).execute + + { + merge_request: merge_request.valid? ? merge_request : nil, + errors: errors_on_object(merge_request) + } + end + + private + + def find_object(full_path:) + resolve_project(full_path: full_path) + end + end + end +end diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb index 900f8c6f01d..99bd6e819d6 100644 --- a/app/graphql/types/milestone_type.rb +++ b/app/graphql/types/milestone_type.rb @@ -35,5 +35,17 @@ module Types field :updated_at, Types::TimeType, null: false, description: 'Timestamp of last milestone update' + + field :project_milestone, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Indicates if milestone is at project level', + method: :project_milestone? + + field :group_milestone, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Indicates if milestone is at group level', + method: :group_milestone? + + field :subgroup_milestone, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Indicates if milestone is at subgroup level', + method: :subgroup_milestone? end end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index aeff84b83b8..ae5469ba5b2 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -16,6 +16,7 @@ module Types mount_mutation Mutations::Issues::SetConfidential mount_mutation Mutations::Issues::SetDueDate mount_mutation Mutations::Issues::Update + mount_mutation Mutations::MergeRequests::Create mount_mutation Mutations::MergeRequests::SetLabels mount_mutation Mutations::MergeRequests::SetLocked mount_mutation Mutations::MergeRequests::SetMilestone diff --git a/app/models/concerns/featurable.rb b/app/models/concerns/featurable.rb new file mode 100644 index 00000000000..60aa46ce04c --- /dev/null +++ b/app/models/concerns/featurable.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +# == Featurable concern +# +# This concern adds features (tools) functionality to Project and Group +# To enable features you need to call `set_available_features` +# +# Example: +# +# class ProjectFeature +# include Featurable +# set_available_features %i(wiki merge_request) + +module Featurable + extend ActiveSupport::Concern + + # Can be enabled only for members, everyone or disabled + # Access control is made only for non private containers. + # + # Permission levels: + # + # Disabled: not enabled for anyone + # Private: enabled only for team members + # Enabled: enabled for everyone able to access the project + # Public: enabled for everyone (only allowed for pages) + DISABLED = 0 + PRIVATE = 10 + ENABLED = 20 + PUBLIC = 30 + + STRING_OPTIONS = HashWithIndifferentAccess.new({ + 'disabled' => DISABLED, + 'private' => PRIVATE, + 'enabled' => ENABLED, + 'public' => PUBLIC + }).freeze + + class_methods do + def set_available_features(available_features = []) + @available_features = available_features + + class_eval do + available_features.each do |feature| + define_method("#{feature}_enabled?") do + public_send("#{feature}_access_level") > DISABLED # rubocop:disable GitlabSecurity/PublicSend + end + end + end + end + + def available_features + @available_features + end + + def access_level_attribute(feature) + feature = ensure_feature!(feature) + + "#{feature}_access_level".to_sym + end + + def quoted_access_level_column(feature) + attribute = connection.quote_column_name(access_level_attribute(feature)) + table = connection.quote_table_name(table_name) + + "#{table}.#{attribute}" + end + + def access_level_from_str(level) + STRING_OPTIONS.fetch(level) + end + + def str_from_access_level(level) + STRING_OPTIONS.key(level) + end + + def ensure_feature!(feature) + feature = feature.model_name.plural if feature.respond_to?(:model_name) + feature = feature.to_sym + raise ArgumentError, "invalid feature: #{feature}" unless available_features.include?(feature) + + feature + end + end + + def access_level(feature) + public_send(self.class.access_level_attribute(feature)) # rubocop:disable GitlabSecurity/PublicSend + end + + def feature_available?(feature, user) + # This feature might not be behind a feature flag at all, so default to true + return false unless ::Feature.enabled?(feature, user, default_enabled: true) + + get_permission(user, feature) + end + + def string_access_level(feature) + self.class.str_from_access_level(access_level(feature)) + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index a1b14dca4ac..1407a380978 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -139,7 +139,6 @@ module Issuable scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) } scope :with_label_ids, ->(label_ids) { joins(:label_links).where(label_links: { label_id: label_ids }) } - scope :any_label, -> { joins(:label_links).distinct } scope :join_project, -> { joins(:project) } scope :inc_notes_with_associations, -> { includes(notes: [:project, :author, :award_emoji]) } scope :references_project, -> { references(:project) } @@ -316,6 +315,14 @@ module Issuable end end + def any_label(sort = nil) + if sort + joins(:label_links).group(*grouping_columns(sort)) + else + joins(:label_links).distinct + end + end + # Includes table keys in group by clause when sorting # preventing errors in postgres # diff --git a/app/models/milestone.rb b/app/models/milestone.rb index b5e4f62792e..ba9635897a3 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -179,6 +179,10 @@ class Milestone < ApplicationRecord end end + def subgroup_milestone? + group_milestone? && parent.subgroup? + end + private def milestone_format_reference(format = :iid) diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 9201cd24d66..b3ebcbd4b17 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -1,51 +1,16 @@ # frozen_string_literal: true class ProjectFeature < ApplicationRecord - # == Project features permissions - # - # Grants access level to project tools - # - # Tools can be enabled only for users, everyone or disabled - # Access control is made only for non private projects - # - # levels: - # - # Disabled: not enabled for anyone - # Private: enabled only for team members - # Enabled: enabled for everyone able to access the project - # Public: enabled for everyone (only allowed for pages) - # - - # Permission levels - DISABLED = 0 - PRIVATE = 10 - ENABLED = 20 - PUBLIC = 30 + include Featurable FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages metrics_dashboard).freeze + + set_available_features(FEATURES) + PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER, metrics_dashboard: Gitlab::Access::REPORTER }.freeze PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT = { repository: Gitlab::Access::REPORTER }.freeze - STRING_OPTIONS = HashWithIndifferentAccess.new({ - 'disabled' => DISABLED, - 'private' => PRIVATE, - 'enabled' => ENABLED, - 'public' => PUBLIC - }).freeze class << self - def access_level_attribute(feature) - feature = ensure_feature!(feature) - - "#{feature}_access_level".to_sym - end - - def quoted_access_level_column(feature) - attribute = connection.quote_column_name(access_level_attribute(feature)) - table = connection.quote_table_name(table_name) - - "#{table}.#{attribute}" - end - def required_minimum_access_level(feature) feature = ensure_feature!(feature) @@ -60,24 +25,6 @@ class ProjectFeature < ApplicationRecord required_minimum_access_level(feature) end end - - def access_level_from_str(level) - STRING_OPTIONS.fetch(level) - end - - def str_from_access_level(level) - STRING_OPTIONS.key(level) - end - - private - - def ensure_feature!(feature) - feature = feature.model_name.plural if feature.respond_to?(:model_name) - feature = feature.to_sym - raise ArgumentError, "invalid project feature: #{feature}" unless FEATURES.include?(feature) - - feature - end end # Default scopes force us to unscope here since a service may need to check @@ -107,45 +54,6 @@ class ProjectFeature < ApplicationRecord end end - def feature_available?(feature, user) - # This feature might not be behind a feature flag at all, so default to true - return false unless ::Feature.enabled?(feature, user, default_enabled: true) - - get_permission(user, feature) - end - - def access_level(feature) - public_send(ProjectFeature.access_level_attribute(feature)) # rubocop:disable GitlabSecurity/PublicSend - end - - def string_access_level(feature) - ProjectFeature.str_from_access_level(access_level(feature)) - end - - def builds_enabled? - builds_access_level > DISABLED - end - - def wiki_enabled? - wiki_access_level > DISABLED - end - - def merge_requests_enabled? - merge_requests_access_level > DISABLED - end - - def forking_enabled? - forking_access_level > DISABLED - end - - def issues_enabled? - issues_access_level > DISABLED - end - - def pages_enabled? - pages_access_level > DISABLED - end - def public_pages? return true unless Gitlab.config.pages.access_control @@ -164,7 +72,7 @@ class ProjectFeature < ApplicationRecord # which cannot be higher than repository access level def repository_children_level validator = lambda do |field| - level = public_send(field) || ProjectFeature::ENABLED # rubocop:disable GitlabSecurity/PublicSend + level = public_send(field) || ENABLED # rubocop:disable GitlabSecurity/PublicSend not_allowed = level > repository_access_level self.errors.add(field, "cannot have higher visibility level than repository access level") if not_allowed end @@ -175,8 +83,8 @@ class ProjectFeature < ApplicationRecord # Validates access level for other than pages cannot be PUBLIC def allowed_access_levels validator = lambda do |field| - level = public_send(field) || ProjectFeature::ENABLED # rubocop:disable GitlabSecurity/PublicSend - not_allowed = level > ProjectFeature::ENABLED + level = public_send(field) || ENABLED # rubocop:disable GitlabSecurity/PublicSend + not_allowed = level > ENABLED self.errors.add(field, "cannot have public visibility level") if not_allowed end diff --git a/changelogs/unreleased/208412-featurable.yml b/changelogs/unreleased/208412-featurable.yml new file mode 100644 index 00000000000..02adcccec0b --- /dev/null +++ b/changelogs/unreleased/208412-featurable.yml @@ -0,0 +1,5 @@ +--- +title: Extract featurable concern from ProjectFeature +merge_request: 31700 +author: Alexander Randa +type: other diff --git a/changelogs/unreleased/215619_add_mutation_to_create_mr.yml b/changelogs/unreleased/215619_add_mutation_to_create_mr.yml new file mode 100644 index 00000000000..477f8b60d5e --- /dev/null +++ b/changelogs/unreleased/215619_add_mutation_to_create_mr.yml @@ -0,0 +1,5 @@ +--- +title: Add mutation to create a merge request in GraphQL +merge_request: 31867 +author: +type: added diff --git a/changelogs/unreleased/216160-fix-label-any-with-custom-sorting.yml b/changelogs/unreleased/216160-fix-label-any-with-custom-sorting.yml new file mode 100644 index 00000000000..25b0c302cf2 --- /dev/null +++ b/changelogs/unreleased/216160-fix-label-any-with-custom-sorting.yml @@ -0,0 +1,5 @@ +--- +title: Fix issuable listings with any label filter +merge_request: 31729 +author: +type: fixed diff --git a/changelogs/unreleased/Fix-spelling-error.yml b/changelogs/unreleased/Fix-spelling-error.yml new file mode 100644 index 00000000000..c851da131fe --- /dev/null +++ b/changelogs/unreleased/Fix-spelling-error.yml @@ -0,0 +1,5 @@ +--- +title: Fix spelling error on Ci::RunnersFinder +merge_request: 32985 +author: Arthur de Lapertosa Lisboa +type: fixed diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 7cfd4196a34..ccb9d5f0eb3 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -6188,6 +6188,61 @@ type MergeRequestConnection { pageInfo: PageInfo! } +""" +Autogenerated input type of MergeRequestCreate +""" +input MergeRequestCreateInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Description of the merge request (Markdown rendered as HTML for caching) + """ + description: String + + """ + Project full path the merge request is associated with + """ + projectPath: ID! + + """ + Source branch of the merge request + """ + sourceBranch: String! + + """ + Target branch of the merge request + """ + targetBranch: String! + + """ + Title of the merge request + """ + title: String! +} + +""" +Autogenerated return type of MergeRequestCreate +""" +type MergeRequestCreatePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Errors encountered during execution of the mutation. + """ + errors: [String!]! + + """ + The merge request after mutation + """ + mergeRequest: MergeRequest +} + """ An edge in a connection. """ @@ -6673,11 +6728,21 @@ type Milestone { """ dueDate: Time + """ + Indicates if milestone is at group level + """ + groupMilestone: Boolean! + """ ID of the milestone """ id: ID! + """ + Indicates if milestone is at project level + """ + projectMilestone: Boolean! + """ Timestamp of the milestone start date """ @@ -6688,6 +6753,11 @@ type Milestone { """ state: MilestoneStateEnum! + """ + Indicates if milestone is at subgroup level + """ + subgroupMilestone: Boolean! + """ Title of the milestone """ @@ -6788,6 +6858,7 @@ type Mutation { issueSetWeight(input: IssueSetWeightInput!): IssueSetWeightPayload jiraImportStart(input: JiraImportStartInput!): JiraImportStartPayload markAsSpamSnippet(input: MarkAsSpamSnippetInput!): MarkAsSpamSnippetPayload + mergeRequestCreate(input: MergeRequestCreateInput!): MergeRequestCreatePayload mergeRequestSetAssignees(input: MergeRequestSetAssigneesInput!): MergeRequestSetAssigneesPayload mergeRequestSetLabels(input: MergeRequestSetLabelsInput!): MergeRequestSetLabelsPayload mergeRequestSetLocked(input: MergeRequestSetLockedInput!): MergeRequestSetLockedPayload diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index db737743d32..ee383576bc3 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -17302,6 +17302,160 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "MergeRequestCreateInput", + "description": "Autogenerated input type of MergeRequestCreate", + "fields": null, + "inputFields": [ + { + "name": "projectPath", + "description": "Project full path the merge request is associated with", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "title", + "description": "Title of the merge request", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "sourceBranch", + "description": "Source branch of the merge request", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "targetBranch", + "description": "Target branch of the merge request", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "description", + "description": "Description of the merge request (Markdown rendered as HTML for caching)", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MergeRequestCreatePayload", + "description": "Autogenerated return type of MergeRequestCreate", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Errors encountered during execution of the mutation.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequest", + "description": "The merge request after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequest", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "MergeRequestEdge", @@ -18754,6 +18908,24 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "groupMilestone", + "description": "Indicates if milestone is at group level", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "id", "description": "ID of the milestone", @@ -18772,6 +18944,24 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "projectMilestone", + "description": "Indicates if milestone is at project level", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "startDate", "description": "Timestamp of the milestone start date", @@ -18804,6 +18994,24 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "subgroupMilestone", + "description": "Indicates if milestone is at subgroup level", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "title", "description": "Title of the milestone", @@ -19785,6 +19993,33 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "mergeRequestCreate", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "MergeRequestCreateInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequestCreatePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "mergeRequestSetAssignees", "description": null, diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 7d0cf9eb322..add122d9f32 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -912,6 +912,16 @@ Autogenerated return type of MarkAsSpamSnippet | `webUrl` | String | Web URL of the merge request | | `workInProgress` | Boolean! | Indicates if the merge request is a work in progress (WIP) | +## MergeRequestCreatePayload + +Autogenerated return type of MergeRequestCreate + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Errors encountered during execution of the mutation. | +| `mergeRequest` | MergeRequest | The merge request after mutation | + ## MergeRequestPermissions Check permissions for the current user on a merge request @@ -1019,9 +1029,12 @@ Represents a milestone. | `createdAt` | Time! | Timestamp of milestone creation | | `description` | String | Description of the milestone | | `dueDate` | Time | Timestamp of the milestone due date | +| `groupMilestone` | Boolean! | Indicates if milestone is at group level | | `id` | ID! | ID of the milestone | +| `projectMilestone` | Boolean! | Indicates if milestone is at project level | | `startDate` | Time | Timestamp of the milestone start date | | `state` | MilestoneStateEnum! | State of the milestone | +| `subgroupMilestone` | Boolean! | Indicates if milestone is at subgroup level | | `title` | String! | Title of the milestone | | `updatedAt` | Time! | Timestamp of last milestone update | | `webPath` | String! | Web path of the milestone | diff --git a/lib/banzai/filter/external_issue_reference_filter.rb b/lib/banzai/filter/external_issue_reference_filter.rb index 8159dcfed72..a4f1f2f11f9 100644 --- a/lib/banzai/filter/external_issue_reference_filter.rb +++ b/lib/banzai/filter/external_issue_reference_filter.rb @@ -54,6 +54,8 @@ module Banzai doc end + private + # Replace `JIRA-123` issue references in text with links to the referenced # issue's details page. # @@ -63,21 +65,14 @@ module Banzai # Returns a String with `JIRA-123` references replaced with links. All # links have `gfm` and `gfm-issue` class names attached for styling. def issue_link_filter(text, link_content: nil) - project = context[:project] - self.class.references_in(text, issue_reference_pattern) do |match, id| - ExternalIssue.new(id, project) - url = url_for_issue(id, project, only_path: context[:only_path]) - - title = "Issue in #{project.external_issue_tracker.title}" klass = reference_class(:issue) data = data_attribute(project: project.id, external_issue: id) - content = link_content || match %(#{content}) end end @@ -94,7 +89,13 @@ module Banzai external_issues_cached(:external_issue_reference_pattern) end - private + def project + context[:project] + end + + def issue_title + "Issue in #{project.external_issue_tracker.title}" + end def external_issues_cached(attribute) cached_attributes = Gitlab::SafeRequestStore[:banzai_external_issues_tracker_attributes] ||= Hash.new { |h, k| h[k] = {} } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index cbc836cd21e..15a842fb32f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -10797,6 +10797,9 @@ msgstr "" msgid "Group members" msgstr "" +msgid "Group milestone" +msgstr "" + msgid "Group name" msgstr "" @@ -16628,6 +16631,9 @@ msgstr "" msgid "Project members" msgstr "" +msgid "Project milestone" +msgstr "" + msgid "Project name" msgstr "" @@ -20778,6 +20784,9 @@ msgstr "" msgid "StorageSize|Unknown" msgstr "" +msgid "Subgroup milestone" +msgstr "" + msgid "Subgroup overview" msgstr "" diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb index 39b0cd4fb03..ad04c6e61e8 100644 --- a/spec/controllers/projects/blob_controller_spec.rb +++ b/spec/controllers/projects/blob_controller_spec.rb @@ -250,56 +250,6 @@ describe Projects::BlobController do end end - shared_examples "file matches a codeowners rule" do - let(:error_msg) { "Example error msg" } - - it "renders to the edit page with an error msg" do - default_params[:file_path] = "CHANGELOG" - - expect_next_instance_of(Gitlab::CodeOwners::Validator) do |validator| - expect(validator).to receive(:execute).and_return(error_msg) - end - - subject - - expect(flash[:alert]).to eq(error_msg) - expect(response).to render_template(expected_view) - end - end - - describe 'POST create' do - let(:user) { create(:user) } - let(:default_params) do - { - namespace_id: project.namespace, - project_id: project, - id: 'master', - branch_name: 'master', - file_name: 'CHANGELOG', - content: 'Added changes', - commit_message: 'Create CHANGELOG' - } - end - - before do - project.add_developer(user) - - sign_in(user) - end - - it 'redirects to blob' do - post :create, params: default_params - - expect(response).to be_ok - end - - it_behaves_like "file matches a codeowners rule" do - subject { post :create, params: default_params } - - let(:expected_view) { :new } - end - end - describe 'PUT update' do let(:user) { create(:user) } let(:default_params) do @@ -329,12 +279,6 @@ describe Projects::BlobController do expect(response).to redirect_to(blob_after_edit_path) end - it_behaves_like "file matches a codeowners rule" do - subject { put :update, params: default_params } - - let(:expected_view) { :edit } - end - context '?from_merge_request_iid' do let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } let(:mr_params) { default_params.merge(from_merge_request_iid: merge_request.iid) } diff --git a/spec/graphql/mutations/merge_requests/create_spec.rb b/spec/graphql/mutations/merge_requests/create_spec.rb new file mode 100644 index 00000000000..88acd3ed5b6 --- /dev/null +++ b/spec/graphql/mutations/merge_requests/create_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mutations::MergeRequests::Create do + subject(:mutation) { described_class.new(object: nil, context: context, field: nil) } + + let_it_be(:project) { create(:project, :public, :repository) } + let_it_be(:user) { create(:user) } + let_it_be(:context) do + GraphQL::Query::Context.new( + query: OpenStruct.new(schema: nil), + values: { current_user: user }, + object: nil + ) + end + + describe '#resolve' do + subject do + mutation.resolve( + project_path: project.full_path, + title: title, + source_branch: source_branch, + target_branch: target_branch, + description: description + ) + end + + let(:title) { 'MergeRequest' } + let(:source_branch) { 'feature' } + let(:target_branch) { 'master' } + let(:description) { nil } + + let(:mutated_merge_request) { subject[:merge_request] } + + it 'raises an error if the resource is not accessible to the user' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + + context 'when user does not have enough permissions to create a merge request' do + before do + project.add_guest(user) + end + + it 'raises an error if the resource is not accessible to the user' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when the user can create a merge request' do + before_all do + project.add_developer(user) + end + + it 'creates a new merge request' do + expect { mutated_merge_request }.to change(MergeRequest, :count).by(1) + end + + it 'returns a new merge request' do + expect(mutated_merge_request.title).to eq(title) + expect(subject[:errors]).to be_empty + end + + context 'when optional description field is set' do + let(:description) { 'content' } + + it 'returns a new merge request with a description' do + expect(mutated_merge_request.description).to eq(description) + expect(subject[:errors]).to be_empty + end + end + + context 'when service cannot create a merge request' do + let(:title) { nil } + + it 'does not create a new merge request' do + expect { mutated_merge_request }.not_to change(MergeRequest, :count) + end + + it 'returns errors' do + expect(mutated_merge_request).to be_nil + expect(subject[:errors]).to eq(['Title can\'t be blank']) + end + end + end + end +end diff --git a/spec/models/concerns/featurable_spec.rb b/spec/models/concerns/featurable_spec.rb new file mode 100644 index 00000000000..89720e3652c --- /dev/null +++ b/spec/models/concerns/featurable_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Featurable do + let_it_be(:user) { create(:user) } + let(:project) { create(:project) } + let(:feature_class) { subject.class } + let(:features) { feature_class::FEATURES } + + subject { project.project_feature } + + describe '.quoted_access_level_column' do + it 'returns the table name and quoted column name for a feature' do + expected = '"project_features"."issues_access_level"' + + expect(feature_class.quoted_access_level_column(:issues)).to eq(expected) + end + end + + describe '.access_level_attribute' do + it { expect(feature_class.access_level_attribute(:wiki)).to eq :wiki_access_level } + + it 'raises error for unspecified feature' do + expect { feature_class.access_level_attribute(:unknown) } + .to raise_error(ArgumentError, /invalid feature: unknown/) + end + end + + describe '.set_available_features' do + let!(:klass) do + Class.new do + include Featurable + set_available_features %i(feature1 feature2) + + def feature1_access_level + Featurable::DISABLED + end + + def feature2_access_level + Featurable::ENABLED + end + end + end + let!(:instance) { klass.new } + + it { expect(klass.available_features).to eq [:feature1, :feature2] } + it { expect(instance.feature1_enabled?).to be_falsey } + it { expect(instance.feature2_enabled?).to be_truthy } + end + + describe '.available_features' do + it { expect(feature_class.available_features).to include(*features) } + end + + describe '#access_level' do + it 'returns access level' do + expect(subject.access_level(:wiki)).to eq(subject.wiki_access_level) + end + end + + describe '#feature_available?' do + let(:features) { %w(issues wiki builds merge_requests snippets repository pages metrics_dashboard) } + + context 'when features are disabled' do + it "returns false" do + update_all_project_features(project, features, ProjectFeature::DISABLED) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" + end + end + end + + context 'when features are enabled only for team members' do + it "returns false when user is not a team member" do + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" + end + end + + it "returns true when user is a team member" do + project.add_developer(user) + + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed" + end + end + + it "returns true when user is a member of project group" do + group = create(:group) + project = create(:project, namespace: group) + group.add_developer(user) + + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed" + end + end + + context 'when admin mode is enabled', :enable_admin_mode do + it "returns true if user is an admin" do + user.update_attribute(:admin, true) + + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed" + end + end + end + + context 'when admin mode is disabled' do + it "returns false when user is an admin" do + user.update_attribute(:admin, true) + + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" + end + end + end + end + + context 'when feature is enabled for everyone' do + it "returns true" do + expect(project.feature_available?(:issues, user)).to eq(true) + end + end + + context 'when feature is disabled by a feature flag' do + it 'returns false' do + stub_feature_flags(issues: false) + + expect(project.feature_available?(:issues, user)).to eq(false) + end + end + + context 'when feature is enabled by a feature flag' do + it 'returns true' do + stub_feature_flags(issues: true) + + expect(project.feature_available?(:issues, user)).to eq(true) + end + end + end + + describe '#*_enabled?' do + let(:features) { %w(wiki builds merge_requests) } + + it "returns false when feature is disabled" do + update_all_project_features(project, features, ProjectFeature::DISABLED) + + features.each do |feature| + expect(project.public_send("#{feature}_enabled?")).to eq(false), "#{feature} failed" + end + end + + it "returns true when feature is enabled only for team members" do + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed" + end + end + + it "returns true when feature is enabled for everyone" do + features.each do |feature| + expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed" + end + end + end + + def update_all_project_features(project, features, value) + project_feature_attributes = features.map { |f| ["#{f}_access_level", value] }.to_h + project.project_feature.update(project_feature_attributes) + end +end diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 59398e797c1..74ee7a87b7b 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -102,6 +102,22 @@ describe Issuable do end end + describe '.any_label' do + let_it_be(:issue_with_label) { create(:labeled_issue, labels: [create(:label)]) } + let_it_be(:issue_with_multiple_labels) { create(:labeled_issue, labels: [create(:label), create(:label)]) } + let_it_be(:issue_without_label) { create(:issue) } + + it 'returns an issuable with at least one label' do + expect(issuable_class.any_label).to match_array([issue_with_label, issue_with_multiple_labels]) + end + + context 'for custom sorting' do + it 'returns an issuable with at least one label' do + expect(issuable_class.any_label('created_at')).to eq([issue_with_label, issue_with_multiple_labels]) + end + end + end + describe ".search" do let!(:searchable_issue) { create(:issue, title: "Searchable awesome issue") } let!(:searchable_issue2) { create(:issue, title: 'Aw') } diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb index 178fd5c04d0..06061bcc1a1 100644 --- a/spec/models/milestone_spec.rb +++ b/spec/models/milestone_spec.rb @@ -498,4 +498,23 @@ describe Milestone do end end end + + describe '#subgroup_milestone' do + context 'parent is subgroup' do + it 'returns true' do + group = create(:group) + subgroup = create(:group, :private, parent: group) + + expect(build(:milestone, group: subgroup).subgroup_milestone?).to eq(true) + end + end + + context 'parent is not subgroup' do + it 'returns false' do + group = create(:group) + + expect(build(:milestone, group: group).subgroup_milestone?).to eq(false) + end + end + end end diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb index e072cc21b38..e33ea75bc5d 100644 --- a/spec/models/project_feature_spec.rb +++ b/spec/models/project_feature_spec.rb @@ -18,106 +18,6 @@ describe ProjectFeature do end end - describe '.quoted_access_level_column' do - it 'returns the table name and quoted column name for a feature' do - expected = '"project_features"."issues_access_level"' - - expect(described_class.quoted_access_level_column(:issues)).to eq(expected) - end - end - - describe '#feature_available?' do - let(:features) { %w(issues wiki builds merge_requests snippets repository pages metrics_dashboard) } - - context 'when features are disabled' do - it "returns false" do - update_all_project_features(project, features, ProjectFeature::DISABLED) - - features.each do |feature| - expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" - end - end - end - - context 'when features are enabled only for team members' do - it "returns false when user is not a team member" do - update_all_project_features(project, features, ProjectFeature::PRIVATE) - - features.each do |feature| - expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" - end - end - - it "returns true when user is a team member" do - project.add_developer(user) - - update_all_project_features(project, features, ProjectFeature::PRIVATE) - - features.each do |feature| - expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed" - end - end - - it "returns true when user is a member of project group" do - group = create(:group) - project = create(:project, namespace: group) - group.add_developer(user) - - update_all_project_features(project, features, ProjectFeature::PRIVATE) - - features.each do |feature| - expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed" - end - end - - context 'when admin mode is enabled', :enable_admin_mode do - it "returns true if user is an admin" do - user.update_attribute(:admin, true) - - update_all_project_features(project, features, ProjectFeature::PRIVATE) - - features.each do |feature| - expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed" - end - end - end - - context 'when admin mode is disabled' do - it "returns false when user is an admin" do - user.update_attribute(:admin, true) - - update_all_project_features(project, features, ProjectFeature::PRIVATE) - - features.each do |feature| - expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" - end - end - end - end - - context 'when feature is enabled for everyone' do - it "returns true" do - expect(project.feature_available?(:issues, user)).to eq(true) - end - end - - context 'when feature is disabled by a feature flag' do - it 'returns false' do - stub_feature_flags(issues: false) - - expect(project.feature_available?(:issues, user)).to eq(false) - end - end - - context 'when feature is enabled by a feature flag' do - it 'returns true' do - stub_feature_flags(issues: true) - - expect(project.feature_available?(:issues, user)).to eq(true) - end - end - end - context 'repository related features' do before do project.project_feature.update( @@ -153,32 +53,6 @@ describe ProjectFeature do end end - describe '#*_enabled?' do - let(:features) { %w(wiki builds merge_requests) } - - it "returns false when feature is disabled" do - update_all_project_features(project, features, ProjectFeature::DISABLED) - - features.each do |feature| - expect(project.public_send("#{feature}_enabled?")).to eq(false), "#{feature} failed" - end - end - - it "returns true when feature is enabled only for team members" do - update_all_project_features(project, features, ProjectFeature::PRIVATE) - - features.each do |feature| - expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed" - end - end - - it "returns true when feature is enabled for everyone" do - features.each do |feature| - expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed" - end - end - end - describe 'default pages access level' do subject { project_feature.pages_access_level } @@ -313,9 +187,4 @@ describe ProjectFeature do expect(described_class.required_minimum_access_level_for_private_project(:issues)).to eq(Gitlab::Access::GUEST) end end - - def update_all_project_features(project, features, value) - project_feature_attributes = features.map { |f| ["#{f}_access_level", value] }.to_h - project.project_feature.update(project_feature_attributes) - end end diff --git a/spec/requests/api/graphql/mutations/merge_requests/create_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/create_spec.rb new file mode 100644 index 00000000000..5c63f655f1d --- /dev/null +++ b/spec/requests/api/graphql/mutations/merge_requests/create_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Creation of a new merge request' do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let(:project) { create(:project, :public, :repository) } + let(:input) do + { + project_path: project.full_path, + title: title, + source_branch: source_branch, + target_branch: target_branch + } + end + let(:title) { 'MergeRequest' } + let(:source_branch) { 'new_branch' } + let(:target_branch) { 'master' } + + let(:mutation) { graphql_mutation(:merge_request_create, input) } + let(:mutation_response) { graphql_mutation_response(:merge_request_create) } + + context 'the user is not allowed to create a branch' do + it_behaves_like 'a mutation that returns top-level errors', + errors: ['The resource that you are attempting to access does not exist or you don\'t have permission to perform this action'] + end + + context 'when user has permissions to create a merge request' do + before do + project.add_developer(current_user) + end + + it 'creates a new merge request' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['mergeRequest']).to include( + 'title' => title + ) + end + + context 'when source branch is equal to the target branch' do + let(:source_branch) { target_branch } + + it_behaves_like 'a mutation that returns errors in the response', + errors: ['Branch conflict You can\'t use same project/branch for source and target'] + end + end +end