diff options
author | Marcel Amirault <mamirault@gitlab.com> | 2019-07-10 05:56:23 +0000 |
---|---|---|
committer | Marcel Amirault <mamirault@gitlab.com> | 2019-07-10 05:56:23 +0000 |
commit | 2b03a0c2cf699074ec37a863e5c752a2cab2b133 (patch) | |
tree | 7ebc289d2ec498fcebf8ae454cdb1eb1ef500dbd /app | |
parent | c0deda7a86796c5de6ebe376c83f55af0965bde3 (diff) | |
parent | 810df4fb51bf3db4016c5f7458599331d4586300 (diff) | |
download | gitlab-ce-docs-hard-tabs.tar.gz |
Merge branch 'master' into 'docs-hard-tabs'docs-hard-tabs
# Conflicts:
# doc/administration/pseudonymizer.md
# doc/administration/uploads.md
Diffstat (limited to 'app')
72 files changed, 753 insertions, 128 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 4f66a5d080c..a649c521405 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -24,6 +24,7 @@ const Api = { issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key', projectTemplatesPath: '/api/:version/projects/:id/templates/:type', + userCountsPath: '/api/:version/user_counts', usersPath: '/api/:version/users.json', userPath: '/api/:version/users/:id', userStatusPath: '/api/:version/users/:id/status', @@ -312,6 +313,11 @@ const Api = { }); }, + userCounts() { + const url = Api.buildUrl(this.userCountsPath); + return axios.get(url); + }, + userStatus(id, options) { const url = Api.buildUrl(this.userStatusPath).replace(':id', encodeURIComponent(id)); return axios.get(url, { diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue index f58149c9f7b..d8b0b60c183 100644 --- a/app/assets/javascripts/boards/components/board_blank_state.vue +++ b/app/assets/javascripts/boards/components/board_blank_state.vue @@ -61,7 +61,7 @@ export default { <div class="board-blank-state p-3"> <p> {{ - __('BoardBlankState|Add the following default lists to your Issue Board with one click:') + s__('BoardBlankState|Add the following default lists to your Issue Board with one click:') }} </p> <ul class="list-unstyled board-blank-state-list"> @@ -76,7 +76,7 @@ export default { </ul> <p> {{ - __( + s__( 'BoardBlankState|Starting out with the default set of lists will get you right on the way to making the most of your board.', ) }} @@ -86,10 +86,10 @@ export default { type="button" @click.stop="addDefaultLists" > - {{ __('BoardBlankState|Add default lists') }} + {{ s__('BoardBlankState|Add default lists') }} </button> <button class="btn btn-default btn-block" type="button" @click.stop="clearBlankState"> - {{ __("BoardBlankState|Nevermind, I'll use my own") }} + {{ s__("BoardBlankState|Nevermind, I'll use my own") }} </button> </div> </template> diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js index 0d2fe2925d8..ad0f6cc1496 100644 --- a/app/assets/javascripts/commons/index.js +++ b/app/assets/javascripts/commons/index.js @@ -4,3 +4,6 @@ import './jquery'; import './bootstrap'; import './vue'; import '../lib/utils/axios_utils'; +import { openUserCountsBroadcast } from './nav/user_merge_requests'; + +openUserCountsBroadcast(); diff --git a/app/assets/javascripts/commons/nav/user_merge_requests.js b/app/assets/javascripts/commons/nav/user_merge_requests.js new file mode 100644 index 00000000000..8e694cca6a1 --- /dev/null +++ b/app/assets/javascripts/commons/nav/user_merge_requests.js @@ -0,0 +1,67 @@ +import Api from '~/api'; + +let channel; + +function broadcastCount(newCount) { + if (!channel) { + return; + } + + channel.postMessage(newCount); +} + +function updateUserMergeRequestCounts(newCount) { + const mergeRequestsCountEl = document.querySelector('.merge-requests-count'); + mergeRequestsCountEl.textContent = newCount.toLocaleString(); + mergeRequestsCountEl.classList.toggle('hidden', Number(newCount) === 0); +} + +/** + * Refresh user counts (and broadcast if open) + */ +export function refreshUserMergeRequestCounts() { + return Api.userCounts() + .then(({ data }) => { + const count = data.merge_requests; + + updateUserMergeRequestCounts(count); + broadcastCount(count); + }) + .catch(ex => { + console.error(ex); // eslint-disable-line no-console + }); +} + +/** + * Close the broadcast channel for user counts + */ +export function closeUserCountsBroadcast() { + if (!channel) { + return; + } + + channel.close(); + channel = null; +} + +/** + * Open the broadcast channel for user counts, adds user id so we only update + * + * **Please note:** + * Not supported in all browsers, but not polyfilling for now + * to keep bundle size small and + * no special functionality lost except cross tab notifications + */ +export function openUserCountsBroadcast() { + closeUserCountsBroadcast(); + + if (window.BroadcastChannel) { + const currentUserId = typeof gon !== 'undefined' && gon && gon.current_user_id; + if (currentUserId) { + channel = new BroadcastChannel(`mr_count_channel_${currentUserId}`); + channel.onmessage = ev => { + updateUserMergeRequestCounts(ev.data); + }; + } + } +} diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue index b89729375be..99d77a75c23 100644 --- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue +++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue @@ -41,7 +41,7 @@ export default { noForkText() { return sprintf( __( - 'To protect this issues confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private.', + "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private.", ), { link_start: `<a href="${this.newForkPath}" class="help-link">`, link_end: '</a>' }, false, @@ -118,7 +118,7 @@ export default { <template v-if="projects.length"> {{ __( - 'To protect this issues confidentiality, a private fork of this project was selected.', + "To protect this issue's confidentiality, a private fork of this project was selected.", ) }} </template> @@ -126,10 +126,6 @@ export default { {{ __('No forks available to you.') }}<br /> <span v-html="noForkText"></span> </template> - <gl-link :href="helpPagePath" class="help-link" target="_blank"> - <span class="sr-only">{{ __('Read more') }}</span> - <i class="fa fa-question-circle" aria-hidden="true"></i> - </gl-link> </p> </div> </div> diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js index bc9d7fcf30d..c855f3973b0 100644 --- a/app/assets/javascripts/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -1,4 +1,4 @@ -/* eslint-disable consistent-return, func-names, array-callback-return, prefer-arrow-callback, no-unused-vars */ +/* eslint-disable consistent-return, func-names, array-callback-return, prefer-arrow-callback */ import $ from 'jquery'; import _ from 'underscore'; @@ -7,7 +7,7 @@ import Flash from './flash'; import { __ } from './locale'; export default { - init({ container, form, issues, prefixId } = {}) { + init({ form, issues, prefixId } = {}) { this.prefixId = prefixId || 'issue_'; this.form = form || this.getElement('.bulk-update'); this.$labelDropdown = this.form.find('.js-label-select'); diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js index 16f88cddce3..f3f8b6ec715 100644 --- a/app/assets/javascripts/issuable_index.js +++ b/app/assets/javascripts/issuable_index.js @@ -2,26 +2,13 @@ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; import flash from './flash'; import { s__, __ } from './locale'; -import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; -import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; +import issuableInitBulkUpdateSidebar from './issuable_init_bulk_update_sidebar'; export default class IssuableIndex { constructor(pagePrefix) { - this.initBulkUpdate(pagePrefix); + issuableInitBulkUpdateSidebar.init(pagePrefix); IssuableIndex.resetIncomingEmailToken(); } - initBulkUpdate(pagePrefix) { - const userCanBulkUpdate = $('.issues-bulk-update').length > 0; - const alreadyInitialized = Boolean(this.bulkUpdateSidebar); - - if (userCanBulkUpdate && !alreadyInitialized) { - IssuableBulkUpdateActions.init({ - prefixId: pagePrefix, - }); - - this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar(); - } - } static resetIncomingEmailToken() { const $resetToken = $('.incoming-email-token-reset'); diff --git a/app/assets/javascripts/issuable_init_bulk_update_sidebar.js b/app/assets/javascripts/issuable_init_bulk_update_sidebar.js new file mode 100644 index 00000000000..da8969c80f3 --- /dev/null +++ b/app/assets/javascripts/issuable_init_bulk_update_sidebar.js @@ -0,0 +1,19 @@ +import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; +import issuableBulkUpdateActions from './issuable_bulk_update_actions'; + +export default { + bulkUpdateSidebar: null, + + init(prefixId) { + const bulkUpdateEl = document.querySelector('.issues-bulk-update'); + const alreadyInitialized = Boolean(this.bulkUpdateSidebar); + + if (bulkUpdateEl && !alreadyInitialized) { + issuableBulkUpdateActions.init({ prefixId }); + + this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar(); + } + + return this.bulkUpdateSidebar; + }, +}; diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 6c1738f0f1b..fda494fec07 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -13,6 +13,7 @@ import { splitCamelCase, slugifyWithUnderscore, } from '../../lib/utils/text_utility'; +import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import * as constants from '../constants'; import eventHub from '../event_hub'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; @@ -234,7 +235,10 @@ export default { toggleIssueState() { if (this.isOpen) { this.closeIssue() - .then(() => this.enableButton()) + .then(() => { + this.enableButton(); + refreshUserMergeRequestCounts(); + }) .catch(() => { this.enableButton(); this.toggleStateButtonLoading(false); @@ -247,7 +251,10 @@ export default { }); } else { this.reopenIssue() - .then(() => this.enableButton()) + .then(() => { + this.enableButton(); + refreshUserMergeRequestCounts(); + }) .catch(({ data }) => { this.enableButton(); this.toggleStateButtonLoading(false); diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index 2ff0fee62f3..0b136549c14 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -8,12 +8,14 @@ import SystemNote from '~/vue_shared/components/notes/system_note.vue'; import NoteableNote from './noteable_note.vue'; import ToggleRepliesWidget from './toggle_replies_widget.vue'; import NoteEditedText from './note_edited_text.vue'; +import DiscussionNotesRepliesWrapper from './discussion_notes_replies_wrapper.vue'; export default { name: 'DiscussionNotes', components: { ToggleRepliesWidget, NoteEditedText, + DiscussionNotesRepliesWrapper, }, props: { discussion: { @@ -119,9 +121,7 @@ export default { /> <slot slot="avatar-badge" name="avatar-badge"></slot> </component> - <div - :class="discussion.diff_discussion ? 'discussion-collapsible bordered-box clearfix' : ''" - > + <discussion-notes-replies-wrapper :is-diff-discussion="discussion.diff_discussion"> <toggle-replies-widget v-if="hasReplies" :collapsed="!isExpanded" @@ -141,7 +141,7 @@ export default { /> </template> <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot> - </div> + </discussion-notes-replies-wrapper> </template> <template v-else> <component diff --git a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue new file mode 100644 index 00000000000..2ddca56ddd5 --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue @@ -0,0 +1,27 @@ +<script> +/** + * Wrapper for discussion notes replies section. + * + * This is a functional component using the render method because in some cases + * the wrapper is not needed and we want to simply render along the children. + */ +export default { + functional: true, + props: { + isDiffDiscussion: { + type: Boolean, + required: false, + default: false, + }, + }, + render(h, { props, children }) { + if (props.isDiffDiscussion) { + return h('li', { class: 'discussion-collapsible bordered-box clearfix' }, [ + h('ul', { class: 'notes' }, children), + ]); + } + + return children; + }, +}; +</script> diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index 23fb5656008..dcdee77a8ab 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -1,11 +1,15 @@ import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; +import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar'; import { FILTERED_SEARCH } from '~/pages/constants'; import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import initManualOrdering from '~/manual_ordering'; +const ISSUE_BULK_UPDATE_PREFIX = 'issue_'; + document.addEventListener('DOMContentLoaded', () => { IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); + issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX); initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js index 941c4552579..2205a7bafe3 100644 --- a/app/assets/javascripts/pages/projects/issues/form.js +++ b/app/assets/javascripts/pages/projects/issues/form.js @@ -17,7 +17,5 @@ export default () => { new MilestoneSelect(); new IssuableTemplateSelectors(); - if (gon.features.graphql) { - initSuggestions(); - } + initSuggestions(); }; diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 1e66ccbfa29..0d9e992e596 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -76,7 +76,7 @@ export default { variables: { projectPath: this.projectPath, ref: this.ref, - path: this.path, + path: this.path || '/', nextPageCursor: this.nextPageCursor, pageSize: PAGE_SIZE, }, diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 70dc3d2cdfa..be1e4811856 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -2,6 +2,7 @@ import Flash from '~/flash'; import eventHub from '~/sidebar/event_hub'; import Store from '~/sidebar/stores/sidebar_store'; +import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import AssigneeTitle from './assignee_title.vue'; import Assignees from './assignees.vue'; import { __ } from '~/locale'; @@ -73,6 +74,9 @@ export default { this.mediator .saveAssignees(this.field) .then(setLoadingFalse.bind(this)) + .then(() => { + refreshUserMergeRequestCounts(); + }) .catch(() => { setLoadingFalse(); return new Flash(__('Error occurred when saving assignees')); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index d1f75593d14..d4514767912 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -6,6 +6,7 @@ import simplePoll from '~/lib/utils/simple_poll'; import { __ } from '~/locale'; import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge'; import MergeRequest from '../../../merge_request'; +import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import Flash from '../../../flash'; import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; @@ -174,6 +175,8 @@ export default { MergeRequest.decreaseCounter(); stopPolling(); + refreshUserMergeRequestCounts(); + // If user checked remove source branch and we didn't remove the branch yet // we should start another polling for source branch remove process if (this.removeSourceBranch && data.source_branch_exists) { diff --git a/app/assets/javascripts/vue_shared/directives/tooltip.js b/app/assets/javascripts/vue_shared/directives/tooltip.js index 2d1f7a1cfd0..73e92728cb9 100644 --- a/app/assets/javascripts/vue_shared/directives/tooltip.js +++ b/app/assets/javascripts/vue_shared/directives/tooltip.js @@ -3,8 +3,12 @@ import '~/commons/bootstrap'; export default { bind(el) { + const glTooltipDelay = localStorage.getItem('gl-tooltip-delay'); + const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0; + $(el).tooltip({ trigger: 'hover', + delay, }); }, diff --git a/app/assets/stylesheets/components/toast.scss b/app/assets/stylesheets/components/toast.scss index acbd909d595..e27bf282247 100644 --- a/app/assets/stylesheets/components/toast.scss +++ b/app/assets/stylesheets/components/toast.scss @@ -15,11 +15,15 @@ .toasted.gl-toast { border-radius: $border-radius-default; font-size: $gl-font-size; - padding: $gl-padding-8 $gl-padding-24; + padding: $gl-padding-8 $gl-padding $gl-padding-8 $gl-padding-24; margin-top: $toast-default-margin; line-height: $gl-line-height; background-color: rgba($gray-900, $toast-background-opacity); + span { + padding-right: $gl-padding-8; + } + @include media-breakpoint-down(xs) { .action:first-of-type { // Ensures actions buttons are right aligned on mobile @@ -29,19 +33,14 @@ .action { color: $blue-300; - margin: 0 0 0 $toast-action-margin-left; + margin: 0 0 0 $toast-default-margin; text-transform: none; font-size: $gl-font-size; - - &:first-of-type { - padding-right: 0; - } } .toast-close { font-size: $default-icon-size; margin-left: $toast-default-margin; - padding-left: $gl-padding; } } } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index a12029d2419..e75c1379dfb 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -287,8 +287,8 @@ list-style: none; padding: 0 1px; - a:not(.help-link), - button:not(.btn), + a, + button:not(.dropdown-toggle,.ci-action-icon-container), .menu-item { @include dropdown-link; } diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index f75e5b55506..975dca168d5 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -35,7 +35,8 @@ background-color: $modal-body-bg; line-height: $line-height-base; position: relative; - padding: #{3 * $grid-size} #{2 * $grid-size}; + min-height: $modal-body-height; + padding: #{2 * $grid-size} #{6 * $grid-size} #{2 * $grid-size} #{2 * $grid-size}; text-align: left; white-space: normal; @@ -85,9 +86,9 @@ body.modal-open { .modal { background-color: $black-transparent; - @include media-breakpoint-up(md) { + @include media-breakpoint-up(sm) { .modal-dialog { - margin: 30px auto; + margin: 64px auto; } } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index b6a24247d40..4521643ce08 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -507,7 +507,6 @@ $toast-height: 48px; $toast-max-width: 586px; $toast-padding-right: 42px; $toast-default-margin: 8px; -$toast-action-margin-left: 16px; $toast-background-opacity: 0.95; /* @@ -805,7 +804,7 @@ $border-color-settings: #e1e1e1; /* Modals */ -$modal-body-height: 134px; +$modal-body-height: 80px; $modal-border-color: #e9ecef; $priority-label-empty-state-width: 114px; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 623c44e062f..3ffe8ae304d 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -1095,6 +1095,10 @@ table.code { .discussion-collapsible { margin: 0 $gl-padding $gl-padding 71px; + + .notes { + border-radius: $border-radius-default; + } } .parallel { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 5db0136e3f1..b9b8eabf909 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -139,7 +139,6 @@ $note-form-margin-left: 72px; border-radius: 4px 4px 0 0; &.collapsed { - border: 0; border-radius: 4px; } } diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss index 10120a472d3..60400f10ca5 100644 --- a/app/assets/stylesheets/pages/wiki.scss +++ b/app/assets/stylesheets/pages/wiki.scss @@ -168,6 +168,10 @@ } ul.wiki-pages-list.content-list { + a { + color: $blue-600; + } + ul { list-style: none; margin-left: 0; diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 065d2d3a4ec..6fa2f75be33 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -92,7 +92,7 @@ module IssuableActions end def bulk_update - result = Issuable::BulkUpdateService.new(project, current_user, bulk_update_params).execute(resource_name) + result = Issuable::BulkUpdateService.new(current_user, bulk_update_params).execute(resource_name) quantity = result[:count] render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" } @@ -181,7 +181,7 @@ module IssuableActions end def authorize_admin_issuable! - unless can?(current_user, :"admin_#{resource_name}", @project) # rubocop:disable Gitlab/ModuleWithInstanceVariables + unless can?(current_user, :"admin_#{resource_name}", parent) return access_denied! end end diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 1ce0afac83b..9fbbe373b0d 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -11,7 +11,6 @@ class GraphqlController < ApplicationController # around in GraphiQL. protect_from_forgery with: :null_session, only: :execute - before_action :check_graphql_feature_flag! before_action :authorize_access_api! before_action(only: [:execute]) { authenticate_sessionless_user!(:api) } @@ -86,8 +85,4 @@ class GraphqlController < ApplicationController render json: error, status: status end - - def check_graphql_feature_flag! - render_404 unless Gitlab::Graphql.enabled? - end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index b866f574f67..228de8bc6f3 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -45,8 +45,6 @@ class Projects::IssuesController < Projects::ApplicationController before_action :authorize_import_issues!, only: [:import_csv] before_action :authorize_download_code!, only: [:related_branches] - before_action :set_suggested_issues_feature_flags, only: [:new] - respond_to :html def index @@ -285,8 +283,4 @@ class Projects::IssuesController < Projects::ApplicationController # 3. https://gitlab.com/gitlab-org/gitlab-ce/issues/42426 Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42422') end - - def set_suggested_issues_feature_flags - push_frontend_feature_flag(:graphql, default_enabled: true) - end end diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index 152ebb930e2..7edd14e48f7 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -66,6 +66,8 @@ class GitlabSchema < GraphQL::Schema if gid.model_class < ApplicationRecord Gitlab::Graphql::Loaders::BatchModelLoader.new(gid.model_class, gid.model_id).find + elsif gid.model_class.respond_to?(:lazy_find) + gid.model_class.lazy_find(gid.model_id) else gid.find end diff --git a/app/graphql/mutations/notes/base.rb b/app/graphql/mutations/notes/base.rb new file mode 100644 index 00000000000..a7198f5fba6 --- /dev/null +++ b/app/graphql/mutations/notes/base.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Mutations + module Notes + class Base < BaseMutation + include Gitlab::Graphql::Authorize::AuthorizeResource + + field :note, + Types::Notes::NoteType, + null: true, + description: 'The note after mutation' + + private + + def find_object(id:) + GitlabSchema.object_from_id(id) + end + + def check_object_is_noteable!(object) + unless object.is_a?(Noteable) + raise Gitlab::Graphql::Errors::ResourceNotAvailable, + 'Cannot add notes to this resource' + end + end + + def check_object_is_note!(object) + unless object.is_a?(Note) + raise Gitlab::Graphql::Errors::ResourceNotAvailable, + 'Resource is not a note' + end + end + end + end +end diff --git a/app/graphql/mutations/notes/create/base.rb b/app/graphql/mutations/notes/create/base.rb new file mode 100644 index 00000000000..d3a5dae2188 --- /dev/null +++ b/app/graphql/mutations/notes/create/base.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Mutations + module Notes + module Create + # This is a Base class for the Note creation Mutations and is not + # mounted as a GraphQL mutation itself. + class Base < Mutations::Notes::Base + authorize :create_note + + argument :noteable_id, + GraphQL::ID_TYPE, + required: true, + description: 'The global id of the resource to add a note to' + + argument :body, + GraphQL::STRING_TYPE, + required: true, + description: copy_field_description(Types::Notes::NoteType, :body) + + private + + def resolve(args) + noteable = authorized_find!(id: args[:noteable_id]) + + check_object_is_noteable!(noteable) + + note = ::Notes::CreateService.new( + noteable.project, + current_user, + create_note_params(noteable, args) + ).execute + + { + note: (note if note.persisted?), + errors: errors_on_object(note) + } + end + + def create_note_params(noteable, args) + { + noteable: noteable, + note: args[:body] + } + end + end + end + end +end diff --git a/app/graphql/mutations/notes/create/diff_note.rb b/app/graphql/mutations/notes/create/diff_note.rb new file mode 100644 index 00000000000..9b5f3092006 --- /dev/null +++ b/app/graphql/mutations/notes/create/diff_note.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Mutations + module Notes + module Create + class DiffNote < Base + graphql_name 'CreateDiffNote' + + argument :position, + Types::Notes::DiffPositionInputType, + required: true, + description: copy_field_description(Types::Notes::NoteType, :position) + + private + + def create_note_params(noteable, args) + super(noteable, args).merge({ + type: 'DiffNote', + position: position(noteable, args) + }) + end + + def position(noteable, args) + position = args[:position].to_h + position[:position_type] = 'text' + position.merge!(position[:paths].to_h) + + Gitlab::Diff::Position.new(position) + end + end + end + end +end diff --git a/app/graphql/mutations/notes/create/image_diff_note.rb b/app/graphql/mutations/notes/create/image_diff_note.rb new file mode 100644 index 00000000000..d94fd4d6ff8 --- /dev/null +++ b/app/graphql/mutations/notes/create/image_diff_note.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Mutations + module Notes + module Create + class ImageDiffNote < Base + graphql_name 'CreateImageDiffNote' + + argument :position, + Types::Notes::DiffImagePositionInputType, + required: true, + description: copy_field_description(Types::Notes::NoteType, :position) + + private + + def create_note_params(noteable, args) + super(noteable, args).merge({ + type: 'DiffNote', + position: position(noteable, args) + }) + end + + def position(noteable, args) + position = args[:position].to_h + position[:position_type] = 'image' + position.merge!(position[:paths].to_h) + + Gitlab::Diff::Position.new(position) + end + end + end + end +end diff --git a/app/graphql/mutations/notes/create/note.rb b/app/graphql/mutations/notes/create/note.rb new file mode 100644 index 00000000000..5236e48026e --- /dev/null +++ b/app/graphql/mutations/notes/create/note.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Mutations + module Notes + module Create + class Note < Base + graphql_name 'CreateNote' + + argument :discussion_id, + GraphQL::ID_TYPE, + required: false, + description: 'The global id of the discussion this note is in reply to' + + private + + def create_note_params(noteable, args) + discussion_id = nil + + if args[:discussion_id] + discussion = GitlabSchema.object_from_id(args[:discussion_id]) + authorize_discussion!(discussion) + + discussion_id = discussion.id + end + + super(noteable, args).merge({ + in_reply_to_discussion_id: discussion_id + }) + end + + def authorize_discussion!(discussion) + unless Ability.allowed?(current_user, :read_note, discussion, scope: :user) + raise Gitlab::Graphql::Errors::ResourceNotAvailable, + "The discussion does not exist or you don't have permission to perform this action" + end + end + end + end + end +end diff --git a/app/graphql/mutations/notes/destroy.rb b/app/graphql/mutations/notes/destroy.rb new file mode 100644 index 00000000000..a81322bc9b7 --- /dev/null +++ b/app/graphql/mutations/notes/destroy.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module Notes + class Destroy < Base + graphql_name 'DestroyNote' + + authorize :admin_note + + argument :id, + GraphQL::ID_TYPE, + required: true, + description: 'The global id of the note to destroy' + + def resolve(id:) + note = authorized_find!(id: id) + + check_object_is_note!(note) + + ::Notes::DestroyService.new(note.project, current_user).execute(note) + + { + errors: [] + } + end + end + end +end diff --git a/app/graphql/mutations/notes/update.rb b/app/graphql/mutations/notes/update.rb new file mode 100644 index 00000000000..ebf57b800c0 --- /dev/null +++ b/app/graphql/mutations/notes/update.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Mutations + module Notes + class Update < Base + graphql_name 'UpdateNote' + + authorize :admin_note + + argument :id, + GraphQL::ID_TYPE, + required: true, + description: 'The global id of the note to update' + + argument :body, + GraphQL::STRING_TYPE, + required: true, + description: copy_field_description(Types::Notes::NoteType, :body) + + def resolve(args) + note = authorized_find!(id: args[:id]) + + check_object_is_note!(note) + + note = ::Notes::UpdateService.new( + note.project, + current_user, + { note: args[:body] } + ).execute(note) + + { + note: note.reset, + errors: errors_on_object(note) + } + end + end + end +end diff --git a/app/graphql/types/base_input_object.rb b/app/graphql/types/base_input_object.rb index aebed035d3b..90a29b0cfb8 100644 --- a/app/graphql/types/base_input_object.rb +++ b/app/graphql/types/base_input_object.rb @@ -2,5 +2,6 @@ module Types class BaseInputObject < GraphQL::Schema::InputObject + prepend Gitlab::Graphql::CopyFieldDescription end end diff --git a/app/graphql/types/diff_paths_input_type.rb b/app/graphql/types/diff_paths_input_type.rb new file mode 100644 index 00000000000..43feddd9827 --- /dev/null +++ b/app/graphql/types/diff_paths_input_type.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + class DiffPathsInputType < BaseInputObject + argument :old_path, GraphQL::STRING_TYPE, required: false, + description: 'The path of the file on the start sha' + argument :new_path, GraphQL::STRING_TYPE, required: false, + description: 'The path of the file on the head sha' + end + # rubocop: enable Graphql/AuthorizeTypes +end diff --git a/app/graphql/types/diff_refs_type.rb b/app/graphql/types/diff_refs_type.rb new file mode 100644 index 00000000000..33a5780cd68 --- /dev/null +++ b/app/graphql/types/diff_refs_type.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + # Types that use DiffRefsType should have their own authorization + class DiffRefsType < BaseObject + graphql_name 'DiffRefs' + + field :head_sha, GraphQL::STRING_TYPE, null: false, description: 'The sha of the head at the time the comment was made' + field :base_sha, GraphQL::STRING_TYPE, null: false, description: 'The merge base of the branch the comment was made on' + field :start_sha, GraphQL::STRING_TYPE, null: false, description: 'The sha of the branch being compared against' + end + # rubocop: enable Graphql/AuthorizeTypes +end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 6734d4761c2..b8f63a750c5 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -23,6 +23,7 @@ module Types field :updated_at, Types::TimeType, null: false field :source_project, Types::ProjectType, null: true field :target_project, Types::ProjectType, null: false + field :diff_refs, Types::DiffRefsType, null: true # Alias for target_project field :project, Types::ProjectType, null: false field :project_id, GraphQL::INT_TYPE, null: false, method: :target_project_id diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index bc5fb709522..f843d6ad86f 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -10,5 +10,10 @@ module Types mount_mutation Mutations::AwardEmojis::Remove mount_mutation Mutations::AwardEmojis::Toggle mount_mutation Mutations::MergeRequests::SetWip, calls_gitaly: true + mount_mutation Mutations::Notes::Create::Note, calls_gitaly: true + mount_mutation Mutations::Notes::Create::DiffNote, calls_gitaly: true + mount_mutation Mutations::Notes::Create::ImageDiffNote, calls_gitaly: true + mount_mutation Mutations::Notes::Update + mount_mutation Mutations::Notes::Destroy end end diff --git a/app/graphql/types/notes/diff_image_position_input_type.rb b/app/graphql/types/notes/diff_image_position_input_type.rb new file mode 100644 index 00000000000..23b53b20815 --- /dev/null +++ b/app/graphql/types/notes/diff_image_position_input_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Notes + # rubocop: disable Graphql/AuthorizeTypes + class DiffImagePositionInputType < DiffPositionBaseInputType + graphql_name 'DiffImagePositionInput' + + argument :x, GraphQL::INT_TYPE, required: true, + description: copy_field_description(Types::Notes::DiffPositionType, :x) + argument :y, GraphQL::INT_TYPE, required: true, + description: copy_field_description(Types::Notes::DiffPositionType, :y) + argument :width, GraphQL::INT_TYPE, required: true, + description: copy_field_description(Types::Notes::DiffPositionType, :width) + argument :height, GraphQL::INT_TYPE, required: true, + description: copy_field_description(Types::Notes::DiffPositionType, :height) + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/notes/diff_position_base_input_type.rb b/app/graphql/types/notes/diff_position_base_input_type.rb new file mode 100644 index 00000000000..a9b4e1a8948 --- /dev/null +++ b/app/graphql/types/notes/diff_position_base_input_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module Notes + # rubocop: disable Graphql/AuthorizeTypes + class DiffPositionBaseInputType < BaseInputObject + argument :head_sha, GraphQL::STRING_TYPE, required: true, + description: copy_field_description(Types::DiffRefsType, :head_sha) + argument :base_sha, GraphQL::STRING_TYPE, required: false, + description: copy_field_description(Types::DiffRefsType, :base_sha) + argument :start_sha, GraphQL::STRING_TYPE, required: true, + description: copy_field_description(Types::DiffRefsType, :start_sha) + + argument :paths, + Types::DiffPathsInputType, + required: true, + description: 'The paths of the file that was changed. ' \ + 'Both of the properties of this input are optional, but at least one of them is required' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/notes/diff_position_input_type.rb b/app/graphql/types/notes/diff_position_input_type.rb new file mode 100644 index 00000000000..02c91e173cb --- /dev/null +++ b/app/graphql/types/notes/diff_position_input_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Notes + # rubocop: disable Graphql/AuthorizeTypes + class DiffPositionInputType < DiffPositionBaseInputType + graphql_name 'DiffPositionInput' + + argument :old_line, GraphQL::INT_TYPE, required: false, + description: copy_field_description(Types::Notes::DiffPositionType, :old_line) + argument :new_line, GraphQL::INT_TYPE, required: true, + description: copy_field_description(Types::Notes::DiffPositionType, :new_line) + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/notes/diff_position_type.rb b/app/graphql/types/notes/diff_position_type.rb index ebc24451715..6a0377fbfdf 100644 --- a/app/graphql/types/notes/diff_position_type.rb +++ b/app/graphql/types/notes/diff_position_type.rb @@ -7,12 +7,7 @@ module Types class DiffPositionType < BaseObject graphql_name 'DiffPosition' - field :head_sha, GraphQL::STRING_TYPE, null: false, - description: "The sha of the head at the time the comment was made" - field :base_sha, GraphQL::STRING_TYPE, null: true, - description: "The merge base of the branch the comment was made on" - field :start_sha, GraphQL::STRING_TYPE, null: false, - description: "The sha of the branch being compared against" + field :diff_refs, Types::DiffRefsType, null: false field :file_path, GraphQL::STRING_TYPE, null: false, description: "The path of the file that was changed" diff --git a/app/graphql/types/notes/discussion_type.rb b/app/graphql/types/notes/discussion_type.rb index c4691942f2d..a3fb28298f6 100644 --- a/app/graphql/types/notes/discussion_type.rb +++ b/app/graphql/types/notes/discussion_type.rb @@ -8,8 +8,16 @@ module Types authorize :read_note field :id, GraphQL::ID_TYPE, null: false + field :reply_id, GraphQL::ID_TYPE, null: false, description: 'The ID used to reply to this discussion' field :created_at, Types::TimeType, null: false field :notes, Types::Notes::NoteType.connection_type, null: false, description: "All notes in the discussion" + + # The gem we use to generate Global IDs is hard-coded to work with + # `id` properties. To generate a GID for the `reply_id` property, + # we must use the ::Gitlab::GlobalId module. + def reply_id + ::Gitlab::GlobalId.build(object, id: object.reply_id) + end end end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 8dee842a22d..8d0079a4dd3 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -662,6 +662,6 @@ module ProjectsHelper end def vue_file_list_enabled? - Gitlab::Graphql.enabled? && Feature.enabled?(:vue_file_list, @project) + Feature.enabled?(:vue_file_list, @project) end end diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb index ecf37bae6b3..ce810433a3a 100644 --- a/app/helpers/storage_helper.rb +++ b/app/helpers/storage_helper.rb @@ -17,6 +17,6 @@ module StorageHelper counter_lfs_objects: storage_counter(statistics.lfs_objects_size) } - _("%{counter_repositories} repositories, %{counter_wikis} wikis, %{counter_build_artifacts} build artifacts, %{counter_lfs_objects} LFS") % counters + _("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / LFS: %{counter_lfs_objects}") % counters end end diff --git a/app/models/concerns/project_api_compatibility.rb b/app/models/concerns/project_api_compatibility.rb new file mode 100644 index 00000000000..cb00efb06df --- /dev/null +++ b/app/models/concerns/project_api_compatibility.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Add methods used by the projects API +module ProjectAPICompatibility + extend ActiveSupport::Concern + + def build_git_strategy=(value) + write_attribute(:build_allow_git_fetch, value == 'fetch') + end + + def auto_devops_enabled=(value) + self.build_auto_devops if self.auto_devops&.enabled.nil? + self.auto_devops.update! enabled: value + end + + def auto_devops_deploy_strategy=(value) + self.build_auto_devops if self.auto_devops&.enabled.nil? + self.auto_devops.update! deploy_strategy: value + end +end diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index f268a842db4..551a2e56ecf 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -9,32 +9,70 @@ require 'gitlab/utils' module ProjectFeaturesCompatibility extend ActiveSupport::Concern + # TODO: remove in API v5, replaced by *_access_level def wiki_enabled=(value) - write_feature_attribute(:wiki_access_level, value) + write_feature_attribute_boolean(:wiki_access_level, value) end + # TODO: remove in API v5, replaced by *_access_level def builds_enabled=(value) - write_feature_attribute(:builds_access_level, value) + write_feature_attribute_boolean(:builds_access_level, value) end + # TODO: remove in API v5, replaced by *_access_level def merge_requests_enabled=(value) - write_feature_attribute(:merge_requests_access_level, value) + write_feature_attribute_boolean(:merge_requests_access_level, value) end + # TODO: remove in API v5, replaced by *_access_level def issues_enabled=(value) - write_feature_attribute(:issues_access_level, value) + write_feature_attribute_boolean(:issues_access_level, value) end + # TODO: remove in API v5, replaced by *_access_level def snippets_enabled=(value) - write_feature_attribute(:snippets_access_level, value) + write_feature_attribute_boolean(:snippets_access_level, value) + end + + def repository_access_level=(value) + write_feature_attribute_string(:repository_access_level, value) + end + + def wiki_access_level=(value) + write_feature_attribute_string(:wiki_access_level, value) + end + + def builds_access_level=(value) + write_feature_attribute_string(:builds_access_level, value) + end + + def merge_requests_access_level=(value) + write_feature_attribute_string(:merge_requests_access_level, value) + end + + def issues_access_level=(value) + write_feature_attribute_string(:issues_access_level, value) + end + + def snippets_access_level=(value) + write_feature_attribute_string(:snippets_access_level, value) end private - def write_feature_attribute(field, value) + def write_feature_attribute_boolean(field, value) + access_level = Gitlab::Utils.to_boolean(value) ? ProjectFeature::ENABLED : ProjectFeature::DISABLED + write_feature_attribute_raw(field, access_level) + end + + def write_feature_attribute_string(field, value) + access_level = ProjectFeature.access_level_from_str(value) + write_feature_attribute_raw(field, access_level) + end + + def write_feature_attribute_raw(field, value) build_project_feature unless project_feature - access_level = Gitlab::Utils.to_boolean(value) ? ProjectFeature::ENABLED : ProjectFeature::DISABLED - project_feature.__send__(:write_attribute, field, access_level) # rubocop:disable GitlabSecurity/PublicSend + project_feature.__send__(:write_attribute, field, value) # rubocop:disable GitlabSecurity/PublicSend end end diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index 5b3880f94ba..d91be73d6f0 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -173,7 +173,11 @@ module ReactiveCaching end def within_reactive_cache_lifetime?(*args) - !!Rails.cache.read(alive_reactive_cache_key(*args)) + if Feature.enabled?(:reactive_caching_check_key_exists, default_enabled: true) + Rails.cache.exist?(alive_reactive_cache_key(*args)) + else + !!Rails.cache.read(alive_reactive_cache_key(*args)) + end end def enqueuing_update(*args) diff --git a/app/models/discussion.rb b/app/models/discussion.rb index ae13cdfd85f..dd896f77084 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -38,6 +38,17 @@ class Discussion grouped_notes.values.map { |notes| build(notes, context_noteable) } end + def self.lazy_find(discussion_id) + BatchLoader.for(discussion_id).batch do |discussion_ids, loader| + results = Note.where(discussion_id: discussion_ids).fresh.to_a.group_by(&:discussion_id) + results.each do |discussion_id, notes| + next if notes.empty? + + loader.call(discussion_id, Discussion.build(notes)) + end + end + end + # Returns an alphanumeric discussion ID based on `build_discussion_id` def self.discussion_id(note) Digest::SHA1.hexdigest(build_discussion_id(note).join("-")) diff --git a/app/models/label_note.rb b/app/models/label_note.rb index d6814f4a948..ba5f1f82a81 100644 --- a/app/models/label_note.rb +++ b/app/models/label_note.rb @@ -62,19 +62,27 @@ class LabelNote < Note end def note_text(html: false) - added = labels_str('added', label_refs_by_action('add', html)) - removed = labels_str('removed', label_refs_by_action('remove', html)) + added = labels_str(label_refs_by_action('add', html), prefix: 'added', suffix: added_suffix) + removed = labels_str(label_refs_by_action('remove', html), prefix: removed_prefix) [added, removed].compact.join(' and ') end + def removed_prefix + 'removed' + end + + def added_suffix + '' + end + # returns string containing added/removed labels including # count of deleted labels: # # added ~1 ~2 + 1 deleted label # added 3 deleted labels # added ~1 ~2 labels - def labels_str(prefix, label_refs) + def labels_str(label_refs, prefix: '', suffix: '') existing_refs = label_refs.select { |ref| ref.present? }.sort refs_str = existing_refs.empty? ? nil : existing_refs.join(' ') @@ -84,9 +92,9 @@ class LabelNote < Note return unless refs_str || deleted_str label_list_str = [refs_str, deleted_str].compact.join(' + ') - suffix = 'label'.pluralize(deleted > 0 ? deleted : existing_refs.count) + suffix += ' label'.pluralize(deleted > 0 ? deleted : existing_refs.count) - "#{prefix} #{label_list_str} #{suffix}" + "#{prefix} #{label_list_str} #{suffix.squish}" end def label_refs_by_action(action, html) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index e96e26cc773..53977748c30 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1127,6 +1127,19 @@ class MergeRequest < ApplicationRecord "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/merge" end + def train_ref_path + "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/train" + end + + def cleanup_refs(only: :all) + target_refs = [] + target_refs << ref_path if %i[all head].include?(only) + target_refs << merge_ref_path if %i[all merge].include?(only) + target_refs << train_ref_path if %i[all train].include?(only) + + project.repository.delete_refs(*target_refs) + end + def self.merge_request_ref?(ref) ref.start_with?("refs/#{Repository::REF_MERGE_REQUEST}/") end diff --git a/app/models/note.rb b/app/models/note.rb index 4e9ea146485..5c31cff9816 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -158,6 +158,8 @@ class Note < ApplicationRecord Discussion.build_collection(all.includes(:noteable).fresh, context_noteable) end + # Note: Where possible consider using Discussion#lazy_find to return + # Discussions in order to benefit from having records batch loaded. def find_discussion(discussion_id) notes = where(discussion_id: discussion_id).fresh.to_a diff --git a/app/models/project.rb b/app/models/project.rb index 0f4fba5d0b6..bfc35b77b8f 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -15,6 +15,7 @@ class Project < ApplicationRecord include CaseSensitivity include TokenAuthenticatable include ValidAttribute + include ProjectAPICompatibility include ProjectFeaturesCompatibility include SelectForProjectAuthorization include Presentable @@ -2144,7 +2145,7 @@ class Project < ApplicationRecord public? && repository_exists? && Gitlab::CurrentSettings.hashed_storage_enabled && - Feature.enabled?(:object_pools, self) + Feature.enabled?(:object_pools, self, default_enabled: true) end def leave_pool_repository diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 0542581c6e0..7ff06655de0 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -24,6 +24,12 @@ class ProjectFeature < ApplicationRecord FEATURES = %i(issues merge_requests wiki snippets builds repository pages).freeze PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER }.freeze + STRING_OPTIONS = HashWithIndifferentAccess.new({ + 'disabled' => DISABLED, + 'private' => PRIVATE, + 'enabled' => ENABLED, + 'public' => PUBLIC + }).freeze class << self def access_level_attribute(feature) @@ -45,6 +51,14 @@ class ProjectFeature < ApplicationRecord PRIVATE_FEATURES_MIN_ACCESS_LEVEL.fetch(feature, Gitlab::Access::GUEST) 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) @@ -83,6 +97,10 @@ class ProjectFeature < ApplicationRecord 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 diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 8a179b4d56d..3802d258664 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ProjectStatistics < ApplicationRecord + include AfterCommitQueue + belongs_to :project belongs_to :namespace @@ -15,6 +17,7 @@ class ProjectStatistics < ApplicationRecord COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count].freeze INCREMENTABLE_COLUMNS = { build_artifacts_size: %i[storage_size], packages_size: %i[storage_size] }.freeze + NAMESPACE_RELATABLE_COLUMNS = [:repository_size, :wiki_size, :lfs_objects_size].freeze scope :for_project_ids, ->(project_ids) { where(project_id: project_ids) } @@ -22,13 +25,17 @@ class ProjectStatistics < ApplicationRecord repository_size + lfs_objects_size end - def refresh!(only: nil) + def refresh!(only: []) COLUMNS_TO_REFRESH.each do |column, generator| - if only.blank? || only.include?(column) + if only.empty? || only.include?(column) public_send("update_#{column}") # rubocop:disable GitlabSecurity/PublicSend end end + if only.empty? || only.any? { |column| NAMESPACE_RELATABLE_COLUMNS.include?(column) } + schedule_namespace_aggregation_worker + end + save! end @@ -81,4 +88,18 @@ class ProjectStatistics < ApplicationRecord update_all(updates.join(', ')) end + + private + + def schedule_namespace_aggregation_worker + run_after_commit do + next unless schedule_aggregation_worker? + + Namespaces::ScheduleAggregationWorker.perform_async(project.namespace_id) + end + end + + def schedule_aggregation_worker? + Feature.enabled?(:update_statistics_namespace, project&.root_ancestor) + end end diff --git a/app/models/repository.rb b/app/models/repository.rb index a408db7ebbe..a25d5abfa64 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -845,6 +845,10 @@ class Repository raw.merge_to_ref(user, source_sha, branch, target_ref, message, first_parent_ref) end + def delete_refs(*ref_names) + raw.delete_refs(*ref_names) + end + def ff_merge(user, source, target_branch, merge_request: nil) their_commit_id = commit(source)&.id raise 'Invalid merge source' if their_commit_id.nil? diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb index f2c7cb6a65d..ad08f4763ae 100644 --- a/app/models/resource_label_event.rb +++ b/app/models/resource_label_event.rb @@ -36,10 +36,9 @@ class ResourceLabelEvent < ApplicationRecord issue || merge_request end - # create same discussion id for all actions with the same user and time def discussion_id(resource = nil) strong_memoize(:discussion_id) do - Digest::SHA1.hexdigest([self.class.name, created_at, user_id].join("-")) + Digest::SHA1.hexdigest(discussion_id_key.join("-")) end end @@ -121,4 +120,8 @@ class ResourceLabelEvent < ApplicationRecord def resource_parent issuable.project || issuable.group end + + def discussion_id_key + [self.class.name, created_at, user_id] + end end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index dedab98b56d..9d4cf5df713 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -6,7 +6,7 @@ module Ci class RegisterJobService attr_reader :runner - JOB_QUEUE_DURATION_SECONDS_BUCKETS = [1, 3, 10, 30, 60, 300].freeze + JOB_QUEUE_DURATION_SECONDS_BUCKETS = [1, 3, 10, 30, 60, 300, 900].freeze JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET = 5.freeze Result = Struct.new(:build, :valid?) diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb index c4beddf2294..6d215d7a3b9 100644 --- a/app/services/issuable/bulk_update_service.rb +++ b/app/services/issuable/bulk_update_service.rb @@ -1,7 +1,15 @@ # frozen_string_literal: true module Issuable - class BulkUpdateService < IssuableBaseService + class BulkUpdateService + include Gitlab::Allowable + + attr_accessor :current_user, :params + + def initialize(user = nil, params = {}) + @current_user, @params = user, params.dup + end + # rubocop: disable CodeReuse/ActiveRecord def execute(type) model_class = type.classify.constantize diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 02de080e0ba..db673cace81 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -182,7 +182,7 @@ class IssuableBaseService < BaseService # To be overridden by subclasses end - def before_update(issuable) + def before_update(issuable, skip_spam_check: false) # To be overridden by subclasses end @@ -257,7 +257,7 @@ class IssuableBaseService < BaseService last_edited_at: Time.now, last_edited_by: current_user)) - before_update(issuable) + before_update(issuable, skip_spam_check: true) if issuable.with_transaction_returning_status { issuable.save } # We do not touch as it will affect a update on updated_at field diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 6b9f23f24cd..7cd825aa967 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -17,8 +17,8 @@ module Issues super end - def before_update(issue) - spam_check(issue, current_user) + def before_update(issue, skip_spam_check: false) + spam_check(issue, current_user) unless skip_spam_check end def handle_changes(issue, options) diff --git a/app/services/merge_requests/merge_base_service.rb b/app/services/merge_requests/merge_base_service.rb index 095bdca5472..1ed396cee1e 100644 --- a/app/services/merge_requests/merge_base_service.rb +++ b/app/services/merge_requests/merge_base_service.rb @@ -28,6 +28,17 @@ module MergeRequests private + def check_source + unless source + raise_error('No source for merge') + end + end + + # Overridden in EE. + def check_size_limit + # No-op + end + # Overridden in EE. def error_check! # No-op diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index d8a78001b79..3e0f5aa181c 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -48,13 +48,13 @@ module MergeRequests def error_check! super + check_source + error = if @merge_request.should_be_rebased? 'Only fast-forward merge is allowed for your project. Please update your source branch' elsif !@merge_request.mergeable? 'Merge request is not mergeable' - elsif !source - 'No source for merge' end raise_error(error) if error diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb index 0ea50a5dbf5..37b5805ae7e 100644 --- a/app/services/merge_requests/merge_to_ref_service.rb +++ b/app/services/merge_requests/merge_to_ref_service.rb @@ -16,7 +16,7 @@ module MergeRequests def execute(merge_request) @merge_request = merge_request - validate! + error_check! commit_id = commit @@ -39,21 +39,9 @@ module MergeRequests merge_request.diff_head_sha end - def validate! - error_check! - end - + override :error_check! def error_check! - super - - error = - if !hooks_validation_pass?(merge_request) - hooks_validation_error(merge_request) - elsif source.blank? - 'No source for merge' - end - - raise_error(error) if error + check_source end ## diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index f524d35d79e..98230684d56 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -43,11 +43,7 @@ = render_if_exists 'admin/namespace_plan_info', namespace: @group %li - %span.light= _('Storage:') - %strong= storage_counter(@group.storage_size) - ( - = storage_counters_details(@group) - ) + = render 'shared/storage_counter_statistics', storage_size: @group.storage_size, storage_details: @group %li %span.light= _('Group Git LFS status:') diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index e23accc1ea9..0fae8060b32 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -73,11 +73,7 @@ = @project.repository.relative_path %li - %span.light= _('Storage:') - %strong= storage_counter(@project.statistics&.storage_size) - - if @project.statistics - = surround '(', ')' do - = storage_counters_details(@project.statistics) + = render 'shared/storage_counter_statistics', storage_size: @project.statistics&.storage_size, storage_details: @project.statistics %li %span.light last commit: diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index 91d17cfd745..f05e269553a 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -1,3 +1,5 @@ +- @can_bulk_update = can?(current_user, :admin_issue, @group) + - page_title "Issues" = content_for :meta_tags do = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues") @@ -9,8 +11,15 @@ = render 'shared/issuable/nav', type: :issues .nav-controls = render 'shared/issuable/feed_buttons' + + - if @can_bulk_update + = render_if_exists 'shared/issuable/bulk_update_button' + = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues, with_feature_enabled: 'issues', with_shared: false, include_projects_in_subgroups: true = render 'shared/issuable/search_bar', type: :issues + - if @can_bulk_update + = render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues + = render 'shared/issues' diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 496ec3c78b0..a5f57f5893c 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -1,5 +1,5 @@ - if @group && @group.persisted? && @group.path - - group_data_attrs = { group_path: j(@group.path), name: @group.name, issues_path: issues_group_path(j(@group.path)), mr_path: merge_requests_group_path(j(@group.path)) } + - group_data_attrs = { group_path: j(@group.path), name: j(@group.name), issues_path: issues_group_path(@group), mr_path: merge_requests_group_path(@group) } - if @project && @project.persisted? - project_data_attrs = { project_path: j(@project.path), name: j(@project.name), issues_path: project_issues_path(@project), mr_path: project_merge_requests_path(@project), issues_disabled: !@project.issues_enabled? } .search.search-form{ data: { track_label: "navbar_search", track_event: "activate_form_input" } } diff --git a/app/views/shared/_storage_counter_statistics.html.haml b/app/views/shared/_storage_counter_statistics.html.haml new file mode 100644 index 00000000000..99b2323ca82 --- /dev/null +++ b/app/views/shared/_storage_counter_statistics.html.haml @@ -0,0 +1,4 @@ +%span.light= _('Storage:') +%strong= storage_counter(storage_size) +- if storage_details + (#{storage_counters_details(storage_details)}) diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 1bd56e064d5..214e87052da 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -17,8 +17,7 @@ = render 'shared/issuable/form/template_selector', issuable: issuable = render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?) -- if Gitlab::Graphql.enabled? - #js-suggestions{ data: { project_path: @project.full_path } } +#js-suggestions{ data: { project_path: @project.full_path } } = render 'shared/form_elements/description', model: issuable, form: form, project: project |