diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-04-23 12:10:03 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-04-23 12:10:03 +0300 |
commit | 65f7976d0cd11d91a4c0945b2c63a1aa2f888b07 (patch) | |
tree | 07a0e774b12b29352ca6b3bd87b108879ebb00b9 /app | |
parent | 1165608bfd217a96e133487d6049a989a15789c4 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r-- | app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue | 126 | ||||
-rw-r--r-- | app/channels/graphql_channel.rb | 56 | ||||
-rw-r--r-- | app/controllers/graphql_controller.rb | 2 | ||||
-rw-r--r-- | app/graphql/gitlab_schema.rb | 2 | ||||
-rw-r--r-- | app/graphql/graphql_triggers.rb | 7 | ||||
-rw-r--r-- | app/graphql/subscriptions/base_subscription.rb | 31 | ||||
-rw-r--r-- | app/graphql/subscriptions/issuable_updated.rb | 29 | ||||
-rw-r--r-- | app/graphql/types/issuable_type.rb | 23 | ||||
-rw-r--r-- | app/graphql/types/subscription_type.rb | 10 | ||||
-rw-r--r-- | app/helpers/application_settings_helper.rb | 2 | ||||
-rw-r--r-- | app/models/concerns/routable.rb | 4 | ||||
-rw-r--r-- | app/models/project_services/youtrack_service.rb | 9 | ||||
-rw-r--r-- | app/models/user.rb | 4 | ||||
-rw-r--r-- | app/services/issues/update_service.rb | 20 | ||||
-rw-r--r-- | app/services/packages/debian/generate_distribution_key_service.rb | 106 | ||||
-rw-r--r-- | app/services/system_notes/base_service.rb | 6 | ||||
-rw-r--r-- | app/views/search/results/_user.html.haml | 6 |
17 files changed, 397 insertions, 46 deletions
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue index 288d6711682..07cc0ce46bc 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -20,6 +20,7 @@ import axios from '~/lib/utils/axios_utils'; import csrf from '~/lib/utils/csrf'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; +import validation from '~/vue_shared/directives/validation'; const PRIVATE_VISIBILITY = 'private'; const INTERNAL_VISIBILITY = 'internal'; @@ -31,6 +32,13 @@ const ALLOWED_VISIBILITY = { public: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY, PUBLIC_VISIBILITY], }; +const initFormField = ({ value, required = true, skipValidation = false }) => ({ + value, + required, + state: skipValidation ? true : null, + feedback: null, +}); + export default { components: { GlForm, @@ -46,6 +54,9 @@ export default { GlFormRadioGroup, GlFormSelect, }, + directives: { + validation: validation(), + }, inject: { newGroupPath: { default: '', @@ -77,7 +88,8 @@ export default { }, projectDescription: { type: String, - required: true, + required: false, + default: '', }, projectVisibility: { type: String, @@ -85,16 +97,30 @@ export default { }, }, data() { + const form = { + state: false, + showValidation: false, + fields: { + namespace: initFormField({ + value: null, + }), + name: initFormField({ value: this.projectName }), + slug: initFormField({ value: this.projectPath }), + description: initFormField({ + value: this.projectDescription, + required: false, + skipValidation: true, + }), + visibility: initFormField({ + value: this.projectVisibility, + skipValidation: true, + }), + }, + }; return { isSaving: false, namespaces: [], - selectedNamespace: {}, - fork: { - name: this.projectName, - slug: this.projectPath, - description: this.projectDescription, - visibility: this.projectVisibility, - }, + form, }; }, computed: { @@ -106,7 +132,7 @@ export default { }, namespaceAllowedVisibility() { return ( - ALLOWED_VISIBILITY[this.selectedNamespace.visibility] || + ALLOWED_VISIBILITY[this.form.fields.namespace.value?.visibility] || ALLOWED_VISIBILITY[PUBLIC_VISIBILITY] ); }, @@ -139,16 +165,17 @@ export default { }, }, watch: { - selectedNamespace(newVal) { + // eslint-disable-next-line func-names + 'form.fields.namespace.value': function (newVal) { const { visibility } = newVal; if (this.projectAllowedVisibility.includes(visibility)) { - this.fork.visibility = visibility; + this.form.fields.visibility.value = visibility; } }, // eslint-disable-next-line func-names - 'fork.name': function (newVal) { - this.fork.slug = kebabCase(newVal); + 'form.fields.name.value': function (newVal) { + this.form.fields.slug.value = kebabCase(newVal); }, }, mounted() { @@ -166,19 +193,25 @@ export default { ); }, async onSubmit() { + this.form.showValidation = true; + + if (!this.form.state) { + return; + } + this.isSaving = true; + this.form.showValidation = false; const { projectId } = this; - const { name, slug, description, visibility } = this.fork; - const { id: namespaceId } = this.selectedNamespace; + const { name, slug, description, visibility, namespace } = this.form.fields; const postParams = { id: projectId, - name, - namespace_id: namespaceId, - path: slug, - description, - visibility, + name: name.value, + namespace_id: namespace.value.id, + path: slug.value, + description: description.value, + visibility: visibility.value, }; const forkProjectPath = `/api/:version/projects/:id/fork`; @@ -198,16 +231,34 @@ export default { </script> <template> - <gl-form method="POST" @submit.prevent="onSubmit"> + <gl-form novalidate method="POST" @submit.prevent="onSubmit"> <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> - <gl-form-group label="Project name" label-for="fork-name"> - <gl-form-input id="fork-name" v-model="fork.name" data-testid="fork-name-input" required /> + <gl-form-group + :label="__('Project name')" + label-for="fork-name" + :invalid-feedback="form.fields.name.feedback" + > + <gl-form-input + id="fork-name" + v-model="form.fields.name.value" + v-validation:[form.showValidation] + name="name" + data-testid="fork-name-input" + :state="form.fields.name.state" + required + /> </gl-form-group> <div class="gl-md-display-flex"> <div class="gl-flex-basis-half"> - <gl-form-group label="Project URL" label-for="fork-url" class="gl-md-mr-3"> + <gl-form-group + :label="__('Project URL')" + label-for="fork-url" + class="gl-md-mr-3" + :state="form.fields.namespace.state" + :invalid-feedback="s__('ForkProject|Please select a namespace')" + > <gl-form-input-group> <template #prepend> <gl-input-group-text> @@ -216,9 +267,12 @@ export default { </template> <gl-form-select id="fork-url" - v-model="selectedNamespace" + v-model="form.fields.namespace.value" + v-validation:[form.showValidation] + name="namespace" data-testid="fork-url-input" data-qa-selector="fork_namespace_dropdown" + :state="form.fields.namespace.state" required > <template slot="first"> @@ -232,11 +286,19 @@ export default { </gl-form-group> </div> <div class="gl-flex-basis-half"> - <gl-form-group label="Project slug" label-for="fork-slug" class="gl-md-ml-3"> + <gl-form-group + :label="__('Project slug')" + label-for="fork-slug" + class="gl-md-ml-3" + :invalid-feedback="form.fields.slug.feedback" + > <gl-form-input id="fork-slug" - v-model="fork.slug" + v-model="form.fields.slug.value" + v-validation:[form.showValidation] data-testid="fork-slug-input" + name="slug" + :state="form.fields.slug.state" required /> </gl-form-group> @@ -250,11 +312,13 @@ export default { </gl-link> </p> - <gl-form-group label="Project description (optional)" label-for="fork-description"> + <gl-form-group :label="__('Project description (optional)')" label-for="fork-description"> <gl-form-textarea id="fork-description" - v-model="fork.description" + v-model="form.fields.description.value" data-testid="fork-description-textarea" + name="description" + :state="form.fields.description.state" /> </gl-form-group> @@ -266,8 +330,9 @@ export default { </gl-link> </label> <gl-form-radio-group - v-model="fork.visibility" + v-model="form.fields.visibility.value" data-testid="fork-visibility-radio-group" + name="visibility" required > <gl-form-radio @@ -291,6 +356,7 @@ export default { type="submit" category="primary" variant="confirm" + class="js-no-auto-disable" data-testid="submit-button" data-qa-selector="fork_project_button" :loading="isSaving" diff --git a/app/channels/graphql_channel.rb b/app/channels/graphql_channel.rb new file mode 100644 index 00000000000..d364cc2b64b --- /dev/null +++ b/app/channels/graphql_channel.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# This is based on https://github.com/rmosolgo/graphql-ruby/blob/v1.11.8/lib/graphql/subscriptions/action_cable_subscriptions.rb#L19-L82 +# modified to work with our own ActionCableLink client + +class GraphqlChannel < ApplicationCable::Channel # rubocop:disable Gitlab/NamespacedClass + def subscribed + @subscription_ids = [] + + query = params['query'] + variables = Gitlab::Graphql::Variables.new(params['variables']).to_h + operation_name = params['operationName'] + + result = GitlabSchema.execute( + query, + context: context, + variables: variables, + operation_name: operation_name + ) + + payload = { + result: result.to_h, + more: result.subscription? + } + + # Track the subscription here so we can remove it + # on unsubscribe. + if result.context[:subscription_id] + @subscription_ids << result.context[:subscription_id] + end + + transmit(payload) + end + + def unsubscribed + @subscription_ids.each do |sid| + GitlabSchema.subscriptions.delete_subscription(sid) + end + end + + rescue_from Gitlab::Graphql::Variables::Invalid do |exception| + transmit({ errors: [{ message: exception.message }] }) + end + + private + + # When modifying the context, also update GraphqlController#context if needed + # so that we have similar context when executing queries, mutations, and subscriptions + # + # Objects added to the context may also need to be reloaded in + # `Subscriptions::BaseSubscription` so that they are not stale + def context + # is_sessionless_user is always false because we only support cookie auth in ActionCable + { channel: self, current_user: current_user, is_sessionless_user: false } + end +end diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index a13ec1daddb..b7daff04373 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -109,6 +109,8 @@ class GraphqlController < ApplicationController end end + # When modifying the context, also update GraphqlChannel#context if needed + # so that we have similar context when executing queries, mutations, and subscriptions def context @context ||= { current_user: current_user, is_sessionless_user: !!sessionless_user?, request: request } end diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index 8369d0e120f..3778c928ec5 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -10,6 +10,7 @@ class GitlabSchema < GraphQL::Schema DEFAULT_MAX_DEPTH = 15 AUTHENTICATED_MAX_DEPTH = 20 + use GraphQL::Subscriptions::ActionCableSubscriptions use GraphQL::Pagination::Connections use BatchLoader::GraphQL use Gitlab::Graphql::Pagination::Connections @@ -24,6 +25,7 @@ class GitlabSchema < GraphQL::Schema query Types::QueryType mutation Types::MutationType + subscription Types::SubscriptionType default_max_page_size 100 diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb new file mode 100644 index 00000000000..671c7c2cd25 --- /dev/null +++ b/app/graphql/graphql_triggers.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module GraphqlTriggers + def self.issuable_assignees_updated(issuable) + GitlabSchema.subscriptions.trigger('issuableAssigneesUpdated', { issuable_id: issuable.to_gid }, issuable) + end +end diff --git a/app/graphql/subscriptions/base_subscription.rb b/app/graphql/subscriptions/base_subscription.rb new file mode 100644 index 00000000000..5f7931787df --- /dev/null +++ b/app/graphql/subscriptions/base_subscription.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Subscriptions + class BaseSubscription < GraphQL::Schema::Subscription + object_class Types::BaseObject + field_class Types::BaseField + + def initialize(object:, context:, field:) + super + + # Reset user so that we don't use a stale user for authorization + current_user.reset if current_user + end + + def authorized?(*) + raise NotImplementedError + end + + private + + def unauthorized! + unsubscribe if context.query.subscription_update? + + raise GraphQL::ExecutionError, 'Unauthorized subscription' + end + + def current_user + context[:current_user] + end + end +end diff --git a/app/graphql/subscriptions/issuable_updated.rb b/app/graphql/subscriptions/issuable_updated.rb new file mode 100644 index 00000000000..c1d82bfcf9c --- /dev/null +++ b/app/graphql/subscriptions/issuable_updated.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Subscriptions + class IssuableUpdated < BaseSubscription + include Gitlab::Graphql::Laziness + + payload_type Types::IssuableType + + argument :issuable_id, Types::GlobalIDType[Issuable], + required: true, + description: 'ID of the issuable.' + + def subscribe(issuable_id:) + nil + end + + def authorized?(issuable_id:) + # TODO: remove this check when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + raise Gitlab::Graphql::Errors::ArgumentError, 'Invalid IssuableID' unless issuable_id.is_a?(GlobalID) + + issuable = force(GitlabSchema.find_by_gid(issuable_id)) + + unauthorized! unless issuable && Ability.allowed?(current_user, :"read_#{issuable.to_ability_name}", issuable) + + true + end + end +end diff --git a/app/graphql/types/issuable_type.rb b/app/graphql/types/issuable_type.rb new file mode 100644 index 00000000000..fc57491bb59 --- /dev/null +++ b/app/graphql/types/issuable_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + class IssuableType < BaseUnion + graphql_name 'Issuable' + description 'Represents an issuable.' + + possible_types Types::IssueType, Types::MergeRequestType + + def self.resolve_type(object, context) + case object + when Issue + Types::IssueType + when MergeRequest + Types::MergeRequestType + else + raise 'Unsupported issuable type' + end + end + end +end + +Types::IssuableType.prepend_if_ee('::EE::Types::IssuableType') diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb new file mode 100644 index 00000000000..5356a998f0d --- /dev/null +++ b/app/graphql/types/subscription_type.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + class SubscriptionType < ::Types::BaseObject + graphql_name 'Subscription' + + field :issuable_assignees_updated, subscription: Subscriptions::IssuableUpdated, null: true, + description: 'Triggered when the assignees of an issuable are updated.' + end +end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index cda87cbd212..4373f47877f 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -393,7 +393,7 @@ module ApplicationSettingsHelper end def integration_expanded?(substring) - @application_setting.errors.any? { |k| k.to_s.start_with?(substring) } + @application_setting.errors.messages.any? { |k, _| k.to_s.start_with?(substring) } end def instance_clusters_enabled? diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 71d8e06de76..5881997ea56 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -124,7 +124,9 @@ module Routable def set_path_errors route_path_errors = self.errors.delete(:"route.path") - self.errors[:path].concat(route_path_errors) if route_path_errors + route_path_errors&.each do |msg| + self.errors.add(:path, msg) + end end def full_name_changed? diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb index 30abd0159b3..94cbd6c5959 100644 --- a/app/models/project_services/youtrack_service.rb +++ b/app/models/project_services/youtrack_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class YoutrackService < IssueTrackerService + include ActionView::Helpers::UrlHelper + validates :project_url, :issues_url, presence: true, public_url: true, if: :activated? # {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1, gl-030 @@ -17,7 +19,12 @@ class YoutrackService < IssueTrackerService end def description - s_('IssueTracker|YouTrack issue tracker') + s_("IssueTracker|Use YouTrack as this project's issue tracker.") + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/youtrack'), target: '_blank', rel: 'noopener noreferrer' + s_("IssueTracker|Use YouTrack as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } end def self.to_param diff --git a/app/models/user.rb b/app/models/user.rb index 92d11d231ec..a5ef8d64133 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1414,7 +1414,9 @@ class User < ApplicationRecord if namespace_path_errors.include?('has already been taken') && !User.exists?(username: username) self.errors.add(:base, :username_exists_as_a_different_namespace) else - self.errors[:username].concat(namespace_path_errors) + namespace_path_errors.each do |msg| + self.errors.add(:username, msg) + end end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 702527d80a7..e05df029ae3 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -61,12 +61,7 @@ module Issues todo_service.update_issue(issue, current_user, old_mentioned_users) end - if issue.assignees != old_assignees - create_assignee_note(issue, old_assignees) - notification_service.async.reassigned_issue(issue, current_user, old_assignees) - todo_service.reassigned_assignable(issue, current_user, old_assignees) - track_incident_action(current_user, issue, :incident_assigned) - end + handle_assignee_changes(issue, old_assignees) if issue.previous_changes.include?('confidential') # don't enqueue immediately to prevent todos removal in case of a mistake @@ -90,6 +85,19 @@ module Issues end end + def handle_assignee_changes(issue, old_assignees) + return if issue.assignees == old_assignees + + create_assignee_note(issue, old_assignees) + notification_service.async.reassigned_issue(issue, current_user, old_assignees) + todo_service.reassigned_assignable(issue, current_user, old_assignees) + track_incident_action(current_user, issue, :incident_assigned) + + if Gitlab::ActionCable::Config.in_app? || Feature.enabled?(:broadcast_issue_updates, issue.project) + GraphqlTriggers.issuable_assignees_updated(issue) + end + end + def handle_task_changes(issuable) todo_service.resolve_todos_for_target(issuable, current_user) todo_service.update_issue(issuable, current_user) diff --git a/app/services/packages/debian/generate_distribution_key_service.rb b/app/services/packages/debian/generate_distribution_key_service.rb new file mode 100644 index 00000000000..28c97c7681e --- /dev/null +++ b/app/services/packages/debian/generate_distribution_key_service.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Packages + module Debian + class GenerateDistributionKeyService + include Gitlab::Utils::StrongMemoize + + def initialize(current_user:, params: {}) + @current_user = current_user + @params = params + end + + def execute + raise ArgumentError, 'Please provide a user' unless current_user.is_a?(User) + + generate_key + end + + private + + attr_reader :current_user, :params + + def passphrase + strong_memoize(:passphrase) do + params[:passphrase] || ::User.random_password + end + end + + def pinentry_script_content + escaped_passphrase = Shellwords.escape(passphrase) + + <<~EOF + #!/bin/sh + + echo OK Pleased to meet you + + while read -r cmd; do + case "$cmd" in + GETPIN) echo D #{escaped_passphrase}; echo OK;; + *) echo OK;; + esac + done + EOF + end + + def using_pinentry + Gitlab::Gpg.using_tmp_keychain do + home_dir = Gitlab::Gpg.current_home_dir + + File.write("#{home_dir}/pinentry.sh", pinentry_script_content, mode: 'w', perm: 0755) + + File.write("#{home_dir}/gpg-agent.conf", "pinentry-program #{home_dir}/pinentry.sh\n", mode: 'w') + + GPGME::Ctx.new(armor: true, offline: true) do |ctx| + yield ctx + end + end + end + + def generate_key_params + # https://www.gnupg.org/documentation/manuals/gnupg/Unattended-GPG-key-generation.html + '<GnupgKeyParms format="internal">' + "\n" + + { + 'Key-Type': params[:key_type] || 'RSA', + 'Key-Length': params[:key_length] || 4096, + 'Key-Usage': params[:key_usage] || 'sign', + 'Name-Real': params[:name_real] || 'GitLab Debian repository', + 'Name-Email': params[:name_email] || Gitlab.config.gitlab.email_reply_to, + 'Name-Comment': params[:name_comment] || 'GitLab Debian repository automatic signing key', + 'Expire-Date': params[:expire_date] || 0, + 'Passphrase': passphrase + }.map { |k, v| "#{k}: #{v}\n" }.join + + '</GnupgKeyParms>' + end + + def generate_key + using_pinentry do |ctx| + # Generate key + ctx.generate_key generate_key_params + + key = ctx.keys.first # rubocop:disable Gitlab/KeysFirstAndValuesFirst + fingerprint = key.fingerprint + + # Export private key + data = GPGME::Data.new + ctx.export_keys fingerprint, data, GPGME::EXPORT_MODE_SECRET + data.seek 0 + private_key = data.read + + # Export public key + data = GPGME::Data.new + ctx.export_keys fingerprint, data + data.seek 0 + public_key = data.read + + { + private_key: private_key, + public_key: public_key, + passphrase: passphrase, + fingerprint: fingerprint + } + end + end + end + end +end diff --git a/app/services/system_notes/base_service.rb b/app/services/system_notes/base_service.rb index 7341a25b133..ee7784c127b 100644 --- a/app/services/system_notes/base_service.rb +++ b/app/services/system_notes/base_service.rb @@ -13,10 +13,10 @@ module SystemNotes protected def create_note(note_summary) - note = Note.create(note_summary.note.merge(system: true)) - note.system_note_metadata = SystemNoteMetadata.new(note_summary.metadata) if note_summary.metadata? + note_params = note_summary.note.merge(system: true) + note_params[:system_note_metadata] = SystemNoteMetadata.new(note_summary.metadata) if note_summary.metadata? - note + Note.create(note_params) end def content_tag(*args) diff --git a/app/views/search/results/_user.html.haml b/app/views/search/results/_user.html.haml index 8060a1577e4..9e70d9c9baa 100644 --- a/app/views/search/results/_user.html.haml +++ b/app/views/search/results/_user.html.haml @@ -1,9 +1,9 @@ %ul.content-list %li - .avatar-cell.d-none.d-sm-block - = user_avatar(user: user, user_name: user.name, css_class: 'd-none d-sm-inline avatar s40') + .avatar-cell + = user_avatar(user: user, size: 40, user_name: user.name) .user-info - = link_to user_path(user), class: 'd-none d-sm-inline' do + = link_to user_path(user) do .item-title = user.name = user_status(user) |