diff options
59 files changed, 811 insertions, 366 deletions
diff --git a/app/assets/javascripts/blob/blob_utils.js b/app/assets/javascripts/blob/blob_utils.js deleted file mode 100644 index 27fcc7f7b79..00000000000 --- a/app/assets/javascripts/blob/blob_utils.js +++ /dev/null @@ -1,5 +0,0 @@ -// capture anything starting with http:// or https:// -// up until a disallowed character or whitespace -export const blobLinkRegex = /https?:\/\/[^"<>\\^`{|}\s]+/g; - -export default { blobLinkRegex }; diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index f032c2f216b..07e4dde41d9 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -4,10 +4,6 @@ import Flash from '../../flash'; import { handleLocationHash } from '../../lib/utils/common_utils'; import axios from '../../lib/utils/axios_utils'; import { __ } from '~/locale'; -import { blobLinkRegex } from '~/blob/blob_utils'; - -const SIMPLE_VIEWER_NAME = 'simple'; -const RICH_VIEWER_NAME = 'rich'; export default class BlobViewer { constructor() { @@ -25,7 +21,7 @@ export default class BlobViewer { } static initRichViewer() { - const viewer = document.querySelector(`.blob-viewer[data-type="${RICH_VIEWER_NAME}"]`); + const viewer = document.querySelector('.blob-viewer[data-type="rich"]'); if (!viewer || !viewer.dataset.richType) return; const initViewer = promise => @@ -65,12 +61,8 @@ export default class BlobViewer { this.switcherBtns = document.querySelectorAll('.js-blob-viewer-switch-btn'); this.copySourceBtn = document.querySelector('.js-copy-blob-source-btn'); - this.simpleViewer = this.$fileHolder[0].querySelector( - `.blob-viewer[data-type="${SIMPLE_VIEWER_NAME}"]`, - ); - this.richViewer = this.$fileHolder[0].querySelector( - `.blob-viewer[data-type="${RICH_VIEWER_NAME}"]`, - ); + this.simpleViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="simple"]'); + this.richViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="rich"]'); this.initBindings(); @@ -79,10 +71,10 @@ export default class BlobViewer { switchToInitialViewer() { const initialViewer = this.$fileHolder[0].querySelector('.blob-viewer:not(.hidden)'); - let initialViewerName = initialViewer.dataset.type; + let initialViewerName = initialViewer.getAttribute('data-type'); if (this.switcher && window.location.hash.indexOf('#L') === 0) { - initialViewerName = SIMPLE_VIEWER_NAME; + initialViewerName = 'simple'; } this.switchToViewer(initialViewerName); @@ -99,41 +91,35 @@ export default class BlobViewer { this.copySourceBtn.addEventListener('click', () => { if (this.copySourceBtn.classList.contains('disabled')) return this.copySourceBtn.blur(); - return this.switchToViewer(SIMPLE_VIEWER_NAME); + return this.switchToViewer('simple'); }); } } - static linkifyURLs(viewer) { - if (viewer.dataset.linkified) return; - - document.querySelectorAll('.js-blob-content .code .line').forEach(line => { - // eslint-disable-next-line no-param-reassign - line.innerHTML = line.innerHTML.replace(blobLinkRegex, '<a href="$&">$&</a>'); - }); - - // eslint-disable-next-line no-param-reassign - viewer.dataset.linkified = true; - } - switchViewHandler(e) { const target = e.currentTarget; e.preventDefault(); - this.switchToViewer(target.dataset.viewer); + this.switchToViewer(target.getAttribute('data-viewer')); } toggleCopyButtonState() { if (!this.copySourceBtn) return; - if (this.simpleViewer.dataset.loaded) { - this.copySourceBtn.dataset.title = __('Copy file contents'); + if (this.simpleViewer.getAttribute('data-loaded')) { + this.copySourceBtn.setAttribute('title', __('Copy file contents')); this.copySourceBtn.classList.remove('disabled'); } else if (this.activeViewer === this.simpleViewer) { - this.copySourceBtn.dataset.title = __('Wait for the file to load to copy its contents'); + this.copySourceBtn.setAttribute( + 'title', + __('Wait for the file to load to copy its contents'), + ); this.copySourceBtn.classList.add('disabled'); } else { - this.copySourceBtn.dataset.title = __('Switch to the source to copy the file contents'); + this.copySourceBtn.setAttribute( + 'title', + __('Switch to the source to copy the file contents'), + ); this.copySourceBtn.classList.add('disabled'); } @@ -173,8 +159,6 @@ export default class BlobViewer { this.$fileHolder.trigger('highlight:line'); handleLocationHash(); - if (name === SIMPLE_VIEWER_NAME) BlobViewer.linkifyURLs(viewer); - this.toggleCopyButtonState(); }) .catch(() => new Flash(__('Error loading viewer'))); @@ -182,17 +166,17 @@ export default class BlobViewer { static loadViewer(viewerParam) { const viewer = viewerParam; - const { url, loaded, loading } = viewer.dataset; + const url = viewer.getAttribute('data-url'); - if (!url || loaded || loading) { + if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) { return Promise.resolve(viewer); } - viewer.dataset.loading = true; + viewer.setAttribute('data-loading', 'true'); return axios.get(url).then(({ data }) => { viewer.innerHTML = data.html; - viewer.dataset.loaded = true; + viewer.setAttribute('data-loaded', 'true'); return viewer; }); diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 8561f650e8f..011898a5e7a 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -4,8 +4,7 @@ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import { __ } from '~/locale'; -import { blobLinkRegex } from '~/blob/blob_utils'; -import TemplateSelectorMediator from '~/blob/file_template_mediator'; +import TemplateSelectorMediator from '../blob/file_template_mediator'; import getModeByFileExtension from '~/lib/utils/ace_utils'; import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; @@ -18,7 +17,6 @@ export default class EditBlob { this.initModePanesAndLinks(); this.initSoftWrap(); this.initFileSelectors(); - this.initBlobContentLinkClickability(); } configureAceEditor() { @@ -91,22 +89,6 @@ export default class EditBlob { return this.editor.focus(); } - initBlobContentLinkClickability() { - this.editor.renderer.on('afterRender', () => { - document.querySelectorAll('.ace_text-layer .ace_line > *').forEach(token => { - if (token.dataset.linkified || !token.textContent.includes('http')) return; - - // eslint-disable-next-line no-param-reassign - token.innerHTML = token.innerHTML.replace( - blobLinkRegex, - '<a target="_blank" href="$&">$&</a>', - ); - // eslint-disable-next-line no-param-reassign - token.dataset.linkified = true; - }); - }); - } - initSoftWrap() { this.isSoftWrapped = false; this.$toggleButton = $('.soft-wrap-toggle'); diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 1273fcc6a91..b8439bc8741 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -84,7 +84,8 @@ export default { this.$nextTick(() => { if ( this.scrollHeight() <= this.listHeight() && - this.list.issuesSize > this.list.issues.length + this.list.issuesSize > this.list.issues.length && + this.list.isExpanded ) { this.list.page += 1; this.list.getIssues(false).catch(() => { diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 1e213c324eb..bb8c8e68297 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -50,8 +50,8 @@ class List { this.page = 1; this.loading = true; this.loadingMore = false; - this.issues = []; - this.issuesSize = 0; + this.issues = obj.issues || []; + this.issuesSize = obj.issuesSize ? obj.issuesSize : 0; this.defaultAvatar = defaultAvatar; if (obj.label) { diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index fc4944d731e..4938215b2e7 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -258,17 +258,6 @@ } } } - - .file-editor { - .ace_underline { - text-decoration: none; - } - - .ace_line a { - pointer-events: auto; - color: inherit; - } - } } span.idiff { diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss index 95f6fb8c333..bdeac7e97c0 100644 --- a/app/assets/stylesheets/highlight/common.scss +++ b/app/assets/stylesheets/highlight/common.scss @@ -29,12 +29,3 @@ color: $link; } } - -// Links to URLs, emails, or dependencies -.code .line a { - color: inherit; - - &:hover { - text-decoration: underline; - } -} diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss index 16e6824baf8..cbce0ba3f1e 100644 --- a/app/assets/stylesheets/highlight/themes/dark.scss +++ b/app/assets/stylesheets/highlight/themes/dark.scss @@ -193,6 +193,11 @@ $dark-il: #de935f; color: $dark-highlight-color !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $dark-na; + } + .hll { background-color: $dark-hll-bg; } .c { color: $dark-c; } /* Comment */ .err { color: $dark-err; } /* Error */ diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss index cfbb7a1db94..1b61ffa37e3 100644 --- a/app/assets/stylesheets/highlight/themes/monokai.scss +++ b/app/assets/stylesheets/highlight/themes/monokai.scss @@ -193,6 +193,11 @@ $monokai-gi: #a6e22e; color: $black !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $monokai-k; + } + .hll { background-color: $monokai-hll; } .c { color: $monokai-c; } /* Comment */ .err { color: $monokai-err-color; background-color: $monokai-err-bg; } /* Error */ diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss index a099563542d..a7ede266fb5 100644 --- a/app/assets/stylesheets/highlight/themes/none.scss +++ b/app/assets/stylesheets/highlight/themes/none.scss @@ -143,6 +143,12 @@ background-color: $white-normal; } + // Links to URLs, emails, or dependencies + .line a { + color: $gl-text-color; + text-decoration: underline; + } + .hll { background-color: $white-light; } .gd { diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss index d74d5c6ebda..6569f3abc8b 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss @@ -196,6 +196,11 @@ $solarized-dark-il: #2aa198; background-color: $solarized-dark-highlight !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $solarized-dark-kd; + } + /* Solarized Dark For use with Jekyll and Pygments diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss index d995c5bba1f..4e74a9ea50a 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-light.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss @@ -204,6 +204,11 @@ $solarized-light-il: #2aa198; background-color: $solarized-light-highlight !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $solarized-light-kd; + } + /* Solarized Light For use with Jekyll and Pygments diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index c58cf89f0ca..973f94c63aa 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -209,6 +209,11 @@ span.highlight_word { background-color: $white-highlight !important; } +// Links to URLs, emails, or dependencies +.line a { + color: $white-nb; +} + .hll { background-color: $white-hll-bg; } .c { color: $white-c; diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e4d878641b3..c85b192b34a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -20,11 +20,11 @@ class ApplicationController < ActionController::Base before_action :authenticate_user!, except: [:route_not_found] before_action :enforce_terms!, if: :should_enforce_terms? before_action :validate_user_service_ticket! - before_action :check_password_expiration, if: :html_request? + before_action :check_password_expiration before_action :ldap_security_check before_action :sentry_context before_action :default_headers - before_action :add_gon_variables, if: :html_request? + before_action :add_gon_variables, unless: [:peek_request?, :json_request?] before_action :configure_permitted_parameters, if: :devise_controller? before_action :require_email, unless: :devise_controller? before_action :active_user_check, unless: :devise_controller? @@ -455,8 +455,8 @@ class ApplicationController < ActionController::Base response.headers['Page-Title'] = URI.escape(page_title('GitLab')) end - def html_request? - request.format.html? + def peek_request? + request.path.start_with?('/-/peek') end def json_request? @@ -466,7 +466,7 @@ class ApplicationController < ActionController::Base def should_enforce_terms? return false unless Gitlab::CurrentSettings.current_application_settings.enforce_terms - html_request? && !devise_controller? + !(peek_request? || devise_controller?) end def set_usage_stats_consent_flag diff --git a/app/controllers/concerns/confirm_email_warning.rb b/app/controllers/concerns/confirm_email_warning.rb index 32e1a46e580..86df0010665 100644 --- a/app/controllers/concerns/confirm_email_warning.rb +++ b/app/controllers/concerns/confirm_email_warning.rb @@ -4,18 +4,15 @@ module ConfirmEmailWarning extend ActiveSupport::Concern included do - before_action :set_confirm_warning, if: :show_confirm_warning? + before_action :set_confirm_warning, if: -> { Feature.enabled?(:soft_email_confirmation) } end protected - def show_confirm_warning? - html_request? && request.get? && Feature.enabled?(:soft_email_confirmation) - end - def set_confirm_warning return unless current_user return if current_user.confirmed? + return if peek_request? || json_request? || !request.get? email = current_user.unconfirmed_email || current_user.email diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb index 023c41821da..b87779c22d3 100644 --- a/app/controllers/concerns/uploads_actions.rb +++ b/app/controllers/concerns/uploads_actions.rb @@ -1,16 +1,11 @@ # frozen_string_literal: true module UploadsActions - extend ActiveSupport::Concern include Gitlab::Utils::StrongMemoize include SendFileUpload UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo favicon).freeze - included do - prepend_before_action :set_request_format_from_path_extension - end - def create uploader = UploadService.new(model, params[:file], uploader_class).execute @@ -69,18 +64,6 @@ module UploadsActions private - # From ActionDispatch::Http::MimeNegotiation. We have an initializer that - # monkey-patches this method out (so that repository paths don't guess a - # format based on extension), but we do want this behaviour when serving - # uploads. - def set_request_format_from_path_extension - path = request.headers['action_dispatch.original_path'] || request.headers['PATH_INFO'] - - if match = path&.match(/\.(\w+)\z/) - request.format = match.captures.first - end - end - def uploader_class raise NotImplementedError end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index f39a2b81b54..635db386792 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -20,7 +20,7 @@ class UploadsController < ApplicationController skip_before_action :authenticate_user! before_action :upload_mount_satisfied? - before_action :model + before_action :find_model before_action :authorize_access!, only: [:show] before_action :authorize_create_access!, only: [:create, :authorize] before_action :verify_workhorse_api!, only: [:authorize] diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb index 4e489a9c930..1f6829a97d6 100644 --- a/app/finders/group_descendants_finder.rb +++ b/app/finders/group_descendants_finder.rb @@ -80,7 +80,7 @@ class GroupDescendantsFinder if current_user authorized_groups = GroupsFinder.new(current_user, all_available: false) - .execute.as('authorized') + .execute.arel.as('authorized') authorized_to_user = groups_table.project(1).from(authorized_groups) .where(authorized_groups[:id].eq(groups_table[:id])) .exists diff --git a/app/graphql/mutations/todos/base.rb b/app/graphql/mutations/todos/base.rb new file mode 100644 index 00000000000..b6c7b320be1 --- /dev/null +++ b/app/graphql/mutations/todos/base.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Mutations + module Todos + class Base < ::Mutations::BaseMutation + private + + def find_object(id:) + GitlabSchema.object_from_id(id) + end + + def to_global_id(id) + ::URI::GID.build(app: GlobalID.app, model_name: Todo.name, model_id: id, params: nil).to_s + end + end + end +end diff --git a/app/graphql/mutations/todos/mark_done.rb b/app/graphql/mutations/todos/mark_done.rb new file mode 100644 index 00000000000..5483708b5c6 --- /dev/null +++ b/app/graphql/mutations/todos/mark_done.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Mutations + module Todos + class MarkDone < ::Mutations::Todos::Base + graphql_name 'TodoMarkDone' + + authorize :update_todo + + argument :id, + GraphQL::ID_TYPE, + required: true, + description: 'The global id of the todo to mark as done' + + field :todo, Types::TodoType, + null: false, + description: 'The requested todo' + + # rubocop: disable CodeReuse/ActiveRecord + def resolve(id:) + todo = authorized_find!(id: id) + mark_done(Todo.where(id: todo.id)) unless todo.done? + + { + todo: todo.reset, + errors: errors_on_object(todo) + } + end + # rubocop: enable CodeReuse/ActiveRecord + + private + + def mark_done(todo) + TodoService.new.mark_todos_as_done(todo, current_user) + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index c636bf0e31f..632e1fdc2df 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -16,6 +16,7 @@ module Types mount_mutation Mutations::Notes::Create::ImageDiffNote, calls_gitaly: true mount_mutation Mutations::Notes::Update mount_mutation Mutations::Notes::Destroy + mount_mutation Mutations::Todos::MarkDone end end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 12926bc2379..f9840e13e03 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -405,7 +405,7 @@ module Ci .where('stage=sg.stage').failed_but_allowed.to_sql stages_with_statuses = CommitStatus.from(stages_query, :sg) - .pluck('sg.stage', status_sql, "(#{warnings_sql})") + .pluck('sg.stage', Arel.sql(status_sql), Arel.sql("(#{warnings_sql})")) stages_with_statuses.map do |stage| Ci::LegacyStage.new(self, Hash[%i[name status warnings].zip(stage)]) diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 796e6438a2c..01cd1e0224b 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -118,8 +118,8 @@ module Issuable # rubocop:enable GitlabSecurity/SqlInjection scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") } - scope :order_milestone_due_desc, -> { left_joins_milestones.reorder('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC') } - scope :order_milestone_due_asc, -> { left_joins_milestones.reorder('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC') } + scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) } + scope :order_milestone_due_asc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC')) } 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 :any_label, -> { joins(:label_links).group(:id) } diff --git a/app/models/group.rb b/app/models/group.rb index 71d81289bf5..7496fee0b51 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -126,7 +126,7 @@ class Group < Namespace def visible_to_user_arel(user) groups_table = self.arel_table - authorized_groups = user.authorized_groups.as('authorized') + authorized_groups = user.authorized_groups.arel.as('authorized') groups_table.project(1) .from(authorized_groups) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 5e3cef0a52f..b85285978ab 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -796,6 +796,8 @@ class MergeRequest < ApplicationRecord end def check_mergeability + return if Feature.enabled?(:merge_requests_conditional_mergeability_check, default_enabled: true) && !recheck_merge_status? + MergeRequests::MergeabilityCheckService.new(self).execute(retry_lease: false) end # rubocop: enable CodeReuse/ServiceClass diff --git a/app/models/project.rb b/app/models/project.rb index bafde342157..9ee162df241 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1918,7 +1918,7 @@ class Project < ApplicationRecord end def default_environment - production_first = "(CASE WHEN name = 'production' THEN 0 ELSE 1 END), id ASC" + production_first = Arel.sql("(CASE WHEN name = 'production' THEN 0 ELSE 1 END), id ASC") environments .with_state(:available) diff --git a/app/models/todo.rb b/app/models/todo.rb index 1927b54510e..4e48fb3b782 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -160,6 +160,10 @@ class Todo < ApplicationRecord action == ASSIGNED end + def done? + state == 'done' + end + def action_name ACTION_NAMES[action] end diff --git a/app/policies/todo_policy.rb b/app/policies/todo_policy.rb index f8644217f04..d01a046c343 100644 --- a/app/policies/todo_policy.rb +++ b/app/policies/todo_policy.rb @@ -7,4 +7,5 @@ class TodoPolicy < BasePolicy end rule { own_todo }.enable :read_todo + rule { own_todo }.enable :update_todo end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index ff140444c1c..30e2a66e04a 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -149,7 +149,7 @@ module Ci # this returns builds that are ordered by number of running builds # we prefer projects that don't use shared runners at all joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id=project_builds.project_id") - .order('COALESCE(project_builds.running_builds, 0) ASC', 'ci_builds.id ASC') + .order(Arel.sql('COALESCE(project_builds.running_builds, 0) ASC'), 'ci_builds.id ASC') end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/cohorts_service.rb b/app/services/cohorts_service.rb index 97fbb70f350..dbbe89ef260 100644 --- a/app/services/cohorts_service.rb +++ b/app/services/cohorts_service.rb @@ -88,7 +88,7 @@ class CohortsService User .where('created_at > ?', MONTHS_INCLUDED.months.ago.end_of_month) .group(created_at_month, last_activity_on_month) - .reorder("#{created_at_month} ASC", "#{last_activity_on_month} ASC") + .reorder(Arel.sql("#{created_at_month} ASC, #{last_activity_on_month} ASC")) .count end end diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml index 4e609f50993..d7e57fc0d01 100644 --- a/app/views/shared/_file_highlight.html.haml +++ b/app/views/shared/_file_highlight.html.haml @@ -10,7 +10,7 @@ %a.diff-line-num{ href: "#{link}#L#{i}", id: "L#{i}", 'data-line-number' => i } = link_icon = i - .blob-content.js-blob-content{ data: { blob_id: blob.id } } + .blob-content{ data: { blob_id: blob.id } } %pre.code.highlight %code = blob.present.highlight diff --git a/changelogs/unreleased/26207-issue-board-loading-infinite-if-closing-the-closed-row.yml b/changelogs/unreleased/26207-issue-board-loading-infinite-if-closing-the-closed-row.yml new file mode 100644 index 00000000000..f1b7e8a948e --- /dev/null +++ b/changelogs/unreleased/26207-issue-board-loading-infinite-if-closing-the-closed-row.yml @@ -0,0 +1,5 @@ +--- +title: Fix closed board list loading issue +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/31914-graphql-todos-mark-todo-as-done-pd.yml b/changelogs/unreleased/31914-graphql-todos-mark-todo-as-done-pd.yml new file mode 100644 index 00000000000..2a5a7a2ec5e --- /dev/null +++ b/changelogs/unreleased/31914-graphql-todos-mark-todo-as-done-pd.yml @@ -0,0 +1,5 @@ +--- +title: Mark todo done by GraphQL API +merge_request: 18581 +author: +type: added diff --git a/changelogs/unreleased/id-conditional-check-mergeability.yml b/changelogs/unreleased/id-conditional-check-mergeability.yml new file mode 100644 index 00000000000..1b52c86df59 --- /dev/null +++ b/changelogs/unreleased/id-conditional-check-mergeability.yml @@ -0,0 +1,5 @@ +--- +title: Run check_mergeability only if merge status requires it +merge_request: 19364 +author: +type: performance diff --git a/changelogs/unreleased/mfluharty-clickable-links-in-file-view.yml b/changelogs/unreleased/mfluharty-clickable-links-in-file-view.yml deleted file mode 100644 index e6d42e10c19..00000000000 --- a/changelogs/unreleased/mfluharty-clickable-links-in-file-view.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Make URLs in blob viewer and blob editor into clickable links -merge_request: 18305 -author: -type: added diff --git a/db/migrate/20191112115247_add_cached_markdown_version_to_vulnerabilities.rb b/db/migrate/20191112115247_add_cached_markdown_version_to_vulnerabilities.rb new file mode 100644 index 00000000000..b0c513737e8 --- /dev/null +++ b/db/migrate/20191112115247_add_cached_markdown_version_to_vulnerabilities.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddCachedMarkdownVersionToVulnerabilities < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + add_column :vulnerabilities, :cached_markdown_version, :integer + end +end diff --git a/db/post_migrate/20191112115317_change_vulnerabilities_title_html_to_nullable.rb b/db/post_migrate/20191112115317_change_vulnerabilities_title_html_to_nullable.rb new file mode 100644 index 00000000000..6e0f3247410 --- /dev/null +++ b/db/post_migrate/20191112115317_change_vulnerabilities_title_html_to_nullable.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ChangeVulnerabilitiesTitleHtmlToNullable < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + change_column_null :vulnerabilities, :title_html, true + end +end diff --git a/db/schema.rb b/db/schema.rb index b5991904302..0c816d4764e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_11_11_115431) do +ActiveRecord::Schema.define(version: 2019_11_12_115317) do # These are extensions that must be enabled in order to support this database enable_extension "pg_trgm" @@ -3928,7 +3928,7 @@ ActiveRecord::Schema.define(version: 2019_11_11_115431) do t.datetime_with_timezone "created_at", null: false t.datetime_with_timezone "updated_at", null: false t.string "title", limit: 255, null: false - t.text "title_html", null: false + t.text "title_html" t.text "description" t.text "description_html" t.bigint "start_date_sourcing_milestone_id" @@ -3941,6 +3941,7 @@ ActiveRecord::Schema.define(version: 2019_11_11_115431) do t.integer "confidence", limit: 2, null: false t.boolean "confidence_overridden", default: false t.integer "report_type", limit: 2, null: false + t.integer "cached_markdown_version" t.index ["author_id"], name: "index_vulnerabilities_on_author_id" t.index ["closed_by_id"], name: "index_vulnerabilities_on_closed_by_id" t.index ["due_date_sourcing_milestone_id"], name: "index_vulnerabilities_on_due_date_sourcing_milestone_id" diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index a74ce3491d3..d7dd5774365 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -3413,6 +3413,7 @@ type Mutation { mergeRequestSetMilestone(input: MergeRequestSetMilestoneInput!): MergeRequestSetMilestonePayload mergeRequestSetWip(input: MergeRequestSetWipInput!): MergeRequestSetWipPayload removeAwardEmoji(input: RemoveAwardEmojiInput!): RemoveAwardEmojiPayload + todoMarkDone(input: TodoMarkDoneInput!): TodoMarkDonePayload toggleAwardEmoji(input: ToggleAwardEmojiInput!): ToggleAwardEmojiPayload updateEpic(input: UpdateEpicInput!): UpdateEpicPayload updateNote(input: UpdateNoteInput!): UpdateNotePayload @@ -4826,6 +4827,41 @@ type TodoEdge { node: Todo } +""" +Autogenerated input type of TodoMarkDone +""" +input TodoMarkDoneInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The global id of the todo to mark as done + """ + id: ID! +} + +""" +Autogenerated return type of TodoMarkDone +""" +type TodoMarkDonePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The requested todo + """ + todo: Todo! +} + enum TodoStateEnum { done pending diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 5f48eb01f72..da8ff669b72 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -14558,6 +14558,33 @@ "deprecationReason": null }, { + "name": "todoMarkDone", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "TodoMarkDoneInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "TodoMarkDonePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "toggleAwardEmoji", "description": null, "args": [ @@ -16232,6 +16259,112 @@ }, { "kind": "OBJECT", + "name": "TodoMarkDonePayload", + "description": "Autogenerated return type of TodoMarkDone", + "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": "Reasons why the mutation failed.", + "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": "todo", + "description": "The requested todo", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Todo", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "TodoMarkDoneInput", + "description": "Autogenerated input type of TodoMarkDone", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": "The global id of the todo to mark as done", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "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": "DesignManagementUploadPayload", "description": "Autogenerated return type of DesignManagementUpload", "fields": [ diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index bfda8ff1194..aaba282882d 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -756,6 +756,14 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `state` | TodoStateEnum! | State of the todo | | `createdAt` | Time! | Timestamp this todo was created | +### TodoMarkDonePayload + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `todo` | Todo! | The requested todo | + ### ToggleAwardEmojiPayload | Name | Type | Description | diff --git a/lib/gitlab/background_migration/populate_untracked_uploads_dependencies.rb b/lib/gitlab/background_migration/populate_untracked_uploads_dependencies.rb index f5fb33f1660..23e8be4a9ab 100644 --- a/lib/gitlab/background_migration/populate_untracked_uploads_dependencies.rb +++ b/lib/gitlab/background_migration/populate_untracked_uploads_dependencies.rb @@ -176,7 +176,7 @@ module Gitlab self.table_name = 'projects' def self.find_by_full_path(path) - order_sql = "(CASE WHEN routes.path = #{connection.quote(path)} THEN 0 ELSE 1 END)" + order_sql = Arel.sql("(CASE WHEN routes.path = #{connection.quote(path)} THEN 0 ELSE 1 END)") where_full_path_in(path).reorder(order_sql).take end diff --git a/lib/gitlab/ci/templates/Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml index eff20b84e3f..9cc3b4cade6 100644 --- a/lib/gitlab/ci/templates/Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml @@ -28,7 +28,7 @@ dast_environment_deploy: variables: - $CI_DEFAULT_BRANCH != $CI_COMMIT_REF_NAME - $DAST_DISABLED || $DAST_DISABLED_FOR_DEFAULT_BRANCH - - $DAST_WEBSITE # we don't need to create a review app if a URL is already given + - $DAST_WEBSITE # we don't need to create a review app if a URL is already given stop_dast_environment: extends: .auto-deploy diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index 6bada921ad4..7ea7565f758 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -955,7 +955,7 @@ into similar problems in the future (e.g. when new tables are created). table_name = model_class.quoted_table_name model_class.each_batch(of: batch_size) do |relation| - start_id, end_id = relation.pluck("MIN(#{table_name}.id), MAX(#{table_name}.id)").first + start_id, end_id = relation.pluck("MIN(#{table_name}.id)", "MAX(#{table_name}.id)").first if jobs.length >= BACKGROUND_MIGRATION_JOB_BUFFER_SIZE # Note: This code path generally only helps with many millions of rows diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 4501366a318..6dc257ec67a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -20349,6 +20349,9 @@ msgstr "" msgid "failed to dismiss associated finding(id=%{finding_id}): %{message}" msgstr "" +msgid "finding is not found or is already attached to a vulnerability" +msgstr "" + msgid "for %{link_to_merge_request} with %{link_to_merge_request_source_branch}" msgstr "" diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index ca39f5dd9f2..53896c7f5c7 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -90,16 +90,18 @@ describe ApplicationController do let(:format) { :html } it_behaves_like 'setting gon variables' - end - context 'with json format' do - let(:format) { :json } + context 'for peek requests' do + before do + request.path = '/-/peek' + end - it_behaves_like 'not setting gon variables' + it_behaves_like 'not setting gon variables' + end end - context 'with atom format' do - let(:format) { :atom } + context 'with json format' do + let(:format) { :json } it_behaves_like 'not setting gon variables' end diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index f35babc1b56..1bcf3bb106b 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -228,10 +228,10 @@ describe UploadsController do user.block end - it "responds with status 401" do + it "redirects to the sign in page" do get :show, params: { model: "user", mounted_as: "avatar", id: user.id, filename: "dk.png" } - expect(response).to have_gitlab_http_status(401) + expect(response).to redirect_to(new_user_session_path) end end @@ -320,10 +320,10 @@ describe UploadsController do end context "when not signed in" do - it "responds with status 401" do + it "redirects to the sign in page" do get :show, params: { model: "project", mounted_as: "avatar", id: project.id, filename: "dk.png" } - expect(response).to have_gitlab_http_status(401) + expect(response).to redirect_to(new_user_session_path) end end @@ -343,10 +343,10 @@ describe UploadsController do project.add_maintainer(user) end - it "responds with status 401" do + it "redirects to the sign in page" do get :show, params: { model: "project", mounted_as: "avatar", id: project.id, filename: "dk.png" } - expect(response).to have_gitlab_http_status(401) + expect(response).to redirect_to(new_user_session_path) end end @@ -439,10 +439,10 @@ describe UploadsController do user.block end - it "responds with status 401" do + it "redirects to the sign in page" do get :show, params: { model: "group", mounted_as: "avatar", id: group.id, filename: "dk.png" } - expect(response).to have_gitlab_http_status(401) + expect(response).to redirect_to(new_user_session_path) end end @@ -526,10 +526,10 @@ describe UploadsController do end context "when not signed in" do - it "responds with status 401" do + it "redirects to the sign in page" do get :show, params: { model: "note", mounted_as: "attachment", id: note.id, filename: "dk.png" } - expect(response).to have_gitlab_http_status(401) + expect(response).to redirect_to(new_user_session_path) end end @@ -549,10 +549,10 @@ describe UploadsController do project.add_maintainer(user) end - it "responds with status 401" do + it "redirects to the sign in page" do get :show, params: { model: "note", mounted_as: "attachment", id: note.id, filename: "dk.png" } - expect(response).to have_gitlab_http_status(401) + expect(response).to redirect_to(new_user_session_path) end end diff --git a/spec/features/projects/blobs/edit_spec.rb b/spec/features/projects/blobs/edit_spec.rb index b81f52a541b..ef209fecac6 100644 --- a/spec/features/projects/blobs/edit_spec.rb +++ b/spec/features/projects/blobs/edit_spec.rb @@ -62,13 +62,6 @@ describe 'Editing file blob', :js do expect(page).to have_content 'NextFeature' end - it 'renders a URL in the content of file as a link' do - project.repository.create_file(user, 'file.yml', '# go to https://gitlab.com', message: 'testing', branch_name: branch) - visit project_edit_blob_path(project, tree_join(branch, 'file.yml')) - - expect(page).to have_selector('.ace_content .ace_line a') - end - context 'from blob file path' do before do visit project_blob_path(project, tree_join(branch, file_path)) diff --git a/spec/graphql/mutations/todos/mark_done_spec.rb b/spec/graphql/mutations/todos/mark_done_spec.rb new file mode 100644 index 00000000000..761b153d5d1 --- /dev/null +++ b/spec/graphql/mutations/todos/mark_done_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mutations::Todos::MarkDone do + let_it_be(:current_user) { create(:user) } + let_it_be(:author) { create(:user) } + let_it_be(:other_user) { create(:user) } + + let_it_be(:todo1) { create(:todo, user: current_user, author: author, state: :pending) } + let_it_be(:todo2) { create(:todo, user: current_user, author: author, state: :done) } + + let_it_be(:other_user_todo) { create(:todo, user: other_user, author: author, state: :pending) } + + let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }) } + + describe '#resolve' do + it 'marks a single todo as done' do + result = mark_done_mutation(todo1) + + expect(todo1.reload.state).to eq('done') + expect(todo2.reload.state).to eq('done') + expect(other_user_todo.reload.state).to eq('pending') + + todo = result[:todo] + expect(todo.id).to eq(todo1.id) + expect(todo.state).to eq('done') + end + + it 'handles a todo which is already done as expected' do + result = mark_done_mutation(todo2) + + expect(todo1.reload.state).to eq('pending') + expect(todo2.reload.state).to eq('done') + expect(other_user_todo.reload.state).to eq('pending') + + todo = result[:todo] + expect(todo.id).to eq(todo2.id) + expect(todo.state).to eq('done') + end + + it 'ignores requests for todos which do not belong to the current user' do + expect { mark_done_mutation(other_user_todo) }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + + expect(todo1.reload.state).to eq('pending') + expect(todo2.reload.state).to eq('done') + expect(other_user_todo.reload.state).to eq('pending') + end + + it 'ignores invalid GIDs' do + expect { mutation.resolve(id: 'invalid_gid') }.to raise_error(Gitlab::Graphql::Errors::ArgumentError) + + expect(todo1.reload.state).to eq('pending') + expect(todo2.reload.state).to eq('done') + expect(other_user_todo.reload.state).to eq('pending') + end + end + + def mark_done_mutation(todo) + mutation.resolve(id: global_id_of(todo)) + end + + def global_id_of(todo) + todo.to_global_id.to_s + end +end diff --git a/spec/javascripts/blob/viewer/index_spec.js b/spec/javascripts/blob/viewer/index_spec.js index bbc59632f3c..06c06613887 100644 --- a/spec/javascripts/blob/viewer/index_spec.js +++ b/spec/javascripts/blob/viewer/index_spec.js @@ -11,13 +11,6 @@ describe('Blob viewer', () => { preloadFixtures('snippets/show.html'); - const asyncClick = () => - new Promise(resolve => { - document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); - - setTimeout(resolve); - }); - beforeEach(() => { mock = new MockAdapter(axios); @@ -73,12 +66,19 @@ describe('Blob viewer', () => { }); it('doesnt reload file if already loaded', done => { + const asyncClick = () => + new Promise(resolve => { + document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); + + setTimeout(resolve); + }); + asyncClick() .then(() => asyncClick()) .then(() => { - expect(document.querySelector('.blob-viewer[data-type="simple"]').dataset.loaded).toBe( - 'true', - ); + expect( + document.querySelector('.blob-viewer[data-type="simple"]').getAttribute('data-loaded'), + ).toBe('true'); done(); }) @@ -100,7 +100,9 @@ describe('Blob viewer', () => { }); it('has tooltip when disabled', () => { - expect(copyButton.dataset.title).toBe('Switch to the source to copy the file contents'); + expect(copyButton.getAttribute('data-original-title')).toBe( + 'Switch to the source to copy the file contents', + ); }); it('is blurred when clicked and disabled', () => { @@ -134,7 +136,7 @@ describe('Blob viewer', () => { document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); setTimeout(() => { - expect(copyButton.dataset.title).toBe('Copy file contents'); + expect(copyButton.getAttribute('data-original-title')).toBe('Copy file contents'); done(); }); @@ -175,27 +177,4 @@ describe('Blob viewer', () => { expect(axios.get.calls.count()).toBe(1); }); }); - - describe('a URL inside the blob content', () => { - beforeEach(() => { - mock.onGet('http://test.host/snippets/1.json?viewer=simple').reply(200, { - html: - '<div class="js-blob-content"><pre class="code"><code><span class="line" lang="yaml"><span class="c1">To install gitlab-shell you also need a Go compiler version 1.8 or newer. https://golang.org/dl/</span></span></code></pre></div>', - }); - }); - - it('is rendered as a link in simple view', done => { - asyncClick() - .then(() => { - expect(document.querySelector('.blob-viewer[data-type="simple"]').innerHTML).toContain( - '<a href="https://golang.org/dl/">https://golang.org/dl/</a>', - ); - done(); - }) - .catch(() => { - fail(); - done(); - }); - }); - }); }); diff --git a/spec/javascripts/boards/board_list_common_spec.js b/spec/javascripts/boards/board_list_common_spec.js index 5cd17323d0d..ada7589b795 100644 --- a/spec/javascripts/boards/board_list_common_spec.js +++ b/spec/javascripts/boards/board_list_common_spec.js @@ -15,7 +15,12 @@ import boardsStore from '~/boards/stores/boards_store'; window.Sortable = Sortable; -export default function createComponent({ done, listIssueProps = {}, componentProps = {} }) { +export default function createComponent({ + done, + listIssueProps = {}, + componentProps = {}, + listProps = {}, +}) { const el = document.createElement('div'); document.body.appendChild(el); @@ -25,7 +30,7 @@ export default function createComponent({ done, listIssueProps = {}, componentPr boardsStore.create(); const BoardListComp = Vue.extend(BoardList); - const list = new List(listObj); + const list = new List({ ...listObj, ...listProps }); const issue = new ListIssue({ title: 'Testing', id: 1, @@ -35,7 +40,9 @@ export default function createComponent({ done, listIssueProps = {}, componentPr assignees: [], ...listIssueProps, }); - list.issuesSize = 1; + if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) { + list.issuesSize = 1; + } list.issues.push(issue); const component = new BoardListComp({ diff --git a/spec/javascripts/boards/board_list_spec.js b/spec/javascripts/boards/board_list_spec.js index 6774a46ed58..37e96e97279 100644 --- a/spec/javascripts/boards/board_list_spec.js +++ b/spec/javascripts/boards/board_list_spec.js @@ -1,156 +1,210 @@ +/* global List */ + import Vue from 'vue'; import eventHub from '~/boards/eventhub'; import createComponent from './board_list_common_spec'; +import waitForPromises from '../helpers/wait_for_promises'; + +import '~/boards/models/list'; describe('Board list component', () => { let mock; let component; + let getIssues; + function generateIssues(compWrapper) { + for (let i = 1; i < 20; i += 1) { + const issue = Object.assign({}, compWrapper.list.issues[0]); + issue.id += i; + compWrapper.list.issues.push(issue); + } + } - beforeEach(done => { - ({ mock, component } = createComponent({ done })); - }); + describe('When Expanded', () => { + beforeEach(done => { + getIssues = spyOn(List.prototype, 'getIssues').and.returnValue(new Promise(() => {})); + ({ mock, component } = createComponent({ done })); + }); - afterEach(() => { - mock.restore(); - }); + afterEach(() => { + mock.restore(); + component.$destroy(); + }); - it('renders component', () => { - expect(component.$el.classList.contains('board-list-component')).toBe(true); - }); + it('loads first page of issues', done => { + waitForPromises() + .then(() => { + expect(getIssues).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); - it('renders loading icon', done => { - component.loading = true; + it('renders component', () => { + expect(component.$el.classList.contains('board-list-component')).toBe(true); + }); + + it('renders loading icon', done => { + component.loading = true; - Vue.nextTick(() => { - expect(component.$el.querySelector('.board-list-loading')).not.toBeNull(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.board-list-loading')).not.toBeNull(); - done(); + done(); + }); }); - }); - it('renders issues', () => { - expect(component.$el.querySelectorAll('.board-card').length).toBe(1); - }); + it('renders issues', () => { + expect(component.$el.querySelectorAll('.board-card').length).toBe(1); + }); - it('sets data attribute with issue id', () => { - expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1'); - }); + it('sets data attribute with issue id', () => { + expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1'); + }); - it('shows new issue form', done => { - component.toggleForm(); + it('shows new issue form', done => { + component.toggleForm(); - Vue.nextTick(() => { - expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); - expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); + expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); - done(); + done(); + }); }); - }); - it('shows new issue form after eventhub event', done => { - eventHub.$emit(`hide-issue-form-${component.list.id}`); + it('shows new issue form after eventhub event', done => { + eventHub.$emit(`hide-issue-form-${component.list.id}`); - Vue.nextTick(() => { - expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); - expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); + expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); - done(); + done(); + }); }); - }); - it('does not show new issue form for closed list', done => { - component.list.type = 'closed'; - component.toggleForm(); + it('does not show new issue form for closed list', done => { + component.list.type = 'closed'; + component.toggleForm(); - Vue.nextTick(() => { - expect(component.$el.querySelector('.board-new-issue-form')).toBeNull(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.board-new-issue-form')).toBeNull(); - done(); + done(); + }); }); - }); - it('shows count list item', done => { - component.showCount = true; + it('shows count list item', done => { + component.showCount = true; - Vue.nextTick(() => { - expect(component.$el.querySelector('.board-list-count')).not.toBeNull(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.board-list-count')).not.toBeNull(); - expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( - 'Showing all issues', - ); + expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( + 'Showing all issues', + ); - done(); + done(); + }); }); - }); - it('sets data attribute with invalid id', done => { - component.showCount = true; + it('sets data attribute with invalid id', done => { + component.showCount = true; - Vue.nextTick(() => { - expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe( - '-1', - ); + Vue.nextTick(() => { + expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe( + '-1', + ); - done(); + done(); + }); }); - }); - it('shows how many more issues to load', done => { - component.showCount = true; - component.list.issuesSize = 20; + it('shows how many more issues to load', done => { + component.showCount = true; + component.list.issuesSize = 20; - Vue.nextTick(() => { - expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( - 'Showing 1 of 20 issues', - ); + Vue.nextTick(() => { + expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( + 'Showing 1 of 20 issues', + ); - done(); + done(); + }); }); - }); - - it('loads more issues after scrolling', done => { - spyOn(component.list, 'nextPage'); - component.$refs.list.style.height = '100px'; - component.$refs.list.style.overflow = 'scroll'; - for (let i = 1; i < 20; i += 1) { - const issue = Object.assign({}, component.list.issues[0]); - issue.id += i; - component.list.issues.push(issue); - } + it('loads more issues after scrolling', done => { + spyOn(component.list, 'nextPage'); + component.$refs.list.style.height = '100px'; + component.$refs.list.style.overflow = 'scroll'; + generateIssues(component); + + Vue.nextTick(() => { + component.$refs.list.scrollTop = 20000; + + waitForPromises() + .then(() => { + expect(component.list.nextPage).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); - Vue.nextTick(() => { - component.$refs.list.scrollTop = 20000; + it('does not load issues if already loading', done => { + component.list.nextPage = spyOn(component.list, 'nextPage').and.returnValue( + new Promise(() => {}), + ); - setTimeout(() => { - expect(component.list.nextPage).toHaveBeenCalled(); + component.onScroll(); + component.onScroll(); - done(); - }); + waitForPromises() + .then(() => { + expect(component.list.nextPage).toHaveBeenCalledTimes(1); + }) + .then(done) + .catch(done.fail); }); - }); - it('does not load issues if already loading', () => { - component.list.nextPage = spyOn(component.list, 'nextPage').and.returnValue( - new Promise(() => {}), - ); + it('shows loading more spinner', done => { + component.showCount = true; + component.list.loadingMore = true; - component.onScroll(); - component.onScroll(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull(); - expect(component.list.nextPage).toHaveBeenCalledTimes(1); + done(); + }); + }); }); - it('shows loading more spinner', done => { - component.showCount = true; - component.list.loadingMore = true; + describe('When Collapsed', () => { + beforeEach(done => { + getIssues = spyOn(List.prototype, 'getIssues').and.returnValue(new Promise(() => {})); + ({ mock, component } = createComponent({ + done, + listProps: { type: 'closed', collapsed: true, issuesSize: 50 }, + })); + generateIssues(component); + component.scrollHeight = spyOn(component, 'scrollHeight').and.returnValue(0); + }); - Vue.nextTick(() => { - expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull(); + afterEach(() => { + mock.restore(); + component.$destroy(); + }); - done(); + it('does not load all issues', done => { + waitForPromises() + .then(() => { + // Initial getIssues from list constructor + expect(getIssues).toHaveBeenCalledTimes(1); + }) + .then(done) + .catch(done.fail); }); }); }); diff --git a/spec/models/concerns/redactable_spec.rb b/spec/models/concerns/redactable_spec.rb index 57c7d2cb767..3f6a2e2410c 100644 --- a/spec/models/concerns/redactable_spec.rb +++ b/spec/models/concerns/redactable_spec.rb @@ -7,44 +7,6 @@ describe Redactable do stub_commonmark_sourcepos_disabled end - shared_examples 'model with redactable field' do - it 'redacts unsubscribe token' do - model[field] = 'some text /sent_notifications/00000000000000000000000000000000/unsubscribe more text' - - model.save! - - expect(model[field]).to eq 'some text /sent_notifications/REDACTED/unsubscribe more text' - end - - it 'ignores not hexadecimal tokens' do - text = 'some text /sent_notifications/token/unsubscribe more text' - model[field] = text - - model.save! - - expect(model[field]).to eq text - end - - it 'ignores not matching texts' do - text = 'some text /sent_notifications/.*/unsubscribe more text' - model[field] = text - - model.save! - - expect(model[field]).to eq text - end - - it 'redacts the field when saving the model before creating markdown cache' do - model[field] = 'some text /sent_notifications/00000000000000000000000000000000/unsubscribe more text' - - model.save! - - expected = 'some text /sent_notifications/REDACTED/unsubscribe more text' - expect(model[field]).to eq expected - expect(model["#{field}_html"]).to eq "<p dir=\"auto\">#{expected}</p>" - end - end - context 'when model is an issue' do it_behaves_like 'model with redactable field' do let(:model) { create(:issue) } diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index d4137d2ada4..adf178330f4 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -2177,6 +2177,50 @@ describe MergeRequest do end end + describe '#check_mergeability' do + let(:mergeability_service) { double } + + before do + allow(MergeRequests::MergeabilityCheckService).to receive(:new) do + mergeability_service + end + end + + context 'if the merge status is unchecked' do + before do + subject.mark_as_unchecked! + end + + it 'executes MergeabilityCheckService' do + expect(mergeability_service).to receive(:execute) + + subject.check_mergeability + end + end + + context 'if the merge status is checked' do + context 'and feature flag is enabled' do + it 'executes MergeabilityCheckService' do + expect(mergeability_service).not_to receive(:execute) + + subject.check_mergeability + end + end + + context 'and feature flag is disabled' do + before do + stub_feature_flags(merge_requests_conditional_mergeability_check: false) + end + + it 'does not execute MergeabilityCheckService' do + expect(mergeability_service).to receive(:execute) + + subject.check_mergeability + end + end + end + end + describe '#mergeable_state?' do let(:project) { create(:project, :repository) } diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb index 487a1c619c6..e79a2bc325b 100644 --- a/spec/models/todo_spec.rb +++ b/spec/models/todo_spec.rb @@ -150,6 +150,19 @@ describe Todo do end end + describe '#done?' do + let_it_be(:todo1) { create(:todo, state: :pending) } + let_it_be(:todo2) { create(:todo, state: :done) } + + it 'returns true for todos with done state' do + expect(todo2.done?).to be_truthy + end + + it 'returns false for todos with state pending' do + expect(todo1.done?).to be_falsey + end + end + describe '#self_assigned?' do let(:user_1) { build(:user) } diff --git a/spec/requests/api/graphql/mutations/todos/mark_done_spec.rb b/spec/requests/api/graphql/mutations/todos/mark_done_spec.rb new file mode 100644 index 00000000000..fabbb3aeb49 --- /dev/null +++ b/spec/requests/api/graphql/mutations/todos/mark_done_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Marking todos done' do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:author) { create(:user) } + let_it_be(:other_user) { create(:user) } + + let_it_be(:todo1) { create(:todo, user: current_user, author: author, state: :pending) } + let_it_be(:todo2) { create(:todo, user: current_user, author: author, state: :done) } + + let_it_be(:other_user_todo) { create(:todo, user: other_user, author: author, state: :pending) } + + let(:input) { { id: todo1.to_global_id.to_s } } + + let(:mutation) do + graphql_mutation(:todo_mark_done, input, + <<-QL.strip_heredoc + clientMutationId + errors + todo { + id + state + } + QL + ) + end + + def mutation_response + graphql_mutation_response(:todo_mark_done) + end + + it 'marks a single todo as done' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(todo1.reload.state).to eq('done') + expect(todo2.reload.state).to eq('done') + expect(other_user_todo.reload.state).to eq('pending') + + todo = mutation_response['todo'] + expect(todo['id']).to eq(todo1.to_global_id.to_s) + expect(todo['state']).to eq('done') + end + + context 'when todo is already marked done' do + let(:input) { { id: todo2.to_global_id.to_s } } + + it 'has the expected response' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(todo1.reload.state).to eq('pending') + expect(todo2.reload.state).to eq('done') + expect(other_user_todo.reload.state).to eq('pending') + + todo = mutation_response['todo'] + expect(todo['id']).to eq(todo2.to_global_id.to_s) + expect(todo['state']).to eq('done') + end + end + + context 'when todo does not belong to requesting user' do + let(:input) { { id: other_user_todo.to_global_id.to_s } } + let(:access_error) { 'The resource that you are attempting to access does not exist or you don\'t have permission to perform this action' } + + it 'contains the expected error' do + post_graphql_mutation(mutation, current_user: current_user) + + errors = json_response['errors'] + expect(errors).not_to be_blank + expect(errors.first['message']).to eq(access_error) + + expect(todo1.reload.state).to eq('pending') + expect(todo2.reload.state).to eq('done') + expect(other_user_todo.reload.state).to eq('pending') + end + end + + context 'when using an invalid gid' do + let(:input) { { id: 'invalid_gid' } } + let(:invalid_gid_error) { 'invalid_gid is not a valid GitLab id.' } + + it 'contains the expected error' do + post_graphql_mutation(mutation, current_user: current_user) + + errors = json_response['errors'] + expect(errors).not_to be_blank + expect(errors.first['message']).to eq(invalid_gid_error) + + expect(todo1.reload.state).to eq('pending') + expect(todo2.reload.state).to eq('done') + expect(other_user_todo.reload.state).to eq('pending') + end + end +end diff --git a/spec/requests/user_avatar_spec.rb b/spec/requests/user_avatar_spec.rb deleted file mode 100644 index 9451674161c..00000000000 --- a/spec/requests/user_avatar_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe 'Loading a user avatar' do - let(:user) { create(:user, :with_avatar) } - - context 'when logged in' do - # The exact query count will vary depending on the 2FA settings of the - # instance, group, and user. Removing those extra 2FA queries in this case - # may not be a good idea, so we just set up the ideal case. - before do - stub_application_setting(require_two_factor_authentication: true) - - login_as(create(:user, :two_factor)) - end - - # One each for: current user, avatar user, and upload record - it 'only performs three SQL queries' do - get user.avatar_url # Skip queries on first application load - - expect(response).to have_gitlab_http_status(200) - expect { get user.avatar_url }.not_to exceed_query_limit(3) - end - end - - context 'when logged out' do - # One each for avatar user and upload record - it 'only performs two SQL queries' do - get user.avatar_url # Skip queries on first application load - - expect(response).to have_gitlab_http_status(200) - expect { get user.avatar_url }.not_to exceed_query_limit(2) - end - end -end diff --git a/spec/support/shared_examples/models/concern/issuable_shared_examples.rb b/spec/support/shared_examples/models/concerns/issuable_shared_examples.rb index 4978a403324..4978a403324 100644 --- a/spec/support/shared_examples/models/concern/issuable_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/issuable_shared_examples.rb diff --git a/spec/support/shared_examples/models/concerns/redactable_shared_examples.rb b/spec/support/shared_examples/models/concerns/redactable_shared_examples.rb new file mode 100644 index 00000000000..c5c14901268 --- /dev/null +++ b/spec/support/shared_examples/models/concerns/redactable_shared_examples.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +shared_examples 'model with redactable field' do + it 'redacts unsubscribe token' do + model[field] = 'some text /sent_notifications/00000000000000000000000000000000/unsubscribe more text' + + model.save! + + expect(model[field]).to eq 'some text /sent_notifications/REDACTED/unsubscribe more text' + end + + it 'ignores not hexadecimal tokens' do + text = 'some text /sent_notifications/token/unsubscribe more text' + model[field] = text + + model.save! + + expect(model[field]).to eq text + end + + it 'ignores not matching texts' do + text = 'some text /sent_notifications/.*/unsubscribe more text' + model[field] = text + + model.save! + + expect(model[field]).to eq text + end + + it 'redacts the field when saving the model before creating markdown cache' do + model[field] = 'some text /sent_notifications/00000000000000000000000000000000/unsubscribe more text' + + model.save! + + expected = 'some text /sent_notifications/REDACTED/unsubscribe more text' + expect(model[field]).to eq expected + expect(model["#{field}_html"]).to eq "<p dir=\"auto\">#{expected}</p>" + end +end |