diff options
113 files changed, 1875 insertions, 373 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 87d73fc0c52..38fb743b0c9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,7 +1,7 @@ -image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.3-golang-1.8-git-2.13-phantomjs-2.1-node-8.x-yarn-1.0-postgresql-9.6" +image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.5-golang-1.8-git-2.13-phantomjs-2.1-node-8.x-yarn-1.0-postgresql-9.6" .default-cache: &default-cache - key: "ruby-233-with-yarn" + key: "ruby-235-with-yarn" paths: - vendor/ruby - .yarn-cache/ @@ -455,7 +455,7 @@ db:migrate:reset-mysql: variables: SETUP_DB: "false" script: - - git fetch origin v8.14.10 + - git fetch origin v9.3.0 - git checkout -f FETCH_HEAD - bundle install $BUNDLE_INSTALL_FLAGS - cp config/gitlab.yml.example config/gitlab.yml @@ -551,7 +551,7 @@ karma: <<: *dedicated-runner <<: *except-docs <<: *pull-cache - image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.3-golang-1.8-git-2.13-chrome-61.0-node-8.x-yarn-1.0-postgresql-9.6" + image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.5-golang-1.8-git-2.13-chrome-61.0-node-8.x-yarn-1.0-postgresql-9.6" stage: test variables: BABEL_ENV: "coverage" diff --git a/.ruby-version b/.ruby-version index 0bee604df76..cc6c9a491e0 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.3.3 +2.3.5 diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 564edf82ddf..c5d4cee36a1 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.50.0 +0.51.0 @@ -398,7 +398,7 @@ group :ed25519 do end # Gitaly GRPC client -gem 'gitaly-proto', '~> 0.48.0', require: 'gitaly' +gem 'gitaly-proto', '~> 0.51.0', require: 'gitaly' gem 'toml-rb', '~> 0.3.15', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 34f4e6af7e7..0a94a9ce497 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -273,7 +273,7 @@ GEM po_to_json (>= 1.0.0) rails (>= 3.2.0) gherkin-ruby (0.3.2) - gitaly-proto (0.48.0) + gitaly-proto (0.51.0) google-protobuf (~> 3.1) grpc (~> 1.0) github-linguist (4.7.6) @@ -1030,7 +1030,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.2.0) - gitaly-proto (~> 0.48.0) + gitaly-proto (~> 0.51.0) github-linguist (~> 4.7.0) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-markup (~> 1.6.2) diff --git a/app/assets/javascripts/clusters.js b/app/assets/javascripts/clusters.js index 180aa30e98c..661870c226c 100644 --- a/app/assets/javascripts/clusters.js +++ b/app/assets/javascripts/clusters.js @@ -64,19 +64,16 @@ export default class Clusters { this.poll = new Poll({ resource: this.service, method: 'fetchData', - successCallback: (data) => { - const { status, status_reason } = data.data; - this.updateContainer(status, status_reason); - }, - errorCallback: () => { - Flash(s__('ClusterIntegration|Something went wrong on our end.')); - }, + successCallback: data => this.handleSuccess(data), + errorCallback: () => Clusters.handleError(), }); if (!Visibility.hidden()) { this.poll.makeRequest(); } else { - this.service.fetchData(); + this.service.fetchData() + .then(data => this.handleSuccess(data)) + .catch(() => Clusters.handleError()); } Visibility.change(() => { @@ -88,6 +85,15 @@ export default class Clusters { }); } + static handleError() { + Flash(s__('ClusterIntegration|Something went wrong on our end.')); + } + + handleSuccess(data) { + const { status, status_reason } = data.data; + this.updateContainer(status, status_reason); + } + hideAll() { this.errorContainer.classList.add('hidden'); this.successContainer.classList.add('hidden'); diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index ea2e2205077..33a352e158a 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -7,10 +7,12 @@ import { highCountTrim } from '~/lib/utils/text_utility'; * @param {jQuery.Event} e * @param {String} count */ -$(document).on('todo:toggle', (e, count) => { - const parsedCount = parseInt(count, 10); - const $todoPendingCount = $('.todos-count'); +export default function initTodoToggle() { + $(document).on('todo:toggle', (e, count) => { + const parsedCount = parseInt(count, 10); + const $todoPendingCount = $('.todos-count'); - $todoPendingCount.text(highCountTrim(parsedCount)); - $todoPendingCount.toggleClass('hidden', parsedCount === 0); -}); + $todoPendingCount.text(highCountTrim(parsedCount)); + $todoPendingCount.toggleClass('hidden', parsedCount === 0); + }); +} diff --git a/app/assets/javascripts/init_legacy_filters.js b/app/assets/javascripts/init_legacy_filters.js index 1211c2c802c..fcf424408f2 100644 --- a/app/assets/javascripts/init_legacy_filters.js +++ b/app/assets/javascripts/init_legacy_filters.js @@ -1,15 +1,15 @@ /* eslint-disable no-new */ /* global LabelsSelect */ /* global MilestoneSelect */ -/* global IssueStatusSelect */ /* global SubscriptionSelect */ import UsersSelect from './users_select'; +import issueStatusSelect from './issue_status_select'; export default () => { new UsersSelect(); new LabelsSelect(); new MilestoneSelect(); - new IssueStatusSelect(); + issueStatusSelect(); new SubscriptionSelect(); }; diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js index bb509089b1d..4a15ec8b147 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js @@ -1,12 +1,11 @@ /* eslint-disable class-methods-use-this, no-new */ /* global LabelsSelect */ /* global MilestoneSelect */ -/* global IssueStatusSelect */ /* global SubscriptionSelect */ import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import './milestone_select'; -import './issue_status_select'; +import issueStatusSelect from './issue_status_select'; import './subscription_select'; import './labels_select'; @@ -49,7 +48,7 @@ export default class IssuableBulkUpdateSidebar { initDropdowns() { new LabelsSelect(); new MilestoneSelect(); - new IssueStatusSelect(); + issueStatusSelect(); new SubscriptionSelect(); } diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index 5bc7f8d9cb9..da99394ff90 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -2,11 +2,8 @@ import Cookies from 'js-cookie'; import bp from './breakpoints'; import UsersSelect from './users_select'; -const PARTICIPANTS_ROW_COUNT = 7; - export default class IssuableContext { constructor(currentUser) { - this.initParticipants(); this.userSelect = new UsersSelect(currentUser); $('select.select2').select2({ @@ -51,29 +48,4 @@ export default class IssuableContext { } }); } - - initParticipants() { - $(document).on('click', '.js-participants-more', this.toggleHiddenParticipants); - return $('.js-participants-author').each(function forEachAuthor(i) { - if (i >= PARTICIPANTS_ROW_COUNT) { - $(this).addClass('js-participants-hidden').hide(); - } - }); - } - - toggleHiddenParticipants() { - const currentText = $(this).text().trim(); - const lessText = $(this).data('less-text'); - const originalText = $(this).data('original-text'); - - if (currentText === originalText) { - $(this).text(lessText); - - if (gl.lazyLoader) gl.lazyLoader.loadCheck(); - } else { - $(this).text(originalText); - } - - $('.js-participants-hidden').toggle(); - } } diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 3fc29f9a661..acd5730cf3c 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -6,7 +6,7 @@ import TaskList from './task_list'; import CreateMergeRequestDropdown from './create_merge_request_dropdown'; import IssuablesHelper from './helpers/issuables_helper'; -class Issue { +export default class Issue { constructor() { if ($('a.btn-close').length) { this.taskList = new TaskList({ @@ -147,5 +147,3 @@ class Issue { }); } } - -export default Issue; diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js index 56cb536dcde..03546f61d1f 100644 --- a/app/assets/javascripts/issue_status_select.js +++ b/app/assets/javascripts/issue_status_select.js @@ -1,34 +1,23 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, quotes, object-shorthand, no-unused-vars, no-shadow, one-var, one-var-declaration-per-line, comma-dangle, max-len */ -(function() { - this.IssueStatusSelect = (function() { - function IssueStatusSelect() { - $('.js-issue-status').each(function(i, el) { - var fieldName; - fieldName = $(el).data("field-name"); - return $(el).glDropdown({ - selectable: true, - fieldName: fieldName, - toggleLabel: (function(_this) { - return function(selected, el, instance) { - var $item, label; - label = 'Author'; - $item = instance.dropdown.find('.is-active'); - if ($item.length) { - label = $item.text(); - } - return label; - }; - })(this), - clicked: function(options) { - return options.e.preventDefault(); - }, - id: function(obj, el) { - return $(el).data("id"); - } - }); - }); - } - - return IssueStatusSelect; - })(); -}).call(window); +export default function issueStatusSelect() { + $('.js-issue-status').each((i, el) => { + const fieldName = $(el).data('field-name'); + return $(el).glDropdown({ + selectable: true, + fieldName, + toggleLabel(selected, element, instance) { + let label = 'Author'; + const $item = instance.dropdown.find('.is-active'); + if ($item.length) { + label = $item.text(); + } + return label; + }, + clicked(options) { + return options.e.preventDefault(); + }, + id(obj, element) { + return $(element).data('id'); + }, + }); + }); +} diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index fd9d0c335a5..d743f20c615 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -54,11 +54,8 @@ import './gl_dropdown'; import './gl_field_error'; import './gl_field_errors'; import './gl_form'; -import './header'; +import initTodoToggle from './header'; import initImporterStatus from './importer_status'; -import './issuable_form'; -import './issue'; -import './issue_status_select'; import './labels_select'; import './layout_nav'; import LazyLoader from './lazy_loader'; @@ -137,6 +134,7 @@ $(function () { initBreadcrumbs(); initImporterStatus(); + initTodoToggle(); // Set the default path for all cookies to GitLab's root directory Cookies.defaults.path = gon.relative_url_root || '/'; diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue new file mode 100644 index 00000000000..b8510a6ce3a --- /dev/null +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -0,0 +1,125 @@ +<script> +import { __, n__, sprintf } from '../../../locale'; +import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; +import userAvatarImage from '../../../vue_shared/components/user_avatar/user_avatar_image.vue'; + +export default { + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + participants: { + type: Array, + required: false, + default: () => [], + }, + numberOfLessParticipants: { + type: Number, + required: false, + default: 7, + }, + }, + data() { + return { + isShowingMoreParticipants: false, + }; + }, + components: { + loadingIcon, + userAvatarImage, + }, + computed: { + lessParticipants() { + return this.participants.slice(0, this.numberOfLessParticipants); + }, + visibleParticipants() { + return this.isShowingMoreParticipants ? this.participants : this.lessParticipants; + }, + hasMoreParticipants() { + return this.participants.length > this.numberOfLessParticipants; + }, + toggleLabel() { + let label = ''; + if (this.isShowingMoreParticipants) { + label = __('- show less'); + } else { + label = sprintf(__('+ %{moreCount} more'), { + moreCount: this.participants.length - this.numberOfLessParticipants, + }); + } + + return label; + }, + participantLabel() { + return sprintf( + n__('%{count} participant', '%{count} participants', this.participants.length), + { count: this.loading ? '' : this.participantCount }, + ); + }, + participantCount() { + return this.participants.length; + }, + }, + methods: { + toggleMoreParticipants() { + this.isShowingMoreParticipants = !this.isShowingMoreParticipants; + }, + }, +}; +</script> + +<template> + <div> + <div class="sidebar-collapsed-icon"> + <i + class="fa fa-users" + aria-hidden="true"> + </i> + <loading-icon + v-if="loading" + class="js-participants-collapsed-loading-icon" /> + <span + v-else + class="js-participants-collapsed-count"> + {{ participantCount }} + </span> + </div> + <div class="title hide-collapsed"> + <loading-icon + v-if="loading" + :inline="true" + class="js-participants-expanded-loading-icon" /> + {{ participantLabel }} + </div> + <div class="participants-list hide-collapsed"> + <div + v-for="participant in visibleParticipants" + :key="participant.id" + class="participants-author js-participants-author"> + <a + class="author_link" + :href="participant.web_url"> + <user-avatar-image + :lazy="true" + :img-src="participant.avatar_url" + css-classes="avatar-inline" + :size="24" + :tooltip-text="participant.name" + tooltip-placement="bottom" /> + </a> + </div> + </div> + <div + v-if="hasMoreParticipants" + class="participants-more hide-collapsed"> + <button + type="button" + class="btn-transparent btn-blank js-toggle-participants-button" + @click="toggleMoreParticipants"> + {{ toggleLabel }} + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue new file mode 100644 index 00000000000..c1296b28db7 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue @@ -0,0 +1,26 @@ +<script> +import Store from '../../stores/sidebar_store'; +import Mediator from '../../sidebar_mediator'; +import participants from './participants.vue'; + +export default { + data() { + return { + mediator: new Mediator(), + store: new Store(), + }; + }, + components: { + participants, + }, +}; +</script> + +<template> + <div class="block participants"> + <participants + :loading="store.isFetching.participants" + :participants="store.participants" + :number-of-less-participants="7" /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue new file mode 100644 index 00000000000..4ad3d469f25 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue @@ -0,0 +1,45 @@ +<script> +import Store from '../../stores/sidebar_store'; +import Mediator from '../../sidebar_mediator'; +import eventHub from '../../event_hub'; +import Flash from '../../../flash'; +import subscriptions from './subscriptions.vue'; + +export default { + data() { + return { + mediator: new Mediator(), + store: new Store(), + }; + }, + + components: { + subscriptions, + }, + + methods: { + onToggleSubscription() { + this.mediator.toggleSubscription() + .catch(() => { + Flash('Error occurred when toggling the notification subscription'); + }); + }, + }, + + created() { + eventHub.$on('toggleSubscription', this.onToggleSubscription); + }, + + beforeDestroy() { + eventHub.$off('toggleSubscription', this.onToggleSubscription); + }, +}; +</script> + +<template> + <div class="block subscriptions"> + <subscriptions + :loading="store.isFetching.subscriptions" + :subscribed="store.subscribed" /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue new file mode 100644 index 00000000000..a3a8213d63a --- /dev/null +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -0,0 +1,60 @@ +<script> +import { __ } from '../../../locale'; +import eventHub from '../../event_hub'; +import loadingButton from '../../../vue_shared/components/loading_button.vue'; + +export default { + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + subscribed: { + type: Boolean, + required: false, + }, + }, + components: { + loadingButton, + }, + computed: { + buttonLabel() { + let label; + if (this.subscribed === false) { + label = __('Subscribe'); + } else if (this.subscribed === true) { + label = __('Unsubscribe'); + } + + return label; + }, + }, + methods: { + toggleSubscription() { + eventHub.$emit('toggleSubscription'); + }, + }, +}; +</script> + +<template> + <div> + <div class="sidebar-collapsed-icon"> + <i + class="fa fa-rss" + aria-hidden="true"> + </i> + </div> + <span class="issuable-header-text hide-collapsed pull-left"> + {{ __('Notifications') }} + </span> + <loading-button + ref="loadingButton" + class="btn btn-default pull-right hide-collapsed js-issuable-subscribe-button" + :loading="loading" + :label="buttonLabel" + @click="toggleSubscription" + /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js index 604648407a4..37c97225bfd 100644 --- a/app/assets/javascripts/sidebar/services/sidebar_service.js +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -7,6 +7,7 @@ export default class SidebarService { constructor(endpointMap) { if (!SidebarService.singleton) { this.endpoint = endpointMap.endpoint; + this.toggleSubscriptionEndpoint = endpointMap.toggleSubscriptionEndpoint; this.moveIssueEndpoint = endpointMap.moveIssueEndpoint; this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint; @@ -36,6 +37,10 @@ export default class SidebarService { }); } + toggleSubscription() { + return Vue.http.post(this.toggleSubscriptionEndpoint); + } + moveIssue(moveToProjectId) { return Vue.http.post(this.moveIssueEndpoint, { move_to_project_id: moveToProjectId, diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js index 09b9d75c02d..2650bb725d4 100644 --- a/app/assets/javascripts/sidebar/sidebar_bundle.js +++ b/app/assets/javascripts/sidebar/sidebar_bundle.js @@ -4,6 +4,8 @@ import SidebarAssignees from './components/assignees/sidebar_assignees'; import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue'; import SidebarMoveIssue from './lib/sidebar_move_issue'; import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue'; +import sidebarParticipants from './components/participants/sidebar_participants.vue'; +import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue'; import Translate from '../vue_shared/translate'; import Mediator from './sidebar_mediator'; @@ -49,6 +51,36 @@ function mountLockComponent(mediator) { }).$mount(el); } +function mountParticipantsComponent() { + const el = document.querySelector('.js-sidebar-participants-entry-point'); + + if (!el) return; + + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + sidebarParticipants, + }, + render: createElement => createElement('sidebar-participants', {}), + }); +} + +function mountSubscriptionsComponent() { + const el = document.querySelector('.js-sidebar-subscriptions-entry-point'); + + if (!el) return; + + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + sidebarSubscriptions, + }, + render: createElement => createElement('sidebar-subscriptions', {}), + }); +} + function domContentLoaded() { const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML); const mediator = new Mediator(sidebarOptions); @@ -63,6 +95,8 @@ function domContentLoaded() { mountConfidentialComponent(mediator); mountLockComponent(mediator); + mountParticipantsComponent(); + mountSubscriptionsComponent(); new SidebarMoveIssue( mediator, diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index ede3a0de144..2bda5a47791 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -8,6 +8,7 @@ export default class SidebarMediator { this.store = new Store(options); this.service = new Service({ endpoint: options.endpoint, + toggleSubscriptionEndpoint: options.toggleSubscriptionEndpoint, moveIssueEndpoint: options.moveIssueEndpoint, projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint, }); @@ -39,10 +40,25 @@ export default class SidebarMediator { .then((data) => { this.store.setAssigneeData(data); this.store.setTimeTrackingData(data); + this.store.setParticipantsData(data); + this.store.setSubscriptionsData(data); }) .catch(() => new Flash('Error occurred when fetching sidebar data')); } + toggleSubscription() { + this.store.setFetchingState('subscriptions', true); + return this.service.toggleSubscription() + .then(() => { + this.store.setSubscribedState(!this.store.subscribed); + this.store.setFetchingState('subscriptions', false); + }) + .catch((err) => { + this.store.setFetchingState('subscriptions', false); + throw err; + }); + } + fetchAutocompleteProjects(searchTerm) { return this.service.getProjectsAutocomplete(searchTerm) .then(response => response.json()) diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index d5d04103f3f..3150221b685 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -12,10 +12,14 @@ export default class SidebarStore { this.assignees = []; this.isFetching = { assignees: true, + participants: true, + subscriptions: true, }; this.autocompleteProjects = []; this.moveToProjectId = 0; this.isLockDialogOpen = false; + this.participants = []; + this.subscribed = null; SidebarStore.singleton = this; } @@ -37,6 +41,20 @@ export default class SidebarStore { this.humanTotalTimeSpent = data.human_total_time_spent; } + setParticipantsData(data) { + this.isFetching.participants = false; + this.participants = data.participants || []; + } + + setSubscriptionsData(data) { + this.isFetching.subscriptions = false; + this.subscribed = data.subscribed || false; + } + + setFetchingState(key, value) { + this.isFetching[key] = value; + } + addAssignee(assignee) { if (!this.findAssignee(assignee)) { this.assignees.push(assignee); @@ -61,6 +79,10 @@ export default class SidebarStore { this.autocompleteProjects = projects; } + setSubscribedState(subscribed) { + this.subscribed = subscribed; + } + setMoveToProjectId(moveToProjectId) { this.moveToProjectId = moveToProjectId; } diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js index 79c3d335679..99f5c305df5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -11,7 +11,7 @@ export default class MRWidgetService { this.removeWIPResource = Vue.resource(endpoints.removeWIPPath); this.removeSourceBranchResource = Vue.resource(endpoints.sourceBranchPath); this.deploymentsResource = Vue.resource(endpoints.ciEnvironmentsStatusPath); - this.pollResource = Vue.resource(`${endpoints.statusPath}?basic=true`); + this.pollResource = Vue.resource(`${endpoints.statusPath}?serializer=basic`); this.mergeActionsContentResource = Vue.resource(endpoints.mergeActionsContentPath); } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 48532503263..88600a0e6d3 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -542,7 +542,9 @@ } .participants-list { - margin: -5px; + display: flex; + flex-wrap: wrap; + margin: -7px; } @@ -553,7 +555,7 @@ .participants-author { display: inline-block; - padding: 5px; + padding: 7px; &:nth-of-type(7n) { padding-right: 0; diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index fe1334c0cfe..6a5e4538717 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -74,7 +74,7 @@ class Projects::IssuesController < Projects::ApplicationController respond_to do |format| format.html format.json do - render json: serializer.represent(@issue) + render json: serializer.represent(@issue, serializer: params[:serializer]) end end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index c5204080333..2b0294c8387 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -83,7 +83,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo format.json do Gitlab::PollingInterval.set_header(response, interval: 10_000) - render json: serializer.represent(@merge_request, basic: params[:basic]) + render json: serializer.represent(@merge_request, serializer: params[:serializer]) end format.patch do diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index c94384d2a1a..980bbf699b6 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -2,13 +2,13 @@ class Projects::MilestonesController < Projects::ApplicationController include MilestoneActions before_action :check_issuables_available! - before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels] + before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels, :promote] # Allow read any milestone before_action :authorize_read_milestone! # Allow admin milestone - before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels] + before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels, :promote] respond_to :html @@ -69,6 +69,14 @@ class Projects::MilestonesController < Projects::ApplicationController end end + def promote + promoted_milestone = Milestones::PromoteService.new(project, current_user).execute(milestone) + flash[:notice] = "Milestone has been promoted to group milestone." + redirect_to group_milestone_path(project.group, promoted_milestone.iid) + rescue Milestones::PromoteService::PromoteMilestoneError => error + redirect_to milestone, alert: error.message + end + def destroy return access_denied! unless can?(current_user, :admin_milestone, @project) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index baa2d6e375e..d0069cd48cf 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -33,15 +33,17 @@ module IssuablesHelper end def serialize_issuable(issuable) - case issuable - when Issue - IssueSerializer.new(current_user: current_user, project: issuable.project).represent(issuable).to_json - when MergeRequest - MergeRequestSerializer - .new(current_user: current_user, project: issuable.project) - .represent(issuable) - .to_json - end + serializer_klass = case issuable + when Issue + IssueSerializer + when MergeRequest + MergeRequestSerializer + end + + serializer_klass + .new(current_user: current_user, project: issuable.project) + .represent(issuable) + .to_json end def template_dropdown_tag(issuable, &block) @@ -357,7 +359,8 @@ module IssuablesHelper def issuable_sidebar_options(issuable, can_edit_issuable) { - endpoint: "#{issuable_json_path(issuable)}?basic=true", + endpoint: "#{issuable_json_path(issuable)}?serializer=sidebar", + toggleSubscriptionEndpoint: toggle_subscription_path(issuable), moveIssueEndpoint: move_namespace_project_issue_path(namespace_id: issuable.project.namespace.to_param, project_id: issuable.project, id: issuable), projectsAutocompleteEndpoint: autocomplete_projects_path(project_id: @project.id), editable: can_edit_issuable, diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 5a74511afa7..8ada746b244 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -19,11 +19,7 @@ module NavHelper end elsif current_path?('jobs#show') %w[page-gutter build-sidebar right-sidebar-expanded] - elsif current_path?('wikis#show') || - current_path?('wikis#edit') || - current_path?('wikis#update') || - current_path?('wikis#history') || - current_path?('wikis#git_access') + elsif current_controller?('wikis') && current_action?('show', 'create', 'edit', 'update', 'history', 'git_access') %w[page-gutter wiki-sidebar right-sidebar-expanded] else [] diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb index 274b38a7708..f478c8ede18 100644 --- a/app/models/concerns/subscribable.rb +++ b/app/models/concerns/subscribable.rb @@ -13,6 +13,8 @@ module Subscribable end def subscribed?(user, project = nil) + return false unless user + if subscription = subscriptions.find_by(user: user, project: project) subscription.subscribed else diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb index 670b26d4ca3..b75387e236e 100644 --- a/app/models/merge_request_diff_commit.rb +++ b/app/models/merge_request_diff_commit.rb @@ -17,7 +17,9 @@ class MergeRequestDiffCommit < ActiveRecord::Base commit_hash.merge( merge_request_diff_id: merge_request_diff_id, relative_order: index, - sha: sha_attribute.type_cast_for_database(sha) + sha: sha_attribute.type_cast_for_database(sha), + authored_date: Gitlab::Database.sanitize_timestamp(commit_hash[:authored_date]), + committed_date: Gitlab::Database.sanitize_timestamp(commit_hash[:committed_date]) ) end diff --git a/app/models/project.rb b/app/models/project.rb index 7185b4d44fc..413866b994a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -26,7 +26,15 @@ class Project < ActiveRecord::Base NUMBER_OF_PERMITTED_BOARDS = 1 UNKNOWN_IMPORT_URL = 'http://unknown.git'.freeze - LATEST_STORAGE_VERSION = 1 + # Hashed Storage versions handle rolling out new storage to project and dependents models: + # nil: legacy + # 1: repository + # 2: attachments + LATEST_STORAGE_VERSION = 2 + HASHED_STORAGE_FEATURES = { + repository: 1, + attachments: 2 + }.freeze cache_markdown_field :description, pipeline: :description @@ -120,6 +128,7 @@ class Project < ActiveRecord::Base has_one :mock_deployment_service has_one :mock_monitoring_service has_one :microsoft_teams_service + has_one :packagist_service # TODO: replace these relations with the fork network versions has_one :forked_project_link, foreign_key: "forked_to_project_id" @@ -1083,6 +1092,7 @@ class Project < ActiveRecord::Base def hook_attrs(backward: true) attrs = { + id: id, name: name, description: description, web_url: web_url, @@ -1394,6 +1404,19 @@ class Project < ActiveRecord::Base end end + def after_rename_repo + path_before_change = previous_changes['path'].first + + # We need to check if project had been rolled out to move resource to hashed storage or not and decide + # if we need execute any take action or no-op. + + unless hashed_storage?(:attachments) + Gitlab::UploadsTransfer.new.rename_project(path_before_change, self.path, namespace.full_path) + end + + Gitlab::PagesTransfer.new.rename_project(path_before_change, self.path, namespace.full_path) + end + def rename_repo_notify! send_move_instructions(full_path_was) expires_full_path_cache @@ -1404,13 +1427,6 @@ class Project < ActiveRecord::Base reload_repository! end - def after_rename_repo - path_before_change = previous_changes['path'].first - - Gitlab::UploadsTransfer.new.rename_project(path_before_change, self.path, namespace.full_path) - Gitlab::PagesTransfer.new.rename_project(path_before_change, self.path, namespace.full_path) - end - def running_or_pending_build_count(force: false) Rails.cache.fetch(['projects', id, 'running_or_pending_build_count'], force: force) do builds.running_or_pending.count(:all) @@ -1600,8 +1616,13 @@ class Project < ActiveRecord::Base [nil, 0].include?(self.storage_version) end - def hashed_storage? - self.storage_version && self.storage_version >= 1 + # Check if Hashed Storage is enabled for the project with at least informed feature rolled out + # + # @param [Symbol] feature that needs to be rolled out for the project (:repository, :attachments) + def hashed_storage?(feature) + raise ArgumentError, "Invalid feature" unless HASHED_STORAGE_FEATURES.include?(feature) + + self.storage_version && self.storage_version >= HASHED_STORAGE_FEATURES[feature] end def renamed? @@ -1637,7 +1658,7 @@ class Project < ActiveRecord::Base end def migrate_to_hashed_storage! - return if hashed_storage? + return if hashed_storage?(:repository) update!(repository_read_only: true) @@ -1662,7 +1683,7 @@ class Project < ActiveRecord::Base def storage @storage ||= - if hashed_storage? + if hashed_storage?(:repository) Storage::HashedProject.new(self) else Storage::LegacyProject.new(self) diff --git a/app/models/project_services/packagist_service.rb b/app/models/project_services/packagist_service.rb new file mode 100644 index 00000000000..f68a0c1a3c3 --- /dev/null +++ b/app/models/project_services/packagist_service.rb @@ -0,0 +1,65 @@ +class PackagistService < Service + include HTTParty + + prop_accessor :username, :token, :server + + validates :username, presence: true, if: :activated? + validates :token, presence: true, if: :activated? + + default_value_for :push_events, true + default_value_for :tag_push_events, true + + after_save :compose_service_hook, if: :activated? + + def title + 'Packagist' + end + + def description + 'Update your project on Packagist, the main Composer repository' + end + + def self.to_param + 'packagist' + end + + def fields + [ + { type: 'text', name: 'username', placeholder: '', required: true }, + { type: 'text', name: 'token', placeholder: '', required: true }, + { type: 'text', name: 'server', placeholder: 'https://packagist.org', required: false } + ] + end + + def self.supported_events + %w(push merge_request tag_push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + service_hook.execute(data) + end + + def test(data) + begin + result = execute(data) + return { success: false, result: result[:message] } if result[:http_status] != 202 + rescue StandardError => error + return { success: false, result: error } + end + + { success: true, result: result[:message] } + end + + def compose_service_hook + hook = service_hook || build_service_hook + hook.url = hook_url + hook.save + end + + def hook_url + base_url = server.present? ? server : 'https://packagist.org' + "#{base_url}/api/update-package?username=#{username}&apiToken=#{token}" + end +end diff --git a/app/models/service.rb b/app/models/service.rb index 6b64079215f..fdd2605e3e3 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -238,6 +238,7 @@ class Service < ActiveRecord::Base kubernetes mattermost_slash_commands mattermost + packagist pipelines_email pivotaltracker prometheus diff --git a/app/serializers/issuable_sidebar_entity.rb b/app/serializers/issuable_sidebar_entity.rb new file mode 100644 index 00000000000..ff23d8bf0c7 --- /dev/null +++ b/app/serializers/issuable_sidebar_entity.rb @@ -0,0 +1,16 @@ +class IssuableSidebarEntity < Grape::Entity + include RequestAwareEntity + + expose :participants, using: ::API::Entities::UserBasic do |issuable| + issuable.participants(request.current_user) + end + + expose :subscribed do |issuable| + issuable.subscribed?(request.current_user, issuable.project) + end + + expose :time_estimate + expose :total_time_spent + expose :human_time_estimate + expose :human_total_time_spent +end diff --git a/app/serializers/issue_serializer.rb b/app/serializers/issue_serializer.rb index 4fff54a9126..2555595379b 100644 --- a/app/serializers/issue_serializer.rb +++ b/app/serializers/issue_serializer.rb @@ -1,3 +1,16 @@ class IssueSerializer < BaseSerializer - entity IssueEntity + # This overrided method takes care of which entity should be used + # to serialize the `issue` based on `basic` key in `opts` param. + # Hence, `entity` doesn't need to be declared on the class scope. + def represent(merge_request, opts = {}) + entity = + case opts[:serializer] + when 'sidebar' + IssueSidebarEntity + else + IssueEntity + end + + super(merge_request, opts, entity) + end end diff --git a/app/serializers/issue_sidebar_entity.rb b/app/serializers/issue_sidebar_entity.rb new file mode 100644 index 00000000000..6c823dbfe95 --- /dev/null +++ b/app/serializers/issue_sidebar_entity.rb @@ -0,0 +1,3 @@ +class IssueSidebarEntity < IssuableSidebarEntity + expose :assignees, using: API::Entities::UserBasic +end diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb index 8461f158bb5..d54a6516aed 100644 --- a/app/serializers/merge_request_basic_entity.rb +++ b/app/serializers/merge_request_basic_entity.rb @@ -1,11 +1,7 @@ -class MergeRequestBasicEntity < Grape::Entity +class MergeRequestBasicEntity < IssuableSidebarEntity expose :assignee_id expose :merge_status expose :merge_error expose :state expose :source_branch_exists?, as: :source_branch_exists - expose :time_estimate - expose :total_time_spent - expose :human_time_estimate - expose :human_total_time_spent end diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index f67034ce47a..e9d98d8baca 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -3,7 +3,14 @@ class MergeRequestSerializer < BaseSerializer # to serialize the `merge_request` based on `basic` key in `opts` param. # Hence, `entity` doesn't need to be declared on the class scope. def represent(merge_request, opts = {}) - entity = opts[:basic] ? MergeRequestBasicEntity : MergeRequestEntity + entity = + case opts[:serializer] + when 'basic', 'sidebar' + MergeRequestBasicEntity + else + MergeRequestEntity + end + super(merge_request, opts, entity) end end diff --git a/app/services/milestones/promote_service.rb b/app/services/milestones/promote_service.rb new file mode 100644 index 00000000000..bd9cfd4e0ea --- /dev/null +++ b/app/services/milestones/promote_service.rb @@ -0,0 +1,80 @@ +module Milestones + class PromoteService < Milestones::BaseService + PromoteMilestoneError = Class.new(StandardError) + + def execute(milestone) + check_project_milestone!(milestone) + + Milestone.transaction do + # Destroy all milestones with same title across projects + destroy_old_milestones(milestone) + + group_milestone = clone_project_milestone(milestone) + + move_children_to_group_milestone(group_milestone) + + # Just to be safe + unless group_milestone.valid? + raise_error(group_milestone.errors.full_messages.to_sentence) + end + + group_milestone + end + end + + private + + def milestone_ids_for_merge(group_milestone) + # Pluck need to be used here instead of select so the array of ids + # is persistent after old milestones gets deleted. + @milestone_ids_for_merge ||= begin + search_params = { title: group_milestone.title, project_ids: group_project_ids, state: 'all' } + milestones = MilestonesFinder.new(search_params).execute + milestones.pluck(:id) + end + end + + def move_children_to_group_milestone(group_milestone) + milestone_ids_for_merge(group_milestone).in_groups_of(100) do |milestone_ids| + update_children(group_milestone, milestone_ids) + end + end + + def check_project_milestone!(milestone) + raise_error('Only project milestones can be promoted.') unless milestone.project_milestone? + end + + def clone_project_milestone(milestone) + params = milestone.slice(:title, :description, :start_date, :due_date, :state_event) + + create_service = CreateService.new(group, current_user, params) + + create_service.execute + end + + def update_children(group_milestone, milestone_ids) + issues = Issue.where(project_id: group_project_ids, milestone_id: milestone_ids) + merge_requests = MergeRequest.where(source_project_id: group_project_ids, milestone_id: milestone_ids) + + [issues, merge_requests].each do |issuable_collection| + issuable_collection.update_all(milestone_id: group_milestone.id) + end + end + + def group + @group ||= parent.group || raise_error('Project does not belong to a group.') + end + + def destroy_old_milestones(group_milestone) + Milestone.where(id: milestone_ids_for_merge(group_milestone)).destroy_all + end + + def group_project_ids + @group_project_ids ||= group.projects.map(&:id) + end + + def raise_error(message) + raise PromoteMilestoneError, "Promotion failed - #{message}" + end + end +end diff --git a/app/services/projects/hashed_storage_migration_service.rb b/app/services/projects/hashed_storage_migration_service.rb index 41259de3a16..f5945f3b87f 100644 --- a/app/services/projects/hashed_storage_migration_service.rb +++ b/app/services/projects/hashed_storage_migration_service.rb @@ -10,7 +10,7 @@ module Projects end def execute - return if project.hashed_storage? + return if project.hashed_storage?(:repository) @old_disk_path = project.disk_path has_wiki = project.wiki.repository_exists? diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index 7027ac4b5db..d4ba3a028be 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -30,7 +30,7 @@ class FileUploader < GitlabUploader # # Returns a String without a trailing slash def self.dynamic_path_segment(model) - File.join(CarrierWave.root, base_dir, model.full_path) + File.join(CarrierWave.root, base_dir, model.disk_path) end attr_accessor :model diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index a5153df1159..9fc297ab7f6 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -23,14 +23,18 @@ = milestone_date_range(@milestone) .milestone-buttons - if can?(current_user, :admin_milestone, @project) + = link_to edit_project_milestone_path(@project, @milestone), class: "btn btn-grouped btn-nr" do + Edit + + - if @project.group + = link_to promote_project_milestone_path(@milestone.project, @milestone), title: "Promote to Group Milestone", class: 'btn btn-grouped', data: { confirm: "Promoting this milestone will make it available for all projects inside the group. Existing project milestones with the same name will be merged. Are you sure?", toggle: "tooltip" }, method: :post do + Promote + - if @milestone.active? = link_to 'Close milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped" - else = link_to 'Reopen milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped" - = link_to edit_project_milestone_path(@project, @milestone), class: "btn btn-grouped btn-nr" do - Edit - = link_to project_milestone_path(@project, @milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-grouped btn-danger" do Delete @@ -40,6 +44,7 @@ .detail-page-description.milestone-detail %h2.title = markdown_field(@milestone, :title) + %div - if @milestone.description.present? .description diff --git a/app/views/shared/issuable/_participants.html.haml b/app/views/shared/issuable/_participants.html.haml deleted file mode 100644 index 3f553c9fede..00000000000 --- a/app/views/shared/issuable/_participants.html.haml +++ /dev/null @@ -1,18 +0,0 @@ -- participants_row = 7 -- participants_size = participants.size -- participants_extra = participants_size - participants_row -.block.participants - .sidebar-collapsed-icon - = icon('users') - %span - = participants.count - .title.hide-collapsed - = pluralize participants.count, "participant" - .hide-collapsed.participants-list - - participants.each do |participant| - .participants-author.js-participants-author - = link_to_member(@project, participant, name: false, size: 24, lazy_load: true) - - if participants_extra > 0 - .hide-collapsed.participants-more - %button.btn-transparent.btn-blank.js-participants-more{ type: 'button', data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } } - + #{participants_extra} more diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 7b7411b1e23..e0009a35b9f 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -123,17 +123,10 @@ %script#js-lock-issue-data{ type: "application/json" }= { is_locked: issuable.discussion_locked?, is_editable: can_edit_issuable }.to_json.html_safe #js-lock-entry-point - = render "shared/issuable/participants", participants: issuable.participants(current_user) + .js-sidebar-participants-entry-point + - if current_user - - subscribed = issuable.subscribed?(current_user, @project) - .block.light.subscription{ data: { url: toggle_subscription_path(issuable) } } - .sidebar-collapsed-icon - = icon('rss', 'aria-hidden': 'true') - %span.issuable-header-text.hide-collapsed.pull-left - Notifications - - subscribtion_status = subscribed ? 'subscribed' : 'unsubscribed' - %button.btn.btn-default.pull-right.js-subscribe-button.issuable-subscribe-button.hide-collapsed{ type: "button" } - %span= subscribed ? 'Unsubscribe' : 'Subscribe' + .js-sidebar-subscriptions-entry-point - project_ref = cross_project_reference(@project, issuable) .block.project-reference diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 305e2542281..7ba8f9d4313 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -49,6 +49,13 @@ = link_to edit_project_milestone_path(milestone.project, milestone), class: "btn btn-xs btn-grouped" do Edit \ + + - if @project.group + = link_to promote_project_milestone_path(milestone.project, milestone), title: "Promote to Group Milestone", class: 'btn btn-xs btn-grouped', data: { confirm: "Promoting this milestone will make it available for all projects inside the group. Existing project milestones with the same name will be merged. Are you sure?", toggle: "tooltip" }, method: :post do + Promote + = link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-xs btn-close btn-grouped" + = link_to project_milestone_path(milestone.project, milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-xs btn-remove btn-grouped" do Delete + diff --git a/changelogs/unreleased/23206-load-participants-async.yml b/changelogs/unreleased/23206-load-participants-async.yml new file mode 100644 index 00000000000..12ab43fb88f --- /dev/null +++ b/changelogs/unreleased/23206-load-participants-async.yml @@ -0,0 +1,5 @@ +--- +title: Update participants and subscriptions button in issuable sidebar to be async +merge_request: 14836 +author: +type: changed diff --git a/changelogs/unreleased/31454-missing-project-id-pipeline-hook-data.yml b/changelogs/unreleased/31454-missing-project-id-pipeline-hook-data.yml new file mode 100644 index 00000000000..daf7ac715bd --- /dev/null +++ b/changelogs/unreleased/31454-missing-project-id-pipeline-hook-data.yml @@ -0,0 +1,5 @@ +--- +title: Adds project_id to pipeline hook data +merge_request: 15044 +author: Jacopo Beschi @jacopo-beschi +type: added diff --git a/changelogs/unreleased/3674-hashed-storage-attachments.yml b/changelogs/unreleased/3674-hashed-storage-attachments.yml new file mode 100644 index 00000000000..41bdb5fa568 --- /dev/null +++ b/changelogs/unreleased/3674-hashed-storage-attachments.yml @@ -0,0 +1,5 @@ +--- +title: Hashed Storage support for Attachments +merge_request: 15068 +author: +type: added diff --git a/changelogs/unreleased/39509-fix-wiki-create-sidebar-overlap.yml b/changelogs/unreleased/39509-fix-wiki-create-sidebar-overlap.yml new file mode 100644 index 00000000000..aebf6363d97 --- /dev/null +++ b/changelogs/unreleased/39509-fix-wiki-create-sidebar-overlap.yml @@ -0,0 +1,5 @@ +--- +title: Fix overlap of right-sidebar and main content when creating a Wiki page +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/39619-cancel-merge-when-pipeline-succeeds-from-the-api-fails.yml b/changelogs/unreleased/39619-cancel-merge-when-pipeline-succeeds-from-the-api-fails.yml new file mode 100644 index 00000000000..95251b46ecc --- /dev/null +++ b/changelogs/unreleased/39619-cancel-merge-when-pipeline-succeeds-from-the-api-fails.yml @@ -0,0 +1,5 @@ +--- +title: Fix namespacing for MergeWhenPipelineSucceedsService in MR API +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/39639-clusters-poll.yml b/changelogs/unreleased/39639-clusters-poll.yml new file mode 100644 index 00000000000..f0a82f58b19 --- /dev/null +++ b/changelogs/unreleased/39639-clusters-poll.yml @@ -0,0 +1,5 @@ +--- +title: Adds callback functions for initial request in clusters page +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/add-packagist-project-service.yml b/changelogs/unreleased/add-packagist-project-service.yml new file mode 100644 index 00000000000..a13d00e91f7 --- /dev/null +++ b/changelogs/unreleased/add-packagist-project-service.yml @@ -0,0 +1,5 @@ +--- +title: Add Packagist project service +merge_request: 14493 +author: Matt Coleman +type: added diff --git a/changelogs/unreleased/fix-import-issue-assignees.yml b/changelogs/unreleased/fix-import-issue-assignees.yml new file mode 100644 index 00000000000..063b6afaf08 --- /dev/null +++ b/changelogs/unreleased/fix-import-issue-assignees.yml @@ -0,0 +1,5 @@ +--- +title: Fix missing Import/Export issue assignees +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/issue_38777.yml b/changelogs/unreleased/issue_38777.yml new file mode 100644 index 00000000000..5c49b2f7879 --- /dev/null +++ b/changelogs/unreleased/issue_38777.yml @@ -0,0 +1,5 @@ +--- +title: Allow promoting project milestones to group milestones +merge_request: +author: +type: added diff --git a/changelogs/unreleased/zj-ruby-2-3-5.yml b/changelogs/unreleased/zj-ruby-2-3-5.yml new file mode 100644 index 00000000000..09ec02417aa --- /dev/null +++ b/changelogs/unreleased/zj-ruby-2-3-5.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade Ruby to 2.3.5 to include security patches +merge_request: 15099 +author: +type: security diff --git a/config/routes/project.rb b/config/routes/project.rb index 9f553085d50..746c0c46677 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -293,6 +293,7 @@ constraints(ProjectUrlConstrainer.new) do resources :milestones, constraints: { id: /\d+/ } do member do + post :promote put :sort_issues put :sort_merge_requests get :merge_requests diff --git a/doc/administration/repository_storage_types.md b/doc/administration/repository_storage_types.md index fa882bbe28a..0cb2648cc1e 100644 --- a/doc/administration/repository_storage_types.md +++ b/doc/administration/repository_storage_types.md @@ -25,7 +25,10 @@ Any change in the URL will need to be reflected on disk (when groups / users or of load in big installations, and can be even worst if they are using any type of network based filesystem. Last, for GitLab Geo, this storage type means we have to synchronize the disk state, replicate renames in the correct -order or we may end-up with wrong repository or missing data temporarily. +order or we may end-up with wrong repository or missing data temporarily. + +This pattern also exists in other objects stored in GitLab, like issue Attachments, GitLab Pages artifacts, +Docker Containers for the integrated Registry, etc. ## Hashed Storage @@ -67,3 +70,23 @@ To migrate your existing projects to the new storage type, check the specific [r [ce-28283]: https://gitlab.com/gitlab-org/gitlab-ce/issues/28283 [rake tasks]: raketasks/storage.md#migrate-existing-projects-to-hashed-storage [storage-paths]: repository_storage_types.md + +### Hashed Storage coverage + +We are incrementally moving every storable object in GitLab to the Hashed Storage pattern. You can check the current +coverage status below. + +Not that things stored in S3 compatible endpoint, will not have the downsides mentioned earlier, if they are not +prefixed with `#{namespace}/#{project_name}`, which is true for CI Cache and LFS Objects. + +| Storable Object | Legacy Storage | Hashed Storage | S3 Compatible | GitLab Version | +| ----------------| -------------- | -------------- | ------------- | -------------- | +| Repository | Yes | Yes | - | 10.0 | +| Attachments | Yes | Yes | - | 10.2 | +| Avatars | Yes | No | - | - | +| Pages | Yes | No | - | - | +| Docker Registry | Yes | No | - | - | +| CI Build Logs | No | No | - | - | +| CI Artifacts | No | No | - | - | +| CI Cache | No | No | Yes | - | +| LFS Objects | Yes | No | Yes (EEP) | - | diff --git a/doc/api/pipelines.md b/doc/api/pipelines.md index 890945cfc7e..a6631cab8c3 100644 --- a/doc/api/pipelines.md +++ b/doc/api/pipelines.md @@ -57,7 +57,7 @@ GET /projects/:id/pipelines/:pipeline_id | `pipeline_id` | integer | yes | The ID of a pipeline | ``` -curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/pipeline/46" +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/pipelines/46" ``` Example of response diff --git a/doc/api/services.md b/doc/api/services.md index 6c8f196fd5c..e642ec964de 100644 --- a/doc/api/services.md +++ b/doc/api/services.md @@ -582,6 +582,40 @@ Delete Mattermost slash command service for a project. DELETE /projects/:id/services/mattermost-slash-commands ``` +## Packagist + +Update your project on Packagist, the main Composer repository, when commits or tags are pushed to GitLab. + +### Create/Edit Packagist service + +Set Packagist service for a project. + +``` +PUT /projects/:id/services/packagist +``` + +Parameters: + +- `username` (**required**) +- `token` (**required**) +- `server` (optional) + +### Delete Packagist service + +Delete Packagist service for a project. + +``` +DELETE /projects/:id/services/packagist +``` + +### Get Packagist service settings + +Get Packagist service settings for a project. + +``` +GET /projects/:id/services/packagist +``` + ## Pipeline-Emails Get emails for GitLab CI pipelines. diff --git a/doc/policy/maintenance.md b/doc/policy/maintenance.md index 7ab56c89014..8d0afa9e692 100644 --- a/doc/policy/maintenance.md +++ b/doc/policy/maintenance.md @@ -19,24 +19,30 @@ For example, for GitLab version 10.5.7: * `5` represents minor version * `7` represents patch number -## Security releases +## Patch releases -The current stable release will receive security patches and bug fixes -(eg. `8.9.0` -> `8.9.1`). +Patch releases usually only include bug fixes and are only done for the current +stable release. That said, in some cases, we may backport it to previous stable +release, depending on the severity of the bug. -Feature releases will mark the next supported stable -release where the minor version is increased numerically by increments of one -(eg. `8.9 -> 8.10`). +For instance, if we release `10.1.1` with a fix for a severe bug introduced in +`10.0.0`, we could backport the fix to a new `10.0.x` patch release. -Our current policy is to support one stable release at any given time. -For medium-level security issues, we may consider backporting to the previous two +### Security releases + +Security releases are a special kind of patch release that only include security +fixes and patches (see below). + +Our current policy is to support one stable release at any given time, but for +medium-level security issues, we may backport security fixes to the previous two monthly releases. -For very serious security issues, there is [precedent](https://about.gitlab.com/2016/05/02/cve-2016-4340-patches/) -to backport security fixes to even more monthly releases of GitLab. This decision -is made on a case-by-case basis. +For very serious security issues, there is +[precedent](https://about.gitlab.com/2016/05/02/cve-2016-4340-patches/) +to backport security fixes to even more monthly releases of GitLab. +This decision is made on a case-by-case basis. -## Version support +## Upgrade recommendations We encourage everyone to run the latest stable release to ensure that you can easily upgrade to the most secure and feature-rich GitLab experience. In order @@ -70,7 +76,6 @@ Please see the table below for some examples: | -------------- | ------------ | ------------------------ | ---------------- | | 9.4.5 | 8.13.4 | `8.13.4` -> `8.17.7` -> `9.4.5` | `8.17.7` is the last version in version `8` | | 10.1.4 | 8.13.4 | `8.13.4` -> `8.17.7` -> `9.5.8` -> `10.1.4` | `8.17.7` is the last version in version `8`, `9.5.8` is the last version in version `9` | -| More information about the release procedures can be found in our [release-tools documentation][rel]. You may also want to read our diff --git a/doc/user/project/integrations/project_services.md b/doc/user/project/integrations/project_services.md index 51989ccaaea..a0405161495 100644 --- a/doc/user/project/integrations/project_services.md +++ b/doc/user/project/integrations/project_services.md @@ -43,6 +43,7 @@ Click on the service links to see further configuration instructions and details | [Mattermost slash commands](mattermost_slash_commands.md) | Mattermost chat and ChatOps slash commands | | [Mattermost Notifications](mattermost.md) | Receive event notifications in Mattermost | | [Microsoft teams](microsoft_teams.md) | Receive notifications for actions that happen on GitLab into a room on Microsoft Teams using Office 365 Connectors | +| Packagist | Update your project on Packagist, the main Composer repository | | Pipelines emails | Email the pipeline status to a list of recipients | | [Slack Notifications](slack.md) | Send GitLab events (e.g. issue created) to Slack as notifications | | [Slack slash commands](slack_slash_commands.md) | Use slash commands in Slack to control GitLab | diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index 7abc600a680..df75e25e12b 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -76,6 +76,7 @@ X-Gitlab-Event: Push Hook "user_avatar": "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80", "project_id": 15, "project":{ + "id": 15, "name":"Diaspora", "description":"", "web_url":"http://example.com/mike/diaspora", @@ -156,6 +157,7 @@ X-Gitlab-Event: Tag Push Hook "user_avatar": "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80", "project_id": 1, "project":{ + "id": 1, "name":"Example", "description":"", "web_url":"http://example.com/jsmith/example", @@ -206,6 +208,7 @@ X-Gitlab-Event: Issue Hook "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" }, "project": { + "id": 1, "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlabhq/gitlab-test", @@ -335,6 +338,7 @@ X-Gitlab-Event: Note Hook }, "project_id": 5, "project":{ + "id": 5, "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlabhq/gitlab-test", @@ -414,6 +418,7 @@ X-Gitlab-Event: Note Hook }, "project_id": 5, "project":{ + "id": 5, "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlab-org/gitlab-test", @@ -540,6 +545,7 @@ X-Gitlab-Event: Note Hook }, "project_id": 5, "project":{ + "id": 5, "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlab-org/gitlab-test", @@ -618,6 +624,7 @@ X-Gitlab-Event: Note Hook }, "project_id": 5, "project":{ + "id": 5, "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlab-org/gitlab-test", @@ -692,6 +699,7 @@ X-Gitlab-Event: Merge Request Hook "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" }, "project": { + "id": 1, "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlabhq/gitlab-test", @@ -848,6 +856,7 @@ X-Gitlab-Event: Wiki Page Hook "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon" }, "project": { + "id": 1, "name": "awesome-project", "description": "This is awesome", "web_url": "http://example.com/root/awesome-project", @@ -919,6 +928,7 @@ X-Gitlab-Event: Pipeline Hook "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" }, "project":{ + "id": 1, "name": "Gitlab Test", "description": "Atque in sunt eos similique dolores voluptatem.", "web_url": "http://192.168.64.1:3005/gitlab-org/gitlab-test", diff --git a/doc/user/project/milestones/index.md b/doc/user/project/milestones/index.md index 876b98a4dc5..83adbd8cce2 100644 --- a/doc/user/project/milestones/index.md +++ b/doc/user/project/milestones/index.md @@ -29,7 +29,8 @@ In addition to that you will be able to filter issues or merge requests by group ## Milestone promotion -You will be able to promote a project milestone to a group milestone [in the future](https://gitlab.com/gitlab-org/gitlab-ce/issues/35833). +Project milestones can be promoted to group milestones if its project belongs to a group. When a milestone is promoted all other milestones across the group projects with the same title will be merged into it, which means all milestone's children like issues, merge requests and boards will be moved into the new promoted milestone. +The promote button can be found in the milestone view or milestones list. ## Special milestone filters diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb index 2c3ef2efd52..3843374678c 100644 --- a/features/steps/project/issues/issues.rb +++ b/features/steps/project/issues/issues.rb @@ -20,11 +20,13 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end step 'I should see that I am subscribed' do - expect(find('.issuable-subscribe-button span')).to have_content 'Unsubscribe' + wait_for_requests + expect(find('.js-issuable-subscribe-button span')).to have_content 'Unsubscribe' end step 'I should see that I am unsubscribed' do - expect(find('.issuable-subscribe-button span')).to have_content 'Subscribe' + wait_for_requests + expect(find('.js-issuable-subscribe-button span')).to have_content 'Subscribe' end step 'I click link "Closed"' do diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index be843ec8251..726f09e3669 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -295,7 +295,7 @@ module API unauthorized! unless merge_request.can_cancel_merge_when_pipeline_succeeds?(current_user) - ::MergeRequest::MergeWhenPipelineSucceedsService + ::MergeRequests::MergeWhenPipelineSucceedsService .new(merge_request.target_project, current_user) .cancel(merge_request) end diff --git a/lib/api/services.rb b/lib/api/services.rb index 1e4f7c29633..6454e475036 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -374,6 +374,26 @@ module API desc: 'The Slack token' } ], + 'packagist' => [ + { + required: true, + name: :username, + type: String, + desc: 'The username' + }, + { + required: true, + name: :token, + type: String, + desc: 'The Packagist API token' + }, + { + required: false, + name: :server, + type: String, + desc: 'The server' + } + ], 'pipelines-email' => [ { required: true, @@ -551,6 +571,7 @@ module API KubernetesService, MattermostSlashCommandsService, SlackSlashCommandsService, + PackagistService, PipelinesEmailService, PivotaltrackerService, PrometheusService, diff --git a/lib/api/v3/services.rb b/lib/api/v3/services.rb index 2d13d6fabfd..44ed94d2869 100644 --- a/lib/api/v3/services.rb +++ b/lib/api/v3/services.rb @@ -395,6 +395,26 @@ module API desc: 'The Slack token' } ], + 'packagist' => [ + { + required: true, + name: :username, + type: String, + desc: 'The username' + }, + { + required: true, + name: :token, + type: String, + desc: 'The Packagist API token' + }, + { + required: false, + name: :server, + type: String, + desc: 'The server' + } + ], 'pipelines-email' => [ { required: true, diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb index 357f16936c6..43a00d6cedb 100644 --- a/lib/gitlab/database.rb +++ b/lib/gitlab/database.rb @@ -4,6 +4,10 @@ module Gitlab # https://www.postgresql.org/docs/9.2/static/datatype-numeric.html # http://dev.mysql.com/doc/refman/5.7/en/integer-types.html MAX_INT_VALUE = 2147483647 + # The max value between MySQL's TIMESTAMP and PostgreSQL's timestampz: + # https://www.postgresql.org/docs/9.1/static/datatype-datetime.html + # https://dev.mysql.com/doc/refman/5.7/en/datetime.html + MAX_TIMESTAMP_VALUE = Time.at((1 << 31) - 1).freeze def self.config ActiveRecord::Base.configurations[Rails.env] @@ -120,6 +124,10 @@ module Gitlab EOF end + def self.sanitize_timestamp(timestamp) + MAX_TIMESTAMP_VALUE > timestamp ? timestamp : MAX_TIMESTAMP_VALUE.dup + end + # pool_size - The size of the DB pool. # host - An optional host name to use instead of the default one. def self.create_connection_pool(pool_size, host = nil) diff --git a/lib/gitlab/git/wiki.rb b/lib/gitlab/git/wiki.rb index f01d5c96fc8..9b387a19388 100644 --- a/lib/gitlab/git/wiki.rb +++ b/lib/gitlab/git/wiki.rb @@ -35,10 +35,14 @@ module Gitlab end def delete_page(page_path, commit_details) - assert_type!(commit_details, CommitDetails) - - gollum_wiki.delete_page(gollum_page_by_path(page_path), commit_details.to_h) - nil + @repository.gitaly_migrate(:wiki_delete_page) do |is_enabled| + if is_enabled + gitaly_delete_page(page_path, commit_details) + gollum_wiki.clear_cache + else + gollum_delete_page(page_path, commit_details) + end + end end def update_page(page_path, title, format, content, commit_details) @@ -135,9 +139,20 @@ module Gitlab raise Gitlab::Git::Wiki::DuplicatePageError, e.message end + def gollum_delete_page(page_path, commit_details) + assert_type!(commit_details, CommitDetails) + + gollum_wiki.delete_page(gollum_page_by_path(page_path), commit_details.to_h) + nil + end + def gitaly_write_page(name, format, content, commit_details) gitaly_wiki_client.write_page(name, format, content, commit_details) end + + def gitaly_delete_page(page_path, commit_details) + gitaly_wiki_client.delete_page(page_path, commit_details) + end end end end diff --git a/lib/gitlab/gitaly_client/wiki_service.rb b/lib/gitlab/gitaly_client/wiki_service.rb index 03afcce81f0..b7407dc1cc1 100644 --- a/lib/gitlab/gitaly_client/wiki_service.rb +++ b/lib/gitlab/gitaly_client/wiki_service.rb @@ -15,11 +15,7 @@ module Gitlab repository: @gitaly_repo, name: GitalyClient.encode(name), format: format.to_s, - commit_details: Gitaly::WikiCommitDetails.new( - name: GitalyClient.encode(commit_details.name), - email: GitalyClient.encode(commit_details.email), - message: GitalyClient.encode(commit_details.message) - ) + commit_details: gitaly_commit_details(commit_details) ) strio = StringIO.new(content) @@ -40,6 +36,26 @@ module Gitlab raise Gitlab::Git::Wiki::DuplicatePageError, error end end + + def delete_page(page_path, commit_details) + request = Gitaly::WikiDeletePageRequest.new( + repository: @gitaly_repo, + page_path: GitalyClient.encode(page_path), + commit_details: gitaly_commit_details(commit_details) + ) + + GitalyClient.call(@repository.storage, :wiki_service, :wiki_delete_page, request) + end + + private + + def gitaly_commit_details(commit_details) + Gitaly::WikiCommitDetails.new( + name: GitalyClient.encode(commit_details.name), + email: GitalyClient.encode(commit_details.email), + message: GitalyClient.encode(commit_details.message) + ) + end end end end diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index dec8b4c5acd..e68761066d8 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -19,6 +19,7 @@ project_tree: - milestone: - events: - :push_event_payload + - :issue_assignees - snippets: - :award_emoji - notes: diff --git a/lib/system_check/app/ruby_version_check.rb b/lib/system_check/app/ruby_version_check.rb index 08a2c495bd4..57bbabece1f 100644 --- a/lib/system_check/app/ruby_version_check.rb +++ b/lib/system_check/app/ruby_version_check.rb @@ -5,7 +5,7 @@ module SystemCheck set_check_pass -> { "yes (#{self.current_version})" } def self.required_version - @required_version ||= Gitlab::VersionInfo.new(2, 3, 3) + @required_version ||= Gitlab::VersionInfo.new(2, 3, 5) end def self.current_version diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index b7cccddefdd..52ef8c6a589 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -83,15 +83,15 @@ describe Projects::MergeRequestsController do end describe 'as json' do - context 'with basic param' do + context 'with basic serializer param' do it 'renders basic MR entity as json' do - go(basic: true, format: :json) + go(serializer: 'basic', format: :json) expect(response).to match_response_schema('entities/merge_request_basic') end end - context 'without basic param' do + context 'without basic serializer param' do it 'renders the merge request in the json format' do go(format: :json) diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb index 573530d0db0..209979e642d 100644 --- a/spec/controllers/projects/milestones_controller_spec.rb +++ b/spec/controllers/projects/milestones_controller_spec.rb @@ -86,4 +86,32 @@ describe Projects::MilestonesController do expect(last_note).to eq('removed milestone') end end + + describe '#promote' do + context 'promotion succeeds' do + before do + group = create(:group) + group.add_developer(user) + milestone.project.update(namespace: group) + end + + it 'shows group milestone' do + post :promote, namespace_id: project.namespace.id, project_id: project.id, id: milestone.iid + + group_milestone = assigns(:milestone) + + expect(response).to redirect_to(group_milestone_path(project.group, group_milestone.iid)) + expect(flash[:notice]).to eq('Milestone has been promoted to group milestone.') + end + end + + context 'promotion fails' do + it 'shows project milestone' do + post :promote, namespace_id: project.namespace.id, project_id: project.id, id: milestone.iid + + expect(response).to redirect_to(project_milestone_path(project, milestone)) + expect(flash[:alert]).to eq('Promotion failed - Project does not belong to a group.') + end + end + end end diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index 60ed17c0c81..ebe6939df4c 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -538,7 +538,7 @@ describe 'Issue Boards', :js do end it 'does not show create new list' do - expect(page).not_to have_selector('.js-new-board-list') + expect(page).not_to have_button('.js-new-board-list') end it 'does not allow dragging' do diff --git a/spec/features/projects/merge_requests/user_manages_subscription_spec.rb b/spec/features/projects/merge_requests/user_manages_subscription_spec.rb index 30a80f8e652..4ca435491cb 100644 --- a/spec/features/projects/merge_requests/user_manages_subscription_spec.rb +++ b/spec/features/projects/merge_requests/user_manages_subscription_spec.rb @@ -13,7 +13,7 @@ describe 'User manages subscription', :js do end it 'toggles subscription' do - subscribe_button = find('.issuable-subscribe-button span') + subscribe_button = find('.js-issuable-subscribe-button') expect(subscribe_button).to have_content('Subscribe') diff --git a/spec/features/projects/services/user_activates_packagist_spec.rb b/spec/features/projects/services/user_activates_packagist_spec.rb new file mode 100644 index 00000000000..b0cc818f093 --- /dev/null +++ b/spec/features/projects/services/user_activates_packagist_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe 'User activates Packagist' do + let(:project) { create(:project) } + let(:user) { create(:user) } + + before do + project.add_master(user) + sign_in(user) + + visit(project_settings_integrations_path(project)) + + click_link('Packagist') + end + + it 'activates service' do + check('Active') + fill_in('Username', with: 'theUser') + fill_in('Token', with: 'verySecret') + click_button('Save') + + expect(page).to have_content('Packagist activated.') + end +end diff --git a/spec/features/projects/services/user_views_services_spec.rb b/spec/features/projects/services/user_views_services_spec.rb index f86591c2633..5c5e8b66642 100644 --- a/spec/features/projects/services/user_views_services_spec.rb +++ b/spec/features/projects/services/user_views_services_spec.rb @@ -21,5 +21,6 @@ describe 'User views services' do expect(page).to have_content('JetBrains TeamCity') expect(page).to have_content('Asana') expect(page).to have_content('Irker (IRC gateway)') + expect(page).to have_content('Packagist') end end diff --git a/spec/fixtures/api/schemas/entities/issue.json b/spec/fixtures/api/schemas/entities/issue.json new file mode 100644 index 00000000000..3d3329a3406 --- /dev/null +++ b/spec/fixtures/api/schemas/entities/issue.json @@ -0,0 +1,44 @@ +{ + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "author_id": { "type": "integer" }, + "description": { "type": ["string", "null"] }, + "lock_version": { "type": ["string", "null"] }, + "milestone_id": { "type": ["string", "null"] }, + "title": { "type": "string" }, + "moved_to_id": { "type": ["integer", "null"] }, + "project_id": { "type": "integer" }, + "web_url": { "type": "string" }, + "state": { "type": "string" }, + "create_note_path": { "type": "string" }, + "preview_note_path": { "type": "string" }, + "current_user": { + "type": "object", + "properties": { + "can_create_note": { "type": "boolean" }, + "can_update": { "type": "boolean" } + } + }, + "created_at": { "type": "date-time" }, + "updated_at": { "type": "date-time" }, + "branch_name": { "type": ["string", "null"] }, + "due_date": { "type": "date" }, + "confidential": { "type": "boolean" }, + "discussion_locked": { "type": ["boolean", "null"] }, + "updated_by_id": { "type": ["string", "null"] }, + "deleted_at": { "type": ["string", "null"] }, + "time_estimate": { "type": "integer" }, + "total_time_spent": { "type": "integer" }, + "human_time_estimate": { "type": ["integer", "null"] }, + "human_total_time_spent": { "type": ["integer", "null"] }, + "milestone": { "type": ["object", "null"] }, + "labels": { + "type": "array", + "items": { "$ref": "label.json" } + }, + "assignees": { "type": ["array", "null"] } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/entities/issue_sidebar.json b/spec/fixtures/api/schemas/entities/issue_sidebar.json new file mode 100644 index 00000000000..682e345d5f5 --- /dev/null +++ b/spec/fixtures/api/schemas/entities/issue_sidebar.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "subscribed": { "type": "boolean" }, + "time_estimate": { "type": "integer" }, + "total_time_spent": { "type": "integer" }, + "human_time_estimate": { "type": ["integer", "null"] }, + "human_total_time_spent": { "type": ["integer", "null"] }, + "participants": { + "type": "array", + "items": { "$ref": "../public_api/v4/user/basic.json" } + }, + "assignees": { + "type": "array", + "items": { "$ref": "../public_api/v4/user/basic.json" } + } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/entities/label.json b/spec/fixtures/api/schemas/entities/label.json new file mode 100644 index 00000000000..40dff764c17 --- /dev/null +++ b/spec/fixtures/api/schemas/entities/label.json @@ -0,0 +1,26 @@ +{ + "type": "object", + "required": [ + "id", + "color", + "description", + "title", + "priority" + ], + "properties": { + "id": { "type": "integer" }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{3}{1,2}$" + }, + "description": { "type": ["string", "null"] }, + "text_color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{3}{1,2}$" + }, + "type": { "type": "string" }, + "title": { "type": "string" }, + "priority": { "type": ["integer", "null"] } + }, + "additionalProperties": false +}
\ No newline at end of file diff --git a/spec/fixtures/api/schemas/entities/merge_request_basic.json b/spec/fixtures/api/schemas/entities/merge_request_basic.json index 6b14188582a..995f13381ad 100644 --- a/spec/fixtures/api/schemas/entities/merge_request_basic.json +++ b/spec/fixtures/api/schemas/entities/merge_request_basic.json @@ -9,7 +9,9 @@ "human_time_estimate": { "type": ["string", "null"] }, "human_total_time_spent": { "type": ["string", "null"] }, "merge_error": { "type": ["string", "null"] }, - "assignee_id": { "type": ["integer", "null"] } + "assignee_id": { "type": ["integer", "null"] }, + "subscribed": { "type": ["boolean", "null"] }, + "participants": { "type": "array" } }, "additionalProperties": false } diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json index e1f62508933..a55ecaa5697 100644 --- a/spec/fixtures/api/schemas/issue.json +++ b/spec/fixtures/api/schemas/issue.json @@ -19,32 +19,7 @@ }, "labels": { "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "color", - "description", - "title", - "priority" - ], - "properties": { - "id": { "type": "integer" }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$" - }, - "description": { "type": ["string", "null"] }, - "text_color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$" - }, - "type": { "type": "string" }, - "title": { "type": "string" }, - "priority": { "type": ["integer", "null"] } - }, - "additionalProperties": false - } + "items": { "$ref": "entities/label.json" } }, "assignee": { "id": { "type": "integet" }, diff --git a/spec/javascripts/header_spec.js b/spec/javascripts/header_spec.js index 4751eb868a4..2443ffd48f3 100644 --- a/spec/javascripts/header_spec.js +++ b/spec/javascripts/header_spec.js @@ -1,4 +1,4 @@ -import '~/header'; +import initTodoToggle from '~/header'; describe('Header', function () { const todosPendingCount = '.todos-count'; @@ -14,6 +14,7 @@ describe('Header', function () { preloadFixtures(fixtureTemplate); beforeEach(() => { + initTodoToggle(); loadFixtures(fixtureTemplate); }); diff --git a/spec/javascripts/issuable_context_spec.js b/spec/javascripts/issuable_context_spec.js deleted file mode 100644 index f266209027a..00000000000 --- a/spec/javascripts/issuable_context_spec.js +++ /dev/null @@ -1,33 +0,0 @@ -import $ from 'jquery'; -import IssuableContext from '~/issuable_context'; - -describe('IssuableContext', () => { - describe('toggleHiddenParticipants', () => { - const event = jasmine.createSpyObj('event', ['preventDefault']); - - beforeEach(() => { - spyOn($.fn, 'data').and.returnValue('data'); - spyOn($.fn, 'text').and.returnValue('data'); - }); - - afterEach(() => { - gl.lazyLoader = undefined; - }); - - it('calls loadCheck if lazyLoader is set', () => { - gl.lazyLoader = jasmine.createSpyObj('lazyLoader', ['loadCheck']); - - IssuableContext.prototype.toggleHiddenParticipants(event); - - expect(gl.lazyLoader.loadCheck).toHaveBeenCalled(); - }); - - it('does not throw if lazyLoader is not defined', () => { - gl.lazyLoader = undefined; - - const toggle = IssuableContext.prototype.toggleHiddenParticipants.bind(null, event); - - expect(toggle).not.toThrow(); - }); - }); -}); diff --git a/spec/javascripts/sidebar/mock_data.js b/spec/javascripts/sidebar/mock_data.js index e2b6bcabc98..0682b463043 100644 --- a/spec/javascripts/sidebar/mock_data.js +++ b/spec/javascripts/sidebar/mock_data.js @@ -109,12 +109,14 @@ const sidebarMockData = { labels: [], web_url: '/root/some-project/issues/5', }, + '/gitlab-org/gitlab-shell/issues/5/toggle_subscription': {}, }, }; export default { mediator: { endpoint: '/gitlab-org/gitlab-shell/issues/5.json', + toggleSubscriptionEndpoint: '/gitlab-org/gitlab-shell/issues/5/toggle_subscription', moveIssueEndpoint: '/gitlab-org/gitlab-shell/issues/5/move', projectsAutocompleteEndpoint: '/autocomplete/projects?project_id=15', editable: true, diff --git a/spec/javascripts/sidebar/participants_spec.js b/spec/javascripts/sidebar/participants_spec.js new file mode 100644 index 00000000000..30cc549c7c0 --- /dev/null +++ b/spec/javascripts/sidebar/participants_spec.js @@ -0,0 +1,174 @@ +import Vue from 'vue'; +import participants from '~/sidebar/components/participants/participants.vue'; +import mountComponent from '../helpers/vue_mount_component_helper'; + +const PARTICIPANT = { + id: 1, + state: 'active', + username: 'marcene', + name: 'Allie Will', + web_url: 'foo.com', + avatar_url: 'gravatar.com/avatar/xxx', +}; + +const PARTICIPANT_LIST = [ + PARTICIPANT, + { ...PARTICIPANT, id: 2 }, + { ...PARTICIPANT, id: 3 }, +]; + +describe('Participants', function () { + let vm; + let Participants; + + beforeEach(() => { + Participants = Vue.extend(participants); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('collapsed sidebar state', () => { + it('shows loading spinner when loading', () => { + vm = mountComponent(Participants, { + loading: true, + }); + + expect(vm.$el.querySelector('.js-participants-collapsed-loading-icon')).toBeDefined(); + }); + + it('shows participant count when given', () => { + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + }); + const countEl = vm.$el.querySelector('.js-participants-collapsed-count'); + + expect(countEl.textContent.trim()).toBe(`${PARTICIPANT_LIST.length}`); + }); + + it('shows full participant count when there are hidden participants', () => { + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 1, + }); + const countEl = vm.$el.querySelector('.js-participants-collapsed-count'); + + expect(countEl.textContent.trim()).toBe(`${PARTICIPANT_LIST.length}`); + }); + }); + + describe('expanded sidebar state', () => { + it('shows loading spinner when loading', () => { + vm = mountComponent(Participants, { + loading: true, + }); + + expect(vm.$el.querySelector('.js-participants-expanded-loading-icon')).toBeDefined(); + }); + + it('when only showing visible participants, shows an avatar only for each participant under the limit', (done) => { + const numberOfLessParticipants = 2; + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants, + }); + vm.isShowingMoreParticipants = false; + + Vue.nextTick() + .then(() => { + const participantEls = vm.$el.querySelectorAll('.js-participants-author'); + + expect(participantEls.length).toBe(numberOfLessParticipants); + }) + .then(done) + .catch(done.fail); + }); + + it('when only showing all participants, each has an avatar', (done) => { + const numberOfLessParticipants = 2; + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants, + }); + vm.isShowingMoreParticipants = true; + + Vue.nextTick() + .then(() => { + const participantEls = vm.$el.querySelectorAll('.js-participants-author'); + + expect(participantEls.length).toBe(PARTICIPANT_LIST.length); + }) + .then(done) + .catch(done.fail); + }); + + it('does not have more participants link when they can all be shown', () => { + const numberOfLessParticipants = 100; + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants, + }); + const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button'); + + expect(PARTICIPANT_LIST.length).toBeLessThan(numberOfLessParticipants); + expect(moreParticipantLink).toBeNull(); + }); + + it('when too many participants, has more participants link to show more', (done) => { + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + vm.isShowingMoreParticipants = false; + + Vue.nextTick() + .then(() => { + const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button'); + + expect(moreParticipantLink.textContent.trim()).toBe('+ 1 more'); + }) + .then(done) + .catch(done.fail); + }); + + it('when too many participants and already showing them, has more participants link to show less', (done) => { + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + vm.isShowingMoreParticipants = true; + + Vue.nextTick() + .then(() => { + const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button'); + + expect(moreParticipantLink.textContent.trim()).toBe('- show less'); + }) + .then(done) + .catch(done.fail); + }); + + it('clicking more participants link emits event', () => { + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button'); + + expect(vm.isShowingMoreParticipants).toBe(false); + + moreParticipantLink.click(); + + expect(vm.isShowingMoreParticipants).toBe(true); + }); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_mediator_spec.js b/spec/javascripts/sidebar/sidebar_mediator_spec.js index 3aa8ca5db0d..7deb1fd2118 100644 --- a/spec/javascripts/sidebar/sidebar_mediator_spec.js +++ b/spec/javascripts/sidebar/sidebar_mediator_spec.js @@ -57,8 +57,8 @@ describe('Sidebar mediator', () => { .then(() => { expect(this.mediator.service.getProjectsAutocomplete).toHaveBeenCalledWith(searchTerm); expect(this.mediator.store.setAutocompleteProjects).toHaveBeenCalled(); - done(); }) + .then(done) .catch(done.fail); }); @@ -72,8 +72,21 @@ describe('Sidebar mediator', () => { .then(() => { expect(this.mediator.service.moveIssue).toHaveBeenCalledWith(moveToProjectId); expect(gl.utils.visitUrl).toHaveBeenCalledWith('/root/some-project/issues/5'); - done(); }) + .then(done) + .catch(done.fail); + }); + + it('toggle subscription', (done) => { + this.mediator.store.setSubscribedState(false); + spyOn(this.mediator.service, 'toggleSubscription').and.callThrough(); + + this.mediator.toggleSubscription() + .then(() => { + expect(this.mediator.service.toggleSubscription).toHaveBeenCalled(); + expect(this.mediator.store.subscribed).toEqual(true); + }) + .then(done) .catch(done.fail); }); }); diff --git a/spec/javascripts/sidebar/sidebar_service_spec.js b/spec/javascripts/sidebar/sidebar_service_spec.js index a4bd8ba8d88..7324d34d84a 100644 --- a/spec/javascripts/sidebar/sidebar_service_spec.js +++ b/spec/javascripts/sidebar/sidebar_service_spec.js @@ -7,6 +7,7 @@ describe('Sidebar service', () => { Vue.http.interceptors.push(Mock.sidebarMockInterceptor); this.service = new SidebarService({ endpoint: '/gitlab-org/gitlab-shell/issues/5.json', + toggleSubscriptionEndpoint: '/gitlab-org/gitlab-shell/issues/5/toggle_subscription', moveIssueEndpoint: '/gitlab-org/gitlab-shell/issues/5/move', projectsAutocompleteEndpoint: '/autocomplete/projects?project_id=15', }); @@ -23,6 +24,7 @@ describe('Sidebar service', () => { expect(resp).toBeDefined(); done(); }) + .then(done) .catch(done.fail); }); @@ -30,8 +32,8 @@ describe('Sidebar service', () => { this.service.update('issue[assignee_ids]', [1]) .then((resp) => { expect(resp).toBeDefined(); - done(); }) + .then(done) .catch(done.fail); }); @@ -39,8 +41,8 @@ describe('Sidebar service', () => { this.service.getProjectsAutocomplete() .then((resp) => { expect(resp).toBeDefined(); - done(); }) + .then(done) .catch(done.fail); }); @@ -48,8 +50,17 @@ describe('Sidebar service', () => { this.service.moveIssue(123) .then((resp) => { expect(resp).toBeDefined(); - done(); }) + .then(done) + .catch(done.fail); + }); + + it('toggles the subscription', (done) => { + this.service.toggleSubscription() + .then((resp) => { + expect(resp).toBeDefined(); + }) + .then(done) .catch(done.fail); }); }); diff --git a/spec/javascripts/sidebar/sidebar_store_spec.js b/spec/javascripts/sidebar/sidebar_store_spec.js index 69eb3839d67..51dee64fb93 100644 --- a/spec/javascripts/sidebar/sidebar_store_spec.js +++ b/spec/javascripts/sidebar/sidebar_store_spec.js @@ -2,21 +2,36 @@ import SidebarStore from '~/sidebar/stores/sidebar_store'; import Mock from './mock_data'; import UsersMockHelper from '../helpers/user_mock_data_helper'; -describe('Sidebar store', () => { - const assignee = { - id: 2, - name: 'gitlab user 2', - username: 'gitlab2', - avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - }; - - const anotherAssignee = { - id: 3, - name: 'gitlab user 3', - username: 'gitlab3', - avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - }; +const ASSIGNEE = { + id: 2, + name: 'gitlab user 2', + username: 'gitlab2', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', +}; + +const ANOTHER_ASSINEE = { + id: 3, + name: 'gitlab user 3', + username: 'gitlab3', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', +}; + +const PARTICIPANT = { + id: 1, + state: 'active', + username: 'marcene', + name: 'Allie Will', + web_url: 'foo.com', + avatar_url: 'gravatar.com/avatar/xxx', +}; + +const PARTICIPANT_LIST = [ + PARTICIPANT, + { ...PARTICIPANT, id: 2 }, + { ...PARTICIPANT, id: 3 }, +]; +describe('Sidebar store', () => { beforeEach(() => { this.store = new SidebarStore({ currentUser: { @@ -40,23 +55,23 @@ describe('Sidebar store', () => { }); it('adds a new assignee', () => { - this.store.addAssignee(assignee); + this.store.addAssignee(ASSIGNEE); expect(this.store.assignees.length).toEqual(1); }); it('removes an assignee', () => { - this.store.removeAssignee(assignee); + this.store.removeAssignee(ASSIGNEE); expect(this.store.assignees.length).toEqual(0); }); it('finds an existent assignee', () => { let foundAssignee; - this.store.addAssignee(assignee); - foundAssignee = this.store.findAssignee(assignee); + this.store.addAssignee(ASSIGNEE); + foundAssignee = this.store.findAssignee(ASSIGNEE); expect(foundAssignee).toBeDefined(); - expect(foundAssignee).toEqual(assignee); - foundAssignee = this.store.findAssignee(anotherAssignee); + expect(foundAssignee).toEqual(ASSIGNEE); + foundAssignee = this.store.findAssignee(ANOTHER_ASSINEE); expect(foundAssignee).toBeUndefined(); }); @@ -65,6 +80,28 @@ describe('Sidebar store', () => { expect(this.store.assignees.length).toEqual(0); }); + it('sets participants data', () => { + expect(this.store.participants.length).toEqual(0); + + this.store.setParticipantsData({ + participants: PARTICIPANT_LIST, + }); + + expect(this.store.isFetching.participants).toEqual(false); + expect(this.store.participants.length).toEqual(PARTICIPANT_LIST.length); + }); + + it('sets subcriptions data', () => { + expect(this.store.subscribed).toEqual(null); + + this.store.setSubscriptionsData({ + subscribed: true, + }); + + expect(this.store.isFetching.subscriptions).toEqual(false); + expect(this.store.subscribed).toEqual(true); + }); + it('set assigned data', () => { const users = { assignees: UsersMockHelper.createNumberRandomUsers(3), @@ -75,6 +112,14 @@ describe('Sidebar store', () => { expect(this.store.assignees.length).toEqual(3); }); + it('sets fetching state', () => { + expect(this.store.isFetching.participants).toEqual(true); + + this.store.setFetchingState('participants', false); + + expect(this.store.isFetching.participants).toEqual(false); + }); + it('set time tracking data', () => { this.store.setTimeTrackingData(Mock.time); expect(this.store.timeEstimate).toEqual(Mock.time.time_estimate); @@ -90,6 +135,14 @@ describe('Sidebar store', () => { expect(this.store.autocompleteProjects).toEqual(projects); }); + it('sets subscribed state', () => { + expect(this.store.subscribed).toEqual(null); + + this.store.setSubscribedState(true); + + expect(this.store.subscribed).toEqual(true); + }); + it('set move to project ID', () => { const projectId = 7; this.store.setMoveToProjectId(projectId); diff --git a/spec/javascripts/sidebar/sidebar_subscriptions_spec.js b/spec/javascripts/sidebar/sidebar_subscriptions_spec.js new file mode 100644 index 00000000000..7adf22b0f1f --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_subscriptions_spec.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; +import sidebarSubscriptions from '~/sidebar/components/subscriptions/sidebar_subscriptions.vue'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import eventHub from '~/sidebar/event_hub'; +import mountComponent from '../helpers/vue_mount_component_helper'; +import Mock from './mock_data'; + +describe('Sidebar Subscriptions', function () { + let vm; + let SidebarSubscriptions; + + beforeEach(() => { + SidebarSubscriptions = Vue.extend(sidebarSubscriptions); + // Setup the stores, services, etc + // eslint-disable-next-line no-new + new SidebarMediator(Mock.mediator); + }); + + afterEach(() => { + vm.$destroy(); + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + }); + + it('calls the mediator toggleSubscription on event', () => { + spyOn(SidebarMediator.prototype, 'toggleSubscription').and.returnValue(Promise.resolve()); + vm = mountComponent(SidebarSubscriptions, {}); + + eventHub.$emit('toggleSubscription'); + + expect(SidebarMediator.prototype.toggleSubscription).toHaveBeenCalled(); + }); +}); diff --git a/spec/javascripts/sidebar/subscriptions_spec.js b/spec/javascripts/sidebar/subscriptions_spec.js new file mode 100644 index 00000000000..9b33dd02fb9 --- /dev/null +++ b/spec/javascripts/sidebar/subscriptions_spec.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue'; +import mountComponent from '../helpers/vue_mount_component_helper'; + +describe('Subscriptions', function () { + let vm; + let Subscriptions; + + beforeEach(() => { + Subscriptions = Vue.extend(subscriptions); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('shows loading spinner when loading', () => { + vm = mountComponent(Subscriptions, { + loading: true, + subscribed: undefined, + }); + + expect(vm.$refs.loadingButton.loading).toBe(true); + expect(vm.$refs.loadingButton.label).toBeUndefined(); + }); + + it('has "Subscribe" text when currently not subscribed', () => { + vm = mountComponent(Subscriptions, { + subscribed: false, + }); + + expect(vm.$refs.loadingButton.label).toBe('Subscribe'); + }); + + it('has "Unsubscribe" text when currently not subscribed', () => { + vm = mountComponent(Subscriptions, { + subscribed: true, + }); + + expect(vm.$refs.loadingButton.label).toBe('Unsubscribe'); + }); +}); diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb index 5fa94999d25..7aeb85b8f5a 100644 --- a/spec/lib/gitlab/database_spec.rb +++ b/spec/lib/gitlab/database_spec.rb @@ -256,4 +256,26 @@ describe Gitlab::Database do expect(described_class.false_value).to eq 0 end end + + describe '#sanitize_timestamp' do + let(:max_timestamp) { Time.at((1 << 31) - 1) } + + subject { described_class.sanitize_timestamp(timestamp) } + + context 'with a timestamp smaller than MAX_TIMESTAMP_VALUE' do + let(:timestamp) { max_timestamp - 10.years } + + it 'returns the given timestamp' do + expect(subject).to eq(timestamp) + end + end + + context 'with a timestamp larger than MAX_TIMESTAMP_VALUE' do + let(:timestamp) { max_timestamp + 1.second } + + it 'returns MAX_TIMESTAMP_VALUE' do + expect(subject).to eq(max_timestamp) + end + end + end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 29baa70d5ae..6c6b9154a0a 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -195,6 +195,7 @@ project: - mattermost_slash_commands_service - slack_slash_commands_service - irker_service +- packagist_service - pivotaltracker_service - prometheus_service - hipchat_service @@ -286,3 +287,6 @@ timelogs: - user push_event_payload: - event +issue_assignees: +- issue +- assignee
\ No newline at end of file diff --git a/spec/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json index 1115fb218d6..9a68bbb379c 100644 --- a/spec/lib/gitlab/import_export/project.json +++ b/spec/lib/gitlab/import_export/project.json @@ -43,7 +43,7 @@ "issues": [ { "id": 40, - "title": "Voluptatem amet doloribus deleniti eos maxime repudiandae molestias.", + "title": "Voluptatem", "assignee_id": 1, "author_id": 22, "project_id": 5, @@ -60,6 +60,12 @@ "due_date": null, "moved_to_id": null, "test_ee_field": "test", + "issue_assignees": [ + { + "user_id": 1, + "issue_id": 1 + } + ], "milestone": { "id": 1, "title": "test milestone", diff --git a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb index 4301eee17dc..76b01b6a1ec 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -63,6 +63,10 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do expect(issue.reload.updated_at.to_s).to eq('2016-06-14 15:02:47 UTC') end + it 'has issue assignees' do + expect(Issue.where(title: 'Voluptatem').first.issue_assignees).not_to be_empty + end + it 'contains the merge access levels on a protected branch' do expect(ProtectedBranch.first.merge_access_levels).not_to be_empty end diff --git a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb index d9b86e1bf34..8da768ebd07 100644 --- a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb @@ -77,6 +77,10 @@ describe Gitlab::ImportExport::ProjectTreeSaver do expect(saved_project_json['issues'].first['notes']).not_to be_empty end + it 'has issue assignees' do + expect(saved_project_json['issues'].first['issue_assignees']).not_to be_empty + end + it 'has author on issue comments' do expect(saved_project_json['issues'].first['notes'].first['author']).not_to be_empty end diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 121c0ed04ed..89d30407077 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -506,3 +506,6 @@ ProjectAutoDevops: - project_id - created_at - updated_at +IssueAssignee: +- user_id +- issue_id
\ No newline at end of file diff --git a/spec/models/concerns/subscribable_spec.rb b/spec/models/concerns/subscribable_spec.rb index 28ff8158e0e..45dfb136aea 100644 --- a/spec/models/concerns/subscribable_spec.rb +++ b/spec/models/concerns/subscribable_spec.rb @@ -6,6 +6,12 @@ describe Subscribable, 'Subscribable' do let(:user_1) { create(:user) } describe '#subscribed?' do + context 'without user' do + it 'returns false' do + expect(resource.subscribed?(nil, project)).to be_falsey + end + end + context 'without project' do it 'returns false when no subscription exists' do expect(resource.subscribed?(user_1)).to be_falsey diff --git a/spec/models/merge_request_diff_commit_spec.rb b/spec/models/merge_request_diff_commit_spec.rb index 9d4a0ecf8c0..7709cf43200 100644 --- a/spec/models/merge_request_diff_commit_spec.rb +++ b/spec/models/merge_request_diff_commit_spec.rb @@ -2,14 +2,93 @@ require 'rails_helper' describe MergeRequestDiffCommit do let(:merge_request) { create(:merge_request) } - subject { merge_request.commits.first } + let(:project) { merge_request.project } describe '#to_hash' do + subject { merge_request.commits.first } + it 'returns the same results as Commit#to_hash, except for parent_ids' do - commit_from_repo = merge_request.project.repository.commit(subject.sha) + commit_from_repo = project.repository.commit(subject.sha) commit_from_repo_hash = commit_from_repo.to_hash.merge(parent_ids: []) expect(subject.to_hash).to eq(commit_from_repo_hash) end end + + describe '.create_bulk' do + let(:sha_attribute) { Gitlab::Database::ShaAttribute.new } + let(:merge_request_diff_id) { merge_request.merge_request_diff.id } + let(:commits) do + [ + project.commit('5937ac0a7beb003549fc5fd26fc247adbce4a52e'), + project.commit('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') + ] + end + let(:rows) do + [ + { + "message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "authored_date": "2014-02-27T10:01:38.000+01:00".to_time, + "author_name": "Dmitriy Zaporozhets", + "author_email": "dmitriy.zaporozhets@gmail.com", + "committed_date": "2014-02-27T10:01:38.000+01:00".to_time, + "committer_name": "Dmitriy Zaporozhets", + "committer_email": "dmitriy.zaporozhets@gmail.com", + "merge_request_diff_id": merge_request_diff_id, + "relative_order": 0, + "sha": sha_attribute.type_cast_for_database('5937ac0a7beb003549fc5fd26fc247adbce4a52e') + }, + { + "message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "authored_date": "2014-02-27T09:57:31.000+01:00".to_time, + "author_name": "Dmitriy Zaporozhets", + "author_email": "dmitriy.zaporozhets@gmail.com", + "committed_date": "2014-02-27T09:57:31.000+01:00".to_time, + "committer_name": "Dmitriy Zaporozhets", + "committer_email": "dmitriy.zaporozhets@gmail.com", + "merge_request_diff_id": merge_request_diff_id, + "relative_order": 1, + "sha": sha_attribute.type_cast_for_database('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') + } + ] + end + + subject { described_class.create_bulk(merge_request_diff_id, commits) } + + it 'inserts the commits into the database en masse' do + expect(Gitlab::Database).to receive(:bulk_insert) + .with(described_class.table_name, rows) + + subject + end + + context 'with dates larger than the DB limit' do + let(:commits) do + # This commit's date is "Sun Aug 17 07:12:55 292278994 +0000" + [project.commit('ba3343bc4fa403a8dfbfcab7fc1a8c29ee34bd69')] + end + let(:timestamp) { Time.at((1 << 31) - 1) } + let(:rows) do + [{ + "message": "Weird commit date\n", + "authored_date": timestamp, + "author_name": "Alejandro RodrÃguez", + "author_email": "alejorro70@gmail.com", + "committed_date": timestamp, + "committer_name": "Alejandro RodrÃguez", + "committer_email": "alejorro70@gmail.com", + "merge_request_diff_id": merge_request_diff_id, + "relative_order": 0, + "sha": sha_attribute.type_cast_for_database('ba3343bc4fa403a8dfbfcab7fc1a8c29ee34bd69') + }] + end + + it 'uses a sanitized date' do + expect(Gitlab::Database).to receive(:bulk_insert) + .with(described_class.table_name, rows) + + subject + end + end + end end diff --git a/spec/models/project_services/packagist_service_spec.rb b/spec/models/project_services/packagist_service_spec.rb new file mode 100644 index 00000000000..6acee311700 --- /dev/null +++ b/spec/models/project_services/packagist_service_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe PackagistService do + describe "Associations" do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + let(:project) { create(:project) } + + let(:packagist_server) { 'https://packagist.example.com' } + let(:packagist_username) { 'theUser' } + let(:packagist_token) { 'verySecret' } + let(:packagist_hook_url) do + "#{packagist_server}/api/update-package?username=#{packagist_username}&apiToken=#{packagist_token}" + end + + let(:packagist_params) do + { + active: true, + project: project, + properties: { + username: packagist_username, + token: packagist_token, + server: packagist_server + } + } + end + + describe '#execute' do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + let(:push_sample_data) { Gitlab::DataBuilder::Push.build_sample(project, user) } + let(:packagist_service) { described_class.create(packagist_params) } + + before do + stub_request(:post, packagist_hook_url) + end + + it 'calls Packagist API' do + packagist_service.execute(push_sample_data) + + expect(a_request(:post, packagist_hook_url)).to have_been_made.once + end + end +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 74eba7e33f6..ed6e42d476e 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -24,6 +24,7 @@ describe Project do it { is_expected.to have_one(:slack_service) } it { is_expected.to have_one(:microsoft_teams_service) } it { is_expected.to have_one(:mattermost_service) } + it { is_expected.to have_one(:packagist_service) } it { is_expected.to have_one(:pushover_service) } it { is_expected.to have_one(:asana_service) } it { is_expected.to have_many(:boards) } @@ -2452,6 +2453,7 @@ describe Project do context 'legacy storage' do let(:project) { create(:project, :repository) } let(:gitlab_shell) { Gitlab::Shell.new } + let(:project_storage) { project.send(:storage) } before do allow(project).to receive(:gitlab_shell).and_return(gitlab_shell) @@ -2493,7 +2495,7 @@ describe Project do describe '#hashed_storage?' do it 'returns false' do - expect(project.hashed_storage?).to be_falsey + expect(project.hashed_storage?(:repository)).to be_falsey end end @@ -2546,6 +2548,30 @@ describe Project do it { expect { subject }.to raise_error(StandardError) } end + + context 'gitlab pages' do + before do + expect(project_storage).to receive(:rename_repo) { true } + end + + it 'moves pages folder to new location' do + expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project) + + project.rename_repo + end + end + + context 'attachments' do + before do + expect(project_storage).to receive(:rename_repo) { true } + end + + it 'moves uploads folder to new location' do + expect_any_instance_of(Gitlab::UploadsTransfer).to receive(:rename_project) + + project.rename_repo + end + end end describe '#pages_path' do @@ -2605,8 +2631,14 @@ describe Project do end describe '#hashed_storage?' do - it 'returns true' do - expect(project.hashed_storage?).to be_truthy + it 'returns true if rolled out' do + expect(project.hashed_storage?(:attachments)).to be_truthy + end + + it 'returns false when not rolled out yet' do + project.storage_version = 1 + + expect(project.hashed_storage?(:attachments)).to be_falsey end end @@ -2649,10 +2681,6 @@ describe Project do .to receive(:execute_hooks_for) .with(project, :rename) - expect_any_instance_of(Gitlab::UploadsTransfer) - .to receive(:rename_project) - .with('foo', project.path, project.namespace.full_path) - expect(project).to receive(:expire_caches_before_rename) expect(project).to receive(:expires_full_path_cache) @@ -2673,6 +2701,32 @@ describe Project do it { expect { subject }.to raise_error(StandardError) } end + + context 'gitlab pages' do + it 'moves pages folder to new location' do + expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project) + + project.rename_repo + end + end + + context 'attachments' do + it 'keeps uploads folder location unchanged' do + expect_any_instance_of(Gitlab::UploadsTransfer).not_to receive(:rename_project) + + project.rename_repo + end + + context 'when not rolled out' do + let(:project) { create(:project, :repository, storage_version: 1) } + + it 'moves pages folder to new location' do + expect_any_instance_of(Gitlab::UploadsTransfer).to receive(:rename_project) + + project.rename_repo + end + end + end end describe '#pages_path' do diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb index f10d9383ae2..95d58b96f33 100644 --- a/spec/models/project_wiki_spec.rb +++ b/spec/models/project_wiki_spec.rb @@ -265,23 +265,33 @@ describe ProjectWiki do end describe "#delete_page" do - before do - create_page("index", "some content") - @page = subject.wiki.page(title: "index") - end + shared_examples 'deleting a wiki page' do + before do + create_page("index", "some content") + @page = subject.wiki.page(title: "index") + end - it "deletes the page" do - subject.delete_page(@page) - expect(subject.pages.count).to eq(0) - end + it "deletes the page" do + subject.delete_page(@page) + expect(subject.pages.count).to eq(0) + end - it 'updates project activity' do - subject.delete_page(@page) + it 'updates project activity' do + subject.delete_page(@page) - project.reload + project.reload - expect(project.last_activity_at).to be_within(1.minute).of(Time.now) - expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now) + expect(project.last_activity_at).to be_within(1.minute).of(Time.now) + expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now) + end + end + + context 'when Gitaly wiki_delete_page is enabled' do + it_behaves_like 'deleting a wiki page' + end + + context 'when Gitaly wiki_delete_page is disabled', :skip_gitaly_mock do + it_behaves_like 'deleting a wiki page' end end @@ -343,6 +353,6 @@ describe ProjectWiki do end def destroy_page(page) - subject.delete_page(page, commit_details) + subject.delete_page(page, "test commit") end end diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb index 1f14d06997e..a7227b38850 100644 --- a/spec/models/wiki_page_spec.rb +++ b/spec/models/wiki_page_spec.rb @@ -402,7 +402,7 @@ describe WikiPage do def destroy_page(title) page = wiki.wiki.page(title: title) - wiki.delete_page(page, commit_details) + wiki.delete_page(page, "test commit") end def get_slugs(page_or_dir) diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 28b1404a4f7..024cfe8b372 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -1061,6 +1061,30 @@ describe API::MergeRequests do end end + describe 'POST :id/merge_requests/:merge_request_iid/cancel_merge_when_pipeline_succeeds' do + before do + ::MergeRequests::MergeWhenPipelineSucceedsService.new(merge_request.target_project, user).execute(merge_request) + end + + it 'removes the merge_when_pipeline_succeeds status' do + post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/cancel_merge_when_pipeline_succeeds", user) + + expect(response).to have_gitlab_http_status(201) + end + + it 'returns 404 if the merge request is not found' do + post api("/projects/#{project.id}/merge_requests/123/merge_when_pipeline_succeeds", user) + + expect(response).to have_gitlab_http_status(404) + end + + it 'returns 404 if the merge request id is used instead of iid' do + post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge_when_pipeline_succeeds", user) + + expect(response).to have_gitlab_http_status(404) + end + end + describe 'Time tracking' do let(:issuable) { merge_request } diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index 39d44245c3f..fb1281a6b42 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -426,18 +426,23 @@ describe 'project routing' do end end - # project_milestones GET /:project_id/milestones(.:format) milestones#index - # POST /:project_id/milestones(.:format) milestones#create - # new_project_milestone GET /:project_id/milestones/new(.:format) milestones#new - # edit_project_milestone GET /:project_id/milestones/:id/edit(.:format) milestones#edit - # project_milestone GET /:project_id/milestones/:id(.:format) milestones#show - # PUT /:project_id/milestones/:id(.:format) milestones#update - # DELETE /:project_id/milestones/:id(.:format) milestones#destroy + # project_milestones GET /:project_id/milestones(.:format) milestones#index + # POST /:project_id/milestones(.:format) milestones#create + # new_project_milestone GET /:project_id/milestones/new(.:format) milestones#new + # edit_project_milestone GET /:project_id/milestones/:id/edit(.:format) milestones#edit + # project_milestone GET /:project_id/milestones/:id(.:format) milestones#show + # PUT /:project_id/milestones/:id(.:format) milestones#update + # DELETE /:project_id/milestones/:id(.:format) milestones#destroy + # promote_project_milestone POST /:project_id/milestones/:id/promote milestones#promote describe Projects::MilestonesController, 'routing' do it_behaves_like 'RESTful project resources' do let(:controller) { 'milestones' } let(:actions) { [:index, :create, :new, :edit, :show, :update] } end + + it 'to #promote' do + expect(post('/gitlab/gitlabhq/milestones/1/promote')).to route_to('projects/milestones#promote', namespace_id: 'gitlab', project_id: 'gitlabhq', id: "1") + end end # project_labels GET /:project_id/labels(.:format) labels#index diff --git a/spec/serializers/issue_serializer_spec.rb b/spec/serializers/issue_serializer_spec.rb new file mode 100644 index 00000000000..75578816e75 --- /dev/null +++ b/spec/serializers/issue_serializer_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe IssueSerializer do + let(:resource) { create(:issue) } + let(:user) { create(:user) } + let(:json_entity) do + described_class.new(current_user: user) + .represent(resource, serializer: serializer) + .with_indifferent_access + end + + context 'non-sidebar issue serialization' do + let(:serializer) { nil } + + it 'matches issue json schema' do + expect(json_entity).to match_schema('entities/issue') + end + end + + context 'sidebar issue serialization' do + let(:serializer) { 'sidebar' } + + it 'matches sidebar issue json schema' do + expect(json_entity).to match_schema('entities/issue_sidebar') + end + end +end diff --git a/spec/serializers/merge_request_basic_serializer_spec.rb b/spec/serializers/merge_request_basic_serializer_spec.rb index 4daf5a59d0c..1fad8e6bc5d 100644 --- a/spec/serializers/merge_request_basic_serializer_spec.rb +++ b/spec/serializers/merge_request_basic_serializer_spec.rb @@ -4,9 +4,13 @@ describe MergeRequestBasicSerializer do let(:resource) { create(:merge_request) } let(:user) { create(:user) } - subject { described_class.new.represent(resource) } + let(:json_entity) do + described_class.new(current_user: user) + .represent(resource, serializer: 'basic') + .with_indifferent_access + end - it 'has important MergeRequest attributes' do - expect(subject).to include(:merge_status) + it 'matches basic merge request json' do + expect(json_entity).to match_schema('entities/merge_request_basic') end end diff --git a/spec/serializers/merge_request_serializer_spec.rb b/spec/serializers/merge_request_serializer_spec.rb index 73fbecc153d..e3abefa6d63 100644 --- a/spec/serializers/merge_request_serializer_spec.rb +++ b/spec/serializers/merge_request_serializer_spec.rb @@ -9,11 +9,11 @@ describe MergeRequestSerializer do end describe '#represent' do - let(:opts) { { basic: basic } } - subject { serializer.represent(merge_request, basic: basic) } + let(:opts) { { serializer: serializer_entity } } + subject { serializer.represent(merge_request, serializer: serializer_entity) } - context 'when basic param is truthy' do - let(:basic) { true } + context 'when passing basic serializer param' do + let(:serializer_entity) { 'basic' } it 'calls super class #represent with correct params' do expect_any_instance_of(BaseSerializer).to receive(:represent) @@ -23,8 +23,8 @@ describe MergeRequestSerializer do end end - context 'when basic param is falsy' do - let(:basic) { false } + context 'when serializer param is falsy' do + let(:serializer_entity) { nil } it 'calls super class #represent with correct params' do expect_any_instance_of(BaseSerializer).to receive(:represent) diff --git a/spec/services/milestones/promote_service_spec.rb b/spec/services/milestones/promote_service_spec.rb new file mode 100644 index 00000000000..9f2df6d6d19 --- /dev/null +++ b/spec/services/milestones/promote_service_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +describe Milestones::PromoteService do + let(:group) { create(:group) } + let(:project) { create(:project, namespace: group) } + let(:user) { create(:user) } + let(:milestone_title) { 'project milestone' } + let(:milestone) { create(:milestone, project: project, title: milestone_title) } + let(:service) { described_class.new(project, user) } + + describe '#execute' do + before do + group.add_master(user) + end + + context 'validations' do + it 'raises error if milestone does not belong to a project' do + allow(milestone).to receive(:project_milestone?).and_return(false) + + expect { service.execute(milestone) }.to raise_error(described_class::PromoteMilestoneError) + end + + it 'raises error if project does not belong to a group' do + project.update(namespace: user.namespace) + + expect { service.execute(milestone) }.to raise_error(described_class::PromoteMilestoneError) + end + end + + context 'without duplicated milestone titles across projects' do + it 'promotes project milestone to group milestone' do + promoted_milestone = service.execute(milestone) + + expect(promoted_milestone).to be_group_milestone + end + + it 'sets issuables with new promoted milestone' do + issue = create(:issue, milestone: milestone, project: project) + merge_request = create(:merge_request, milestone: milestone, source_project: project) + + promoted_milestone = service.execute(milestone) + + expect(promoted_milestone).to be_group_milestone + expect(issue.reload.milestone).to eq(promoted_milestone) + expect(merge_request.reload.milestone).to eq(promoted_milestone) + end + end + + context 'with duplicated milestone titles across projects' do + let(:project_2) { create(:project, namespace: group) } + let!(:milestone_2) { create(:milestone, project: project_2, title: milestone_title) } + + it 'deletes project milestones with the same title' do + promoted_milestone = service.execute(milestone) + + expect(promoted_milestone).to be_group_milestone + expect(promoted_milestone).to be_valid + expect(Milestone.exists?(milestone.id)).to be_falsy + expect(Milestone.exists?(milestone_2.id)).to be_falsy + end + + it 'sets all issuables with new promoted milestone' do + issue = create(:issue, milestone: milestone, project: project) + issue_2 = create(:issue, milestone: milestone_2, project: project_2) + merge_request = create(:merge_request, milestone: milestone, source_project: project) + merge_request_2 = create(:merge_request, milestone: milestone_2, source_project: project_2) + + promoted_milestone = service.execute(milestone) + + expect(issue.reload.milestone).to eq(promoted_milestone) + expect(issue_2.reload.milestone).to eq(promoted_milestone) + expect(merge_request.reload.milestone).to eq(promoted_milestone) + expect(merge_request_2.reload.milestone).to eq(promoted_milestone) + end + end + end +end diff --git a/spec/services/projects/hashed_storage_migration_service_spec.rb b/spec/services/projects/hashed_storage_migration_service_spec.rb index aa1988d29d6..b71b47c59b6 100644 --- a/spec/services/projects/hashed_storage_migration_service_spec.rb +++ b/spec/services/projects/hashed_storage_migration_service_spec.rb @@ -23,7 +23,7 @@ describe Projects::HashedStorageMigrationService do it 'updates project to be hashed and not read-only' do service.execute - expect(project.hashed_storage?).to be_truthy + expect(project.hashed_storage?(:repository)).to be_truthy expect(project.repository_read_only).to be_falsey end diff --git a/spec/uploaders/file_uploader_spec.rb b/spec/uploaders/file_uploader_spec.rb index 2492d56a5cf..f52b2bab05b 100644 --- a/spec/uploaders/file_uploader_spec.rb +++ b/spec/uploaders/file_uploader_spec.rb @@ -3,25 +3,51 @@ require 'spec_helper' describe FileUploader do let(:uploader) { described_class.new(build_stubbed(:project)) } - describe '.absolute_path' do - it 'returns the correct absolute path by building it dynamically' do - project = build_stubbed(:project) - upload = double(model: project, path: 'secret/foo.jpg') + context 'legacy storage' do + let(:project) { build_stubbed(:project) } - dynamic_segment = project.path_with_namespace + describe '.absolute_path' do + it 'returns the correct absolute path by building it dynamically' do + upload = double(model: project, path: 'secret/foo.jpg') - expect(described_class.absolute_path(upload)) - .to end_with("#{dynamic_segment}/secret/foo.jpg") + dynamic_segment = project.full_path + + expect(described_class.absolute_path(upload)) + .to end_with("#{dynamic_segment}/secret/foo.jpg") + end + end + + describe "#store_dir" do + it "stores in the namespace path" do + uploader = described_class.new(project) + + expect(uploader.store_dir).to include(project.full_path) + expect(uploader.store_dir).not_to include("system") + end end end - describe "#store_dir" do - it "stores in the namespace path" do - project = build_stubbed(:project) - uploader = described_class.new(project) + context 'hashed storage' do + let(:project) { build_stubbed(:project, :hashed) } + + describe '.absolute_path' do + it 'returns the correct absolute path by building it dynamically' do + upload = double(model: project, path: 'secret/foo.jpg') + + dynamic_segment = project.disk_path + + expect(described_class.absolute_path(upload)) + .to end_with("#{dynamic_segment}/secret/foo.jpg") + end + end + + describe "#store_dir" do + it "stores in the namespace path" do + uploader = described_class.new(project) - expect(uploader.store_dir).to include(project.path_with_namespace) - expect(uploader.store_dir).not_to include("system") + expect(uploader.store_dir).to include(project.disk_path) + expect(uploader.store_dir).not_to include("system") + end end end diff --git a/spec/views/shared/issuable/_participants.html.haml.rb b/spec/views/shared/issuable/_participants.html.haml.rb deleted file mode 100644 index 51059d4c0d7..00000000000 --- a/spec/views/shared/issuable/_participants.html.haml.rb +++ /dev/null @@ -1,26 +0,0 @@ -require 'spec_helper' -require 'nokogiri' - -describe 'shared/issuable/_participants.html.haml' do - let(:project) { create(:project) } - let(:participants) { create_list(:user, 100) } - - before do - allow(view).to receive_messages(project: project, - participants: participants) - end - - it 'renders lazy loaded avatars' do - render 'shared/issuable/participants' - - html = Nokogiri::HTML(rendered) - - avatars = html.css('.participants-author img') - - avatars.each do |avatar| - expect(avatar[:class]).to include('lazy') - expect(avatar[:src]).to eql(LazyImageTagHelper.placeholder_image) - expect(avatar[:"data-src"]).to match('http://www.gravatar.com/avatar/') - end - end -end |