diff options
576 files changed, 7132 insertions, 2581 deletions
diff --git a/.codeclimate.yml b/.codeclimate.yml index b02fe54a4ff..216ecf43beb 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -11,8 +11,8 @@ engines: exclude_paths: - "lib/api/v3/*" eslint: - # eslint-plugin-vue is locked to version 2 in codeclimate, we need version 4 - enabled: false + enabled: true + channel: "eslint-4" rubocop: enabled: true channel: "gitlab-rubocop-0-52-1" @@ -45,3 +45,4 @@ exclude_paths: - log/ - backups/ - coverage-javascript/ +- plugins/ diff --git a/.gitignore b/.gitignore index 2004c2a09b4..fa39ae01ff0 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,4 @@ eslint-report.html /locale/**/LC_MESSAGES /locale/**/*.time_stamp /.rspec +/plugins/* diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ae762e7aa6e..8a0c9802c15 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.6-golang-1.9-git-2.14-chrome-63.0-node-8.x-yarn-1.2-postgresql-9.6" +image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.6-golang-1.9-git-2.16-chrome-63.0-node-8.x-yarn-1.2-postgresql-9.6" .dedicated-runner: &dedicated-runner retry: 1 diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md index aec734870d6..3e58d2a867e 100644 --- a/.gitlab/issue_templates/Bug.md +++ b/.gitlab/issue_templates/Bug.md @@ -1,3 +1,4 @@ +<!--- Please read this! Before opening a new issue, make sure to search for keywords in the issues @@ -14,10 +15,7 @@ For the Enterprise Edition issue tracker: - https://gitlab.com/gitlab-org/gitlab-ee/issues?label_name%5B%5D=bug and verify the issue you're about to submit isn't a duplicate. - -Please remove this notice if you're confident your issue isn't a duplicate. - ------- +---> ### Summary diff --git a/.rubocop.yml b/.rubocop.yml index 24edb641657..293f61fb725 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -17,6 +17,7 @@ AllCops: - 'bin/**/*' - 'generator_templates/**/*' - 'builds/**/*' + - 'plugins/**/*' CacheRootDirectory: tmp # This cop checks whether some constant value isn't a diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfe4bf65f9f..b70d2da5bea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -397,9 +397,9 @@ For issues related to the open source stewardship of GitLab, there is the ~"stewardship" label. This label is to be used for issues in which the stewardship of GitLab -is a topic of discussion. For instance if GitLab Inc. is planning to remove -features from GitLab CE to make exclusive in GitLab EE, related issues -would be labelled with ~"stewardship". +is a topic of discussion. For instance if GitLab Inc. is planning to add +features from GitLab EE to GitLab CE, related issues would be labelled with +~"stewardship". A recent example of this was the issue for [bringing the time tracking API to GitLab CE][time-tracking-issue]. diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 92fc430ae8f..137c1281121 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.82.0 +0.85.0 @@ -411,7 +411,7 @@ group :ed25519 do end # Gitaly GRPC client -gem 'gitaly-proto', '~> 0.84.0', require: 'gitaly' +gem 'gitaly-proto', '~> 0.85.0', require: 'gitaly' # Locked until https://github.com/google/protobuf/issues/4210 is closed gem 'google-protobuf', '= 3.5.1' diff --git a/Gemfile.lock b/Gemfile.lock index 57ff086f0b1..89b86ae0259 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -285,7 +285,7 @@ GEM po_to_json (>= 1.0.0) rails (>= 3.2.0) gherkin-ruby (0.3.2) - gitaly-proto (0.84.0) + gitaly-proto (0.85.0) google-protobuf (~> 3.1) grpc (~> 1.0) github-linguist (5.3.3) @@ -1057,7 +1057,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.2.0) - gitaly-proto (~> 0.84.0) + gitaly-proto (~> 0.85.0) github-linguist (~> 5.3.3) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-markup (~> 1.6.2) diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index 0f28bd233ac..0da872db7e5 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -3,10 +3,10 @@ import AccessorUtilities from './lib/utils/accessor'; export default class Autosave { - constructor(field, key, resource) { + constructor(field, key) { this.field = field; + this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); - this.resource = resource; if (key.join != null) { key = key.join('/'); } @@ -17,31 +17,27 @@ export default class Autosave { } restore() { - var text; - if (!this.isLocalStorageAvailable) return; + if (!this.field.length) return; - text = window.localStorage.getItem(this.key); + const text = window.localStorage.getItem(this.key); if ((text != null ? text.length : void 0) > 0) { this.field.val(text); } - if (!this.resource && this.resource !== 'issue') { - this.field.trigger('input'); - } else { - // v-model does not update with jQuery trigger - // https://github.com/vuejs/vue/issues/2804#issuecomment-216968137 - const event = new Event('change', { bubbles: true, cancelable: false }); - const field = this.field.get(0); - if (field) { - field.dispatchEvent(event); - } - } + + this.field.trigger('input'); + // v-model does not update with jQuery trigger + // https://github.com/vuejs/vue/issues/2804#issuecomment-216968137 + const event = new Event('change', { bubbles: true, cancelable: false }); + const field = this.field.get(0); + field.dispatchEvent(event); } save() { - var text; - text = this.field.val(); + if (!this.field.length) return; + + const text = this.field.val(); if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) { return window.localStorage.setItem(this.key, text); diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 9456edebccb..26e62732b33 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -2,7 +2,7 @@ import _ from 'underscore'; import Cookies from 'js-cookie'; import { __ } from './locale'; -import { isInIssuePage, updateTooltipTitle } from './lib/utils/common_utils'; +import { isInIssuePage, isInMRPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils'; import flash from './flash'; import axios from './lib/utils/axios_utils'; @@ -239,9 +239,9 @@ class AwardsHandler { } addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) { - const isMainAwardsBlock = votesBlock.closest('.js-issue-note-awards').length; + const isMainAwardsBlock = votesBlock.closest('.js-noteable-awards').length; - if (isInIssuePage() && !isMainAwardsBlock) { + if (this.isInVueNoteablePage() && !isMainAwardsBlock) { const id = votesBlock.attr('id').replace('note_', ''); this.hideMenuElement($('.emoji-menu')); @@ -293,8 +293,16 @@ class AwardsHandler { } } + isVueMRDiscussions() { + return isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible'); + } + + isInVueNoteablePage() { + return isInIssuePage() || this.isVueMRDiscussions(); + } + getVotesBlock() { - if (isInIssuePage()) { + if (this.isInVueNoteablePage()) { const $el = $('.js-add-award.is-active').closest('.note.timeline-entry'); if ($el.length) { diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 9a0442e2afe..6637904d87d 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -1,6 +1,6 @@ <script> import Sortable from 'vendor/Sortable'; -import boardNewIssue from './board_new_issue'; +import boardNewIssue from './board_new_issue.vue'; import boardCard from './board_card.vue'; import eventHub from '../eventhub'; import loadingIcon from '../../vue_shared/components/loading_icon.vue'; diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.vue index bc28f7f45f4..efface7143d 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.js +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -1,5 +1,6 @@ -/* global ListIssue */ +<script> import eventHub from '../eventhub'; +import ListIssue from '../models/issue'; const Store = gl.issueBoards.BoardsStore; @@ -17,6 +18,9 @@ export default { error: false, }; }, + mounted() { + this.$refs.input.focus(); + }, methods: { submit(e) { e.preventDefault(); @@ -59,42 +63,51 @@ export default { eventHub.$emit(`hide-issue-form-${this.list.id}`); }, }, - mounted() { - this.$refs.input.focus(); - }, - template: ` - <div class="card board-new-issue-form"> - <form @submit="submit($event)"> - <div class="flash-container" - v-if="error"> - <div class="flash-alert"> - An error occurred. Please try again. - </div> - </div> - <label class="label-light" - :for="list.id + '-title'"> - Title - </label> - <input class="form-control" - type="text" - v-model="title" - ref="input" - autocomplete="off" - :id="list.id + '-title'" /> - <div class="clearfix prepend-top-10"> - <button class="btn btn-success pull-left" - type="submit" - :disabled="title === ''" - ref="submit-button"> - Submit issue - </button> - <button class="btn btn-default pull-right" - type="button" - @click="cancel"> - Cancel - </button> - </div> - </form> - </div> - `, }; +</script> + +<template> + <div class="card board-new-issue-form"> + <form @submit="submit($event)"> + <div + class="flash-container" + v-if="error" + > + <div class="flash-alert"> + An error occurred. Please try again. + </div> + </div> + <label + class="label-light" + :for="list.id + '-title'" + > + Title + </label> + <input + class="form-control" + type="text" + v-model="title" + ref="input" + autocomplete="off" + :id="list.id + '-title'" + /> + <div class="clearfix prepend-top-10"> + <button + class="btn btn-success pull-left" + type="submit" + :disabled="title === ''" + ref="submit-button" + > + Submit issue + </button> + <button + class="btn btn-default pull-right" + type="button" + @click="cancel" + > + Cancel + </button> + </div> + </form> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index add24303e7b..9501e35b178 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -6,7 +6,7 @@ import { __ } from '../../locale'; import Sidebar from '../../right_sidebar'; import eventHub from '../../sidebar/event_hub'; import assigneeTitle from '../../sidebar/components/assignees/assignee_title'; -import assignees from '../../sidebar/components/assignees/assignees'; +import assignees from '../../sidebar/components/assignees/assignees.vue'; import DueDateSelectors from '../../due_date_select'; import './sidebar/remove_issue'; import IssuableContext from '../../issuable_context'; diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index 0df1f7a6f82..57a7cc4ca30 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -4,7 +4,9 @@ import FilteredSearchManager from '../filtered_search/filtered_search_manager'; export default class FilteredSearchBoards extends FilteredSearchManager { constructor(store, updateUrl = false, cantEdit = []) { - super('boards'); + super({ + page: 'boards', + }); this.store = store; this.updateUrl = updateUrl; diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 06a8abea940..8e31f1865f0 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -2,11 +2,13 @@ import _ from 'underscore'; import Vue from 'vue'; -import Flash from '../flash'; -import { __ } from '../locale'; + +import Flash from '~/flash'; +import { __ } from '~/locale'; + import FilteredSearchBoards from './filtered_search_boards'; import eventHub from './eventhub'; -import sidebarEventHub from '../sidebar/event_hub'; +import sidebarEventHub from '~/sidebar/event_hub'; // eslint-disable-line import/first import './models/issue'; import './models/label'; import './models/list'; @@ -22,7 +24,7 @@ import './components/board'; import './components/board_sidebar'; import './components/new_list_dropdown'; import './components/modal/index'; -import '../vue_shared/vue_resource_interceptor'; +import '~/vue_shared/vue_resource_interceptor'; // eslint-disable-line import/first export default () => { const $boardApp = document.getElementById('board-app'); diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index 81edd95bf2b..3bfb6d39ad5 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -110,3 +110,5 @@ class ListIssue { } window.ListIssue = ListIssue; + +export default ListIssue; diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 798d7e0d147..348cdeec737 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -2,7 +2,7 @@ /* global List */ import _ from 'underscore'; import Cookies from 'js-cookie'; -import { getUrlParamsArray } from '../../lib/utils/common_utils'; +import { getUrlParamsArray } from '~/lib/utils/common_utils'; window.gl = window.gl || {}; window.gl.issueBoards = window.gl.issueBoards || {}; diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index b070a59cf15..01aec4f36af 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -37,10 +37,11 @@ export default class Clusters { clusterStatusReason, helpPath, ingressHelpPath, + ingressDnsHelpPath, } = document.querySelector('.js-edit-cluster-form').dataset; this.store = new ClustersStore(); - this.store.setHelpPaths(helpPath, ingressHelpPath); + this.store.setHelpPaths(helpPath, ingressHelpPath, ingressDnsHelpPath); this.store.setManagePrometheusPath(managePrometheusPath); this.store.updateStatus(clusterStatus); this.store.updateStatusReason(clusterStatusReason); @@ -98,6 +99,7 @@ export default class Clusters { helpPath: this.state.helpPath, ingressHelpPath: this.state.ingressHelpPath, managePrometheusPath: this.state.managePrometheusPath, + ingressDnsHelpPath: this.state.ingressDnsHelpPath, }, }); }, diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 50e35bbbba5..c2a35341eb2 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -36,10 +36,6 @@ type: String, required: false, }, - description: { - type: String, - required: true, - }, status: { type: String, required: false, @@ -148,7 +144,7 @@ class="table-section section-wrap" role="gridcell" > - <div v-html="description"></div> + <slot name="description"></slot> </div> <div class="table-section table-button-footer section-align-top" diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 978881a4831..35618398468 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -2,10 +2,16 @@ import _ from 'underscore'; import { s__, sprintf } from '../../locale'; import applicationRow from './application_row.vue'; + import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; + import { + APPLICATION_INSTALLED, + INGRESS, + } from '../constants'; export default { components: { applicationRow, + clipboardButton, }, props: { applications: { @@ -23,6 +29,11 @@ required: false, default: '', }, + ingressDnsHelpPath: { + type: String, + required: false, + default: '', + }, managePrometheusPath: { type: String, required: false, @@ -43,19 +54,16 @@ false, ); }, - helmTillerDescription() { - return _.escape(s__( - `ClusterIntegration|Helm streamlines installing and managing Kubernetes applications. - Tiller runs inside of your Kubernetes Cluster, and manages - releases of your charts.`, - )); + ingressId() { + return INGRESS; + }, + ingressInstalled() { + return this.applications.ingress.status === APPLICATION_INSTALLED; + }, + ingressExternalIp() { + return this.applications.ingress.externalIp; }, ingressDescription() { - const descriptionParagraph = _.escape(s__( - `ClusterIntegration|Ingress gives you a way to route requests to services based on the - request host or path, centralizing a number of services into a single entrypoint.`, - )); - const extraCostParagraph = sprintf( _.escape(s__( `ClusterIntegration|%{boldNotice} This will add some extra resources @@ -84,9 +92,6 @@ return ` <p> - ${descriptionParagraph} - </p> - <p> ${extraCostParagraph} </p> <p class="settings-message append-bottom-0"> @@ -136,33 +141,121 @@ id="helm" :title="applications.helm.title" title-link="https://docs.helm.sh/" - :description="helmTillerDescription" :status="applications.helm.status" :status-reason="applications.helm.statusReason" :request-status="applications.helm.requestStatus" :request-reason="applications.helm.requestReason" - /> + > + <div slot="description"> + {{ s__(`ClusterIntegration|Helm streamlines installing + and managing Kubernetes applications. + Tiller runs inside of your Kubernetes Cluster, + and manages releases of your charts.`) }} + </div> + </application-row> <application-row - id="ingress" + :id="ingressId" :title="applications.ingress.title" title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/" - :description="ingressDescription" :status="applications.ingress.status" :status-reason="applications.ingress.statusReason" :request-status="applications.ingress.requestStatus" :request-reason="applications.ingress.requestReason" - /> + > + <div slot="description"> + <p> + {{ s__(`ClusterIntegration|Ingress gives you a way to route + requests to services based on the request host or path, + centralizing a number of services into a single entrypoint.`) }} + </p> + + <template v-if="ingressInstalled"> + <div class="form-group"> + <label for="ingress-ip-address"> + {{ s__('ClusterIntegration|Ingress IP Address') }} + </label> + <div + v-if="ingressExternalIp" + class="input-group" + > + <input + type="text" + id="ingress-ip-address" + class="form-control js-ip-address" + :value="ingressExternalIp" + readonly + /> + <span class="input-group-btn"> + <clipboard-button + :text="ingressExternalIp" + :title="s__('ClusterIntegration|Copy Ingress IP Address to clipboard')" + css-class="btn btn-default js-clipboard-btn" + /> + </span> + </div> + <input + v-else + type="text" + class="form-control js-ip-address" + readonly + value="?" + /> + </div> + + <p + v-if="!ingressExternalIp" + class="settings-message js-no-ip-message" + > + {{ s__(`ClusterIntegration|The IP address is in + the process of being assigned. Please check your Kubernetes + cluster or Quotas on GKE if it takes a long time.`) }} + + <a + :href="ingressHelpPath" + target="_blank" + rel="noopener noreferrer" + > + {{ __('More information') }} + </a> + </p> + + <p> + {{ s__(`ClusterIntegration|Point a wildcard DNS to this + generated IP address in order to access + your application after it has been deployed.`) }} + <a + :href="ingressDnsHelpPath" + target="_blank" + rel="noopener noreferrer" + > + {{ __('More information') }} + </a> + </p> + + </template> + <div + v-else + v-html="ingressDescription" + > + </div> + </div> + </application-row> <application-row id="prometheus" :title="applications.prometheus.title" title-link="https://prometheus.io/docs/introduction/overview/" :manage-link="managePrometheusPath" - :description="prometheusDescription" :status="applications.prometheus.status" :status-reason="applications.prometheus.statusReason" :request-status="applications.prometheus.requestStatus" :request-reason="applications.prometheus.requestReason" - /> + > + <div + slot="description" + v-html="prometheusDescription" + > + </div> + </application-row> <!-- NOTE: Don't forget to update `clusters.scss` min-height for this block and uncomment `application_spec` tests diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index 93223aefff8..b7179f52bb3 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -10,3 +10,4 @@ export const APPLICATION_ERROR = 'errored'; export const REQUEST_LOADING = 'request-loading'; export const REQUEST_SUCCESS = 'request-success'; export const REQUEST_FAILURE = 'request-failure'; +export const INGRESS = 'ingress'; diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 904ee5fd475..348bbec3b25 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -1,4 +1,5 @@ import { s__ } from '../../locale'; +import { INGRESS } from '../constants'; export default class ClusterStore { constructor() { @@ -21,6 +22,7 @@ export default class ClusterStore { statusReason: null, requestStatus: null, requestReason: null, + externalIp: null, }, runner: { title: s__('ClusterIntegration|GitLab Runner'), @@ -40,9 +42,10 @@ export default class ClusterStore { }; } - setHelpPaths(helpPath, ingressHelpPath) { + setHelpPaths(helpPath, ingressHelpPath, ingressDnsHelpPath) { this.state.helpPath = helpPath; this.state.ingressHelpPath = ingressHelpPath; + this.state.ingressDnsHelpPath = ingressDnsHelpPath; } setManagePrometheusPath(managePrometheusPath) { @@ -64,6 +67,7 @@ export default class ClusterStore { updateStateFromServer(serverState = {}) { this.state.status = serverState.status; this.state.statusReason = serverState.status_reason; + serverState.applications.forEach((serverAppEntry) => { const { name: appId, @@ -76,6 +80,10 @@ export default class ClusterStore { status, statusReason, }; + + if (appId === INGRESS) { + this.state.applications.ingress.externalIp = serverAppEntry.external_ip; + } }); } } diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js index 1f9153d95bd..3d89bf1316e 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js @@ -15,7 +15,7 @@ const CommitPipelinesTable = Vue.extend(commitPipelinesTable); window.gl = window.gl || {}; window.gl.CommitPipelinesTable = CommitPipelinesTable; -document.addEventListener('DOMContentLoaded', () => { +export default () => { const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view'); if (pipelineTableViewEl) { @@ -43,4 +43,4 @@ document.addEventListener('DOMContentLoaded', () => { pipelineTableViewEl.appendChild(table.$el); } } -}); +}; diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index 034f2923b3b..46d89c825f9 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -14,10 +14,10 @@ import CycleAnalyticsStore from './cycle_analytics_store'; Vue.use(Translate); -$(() => { +export default () => { const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed'; - gl.cycleAnalyticsApp = new Vue({ + new Vue({ // eslint-disable-line no-new el: '#cycle-analytics', name: 'CycleAnalytics', components: { @@ -132,4 +132,4 @@ $(() => { }, }, }); -}); +}; diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js index e77910a83d4..fadc34959e1 100644 --- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js +++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js @@ -197,7 +197,7 @@ const JumpToDiscussion = Vue.extend({ } $.scrollTo($target, { - offset: 0 + offset: -150 }); } }, diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js index 20ddcbfb8bd..cc9192deae3 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js @@ -87,6 +87,7 @@ const ResolveBtn = Vue.extend({ CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); this.discussion.updateHeadline(data); gl.mrWidget.checkStatus(); + document.dispatchEvent(new CustomEvent('refreshVueNotes')); this.updateTooltip(); }) diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js index 679057e787c..5f49609fe88 100644 --- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js +++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js @@ -14,6 +14,7 @@ import './components/resolve_count'; import './components/resolve_discussion_btn'; import './components/diff_note_avatars'; import './components/new_issue_for_discussion'; +import { hasVueMRDiscussionsCookie } from '../lib/utils/common_utils'; export default () => { const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box'); @@ -67,12 +68,14 @@ export default () => { gl.diffNotesCompileComponents(); - new Vue({ - el: '#resolve-count-app', - components: { - 'resolve-count': ResolveCount - }, - }); + if (!hasVueMRDiscussionsCookie()) { + new Vue({ + el: '#resolve-count-app', + components: { + 'resolve-count': ResolveCount + }, + }); + } $(window).trigger('resize.nav'); }; diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js index 96fe23640af..d16f9297de1 100644 --- a/app/assets/javascripts/diff_notes/services/resolve.js +++ b/app/assets/javascripts/diff_notes/services/resolve.js @@ -8,8 +8,8 @@ window.gl = window.gl || {}; class ResolveServiceClass { constructor(root) { - this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve`); - this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve`); + this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve?html=true`); + this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`); } resolve(noteId) { @@ -45,6 +45,7 @@ class ResolveServiceClass { if (gl.mrWidget) gl.mrWidget.checkStatus(); discussion.updateHeadline(data); + document.dispatchEvent(new CustomEvent('refreshVueNotes')); }) .catch(() => new Flash('An error occurred when trying to resolve a discussion. Please try again.')); } diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index acf0effa00d..1ccf96a75dc 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -6,177 +6,80 @@ import GlFieldErrors from './gl_field_errors'; import Shortcuts from './shortcuts'; import SearchAutocomplete from './search_autocomplete'; -var Dispatcher; - -(function() { - Dispatcher = (function() { - function Dispatcher() { - this.initSearch(); - this.initFieldErrors(); - this.initPageScripts(); - } - - Dispatcher.prototype.initPageScripts = function() { - var path, shortcut_handler; - const page = $('body').attr('data-page'); - if (!page) { - return false; - } - - const fail = () => Flash('Error loading dynamic module'); - const callDefault = m => m.default(); - - path = page.split(':'); - shortcut_handler = null; - - $('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => { - const gfm = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources); - const enableGFM = convertPermissionToBoolean(el.dataset.supportsAutocomplete); - gfm.setup($(el), { - emojis: true, - members: enableGFM, - issues: enableGFM, - milestones: enableGFM, - mergeRequests: enableGFM, - labels: enableGFM, - }); - }); - - const shortcutHandlerPages = [ - 'projects:activity', - 'projects:artifacts:browse', - 'projects:artifacts:file', - 'projects:blame:show', - 'projects:blob:show', - 'projects:commit:show', - 'projects:commits:show', - 'projects:find_file:show', - 'projects:issues:edit', - 'projects:issues:index', - 'projects:issues:new', - 'projects:issues:show', - 'projects:merge_requests:creations:diffs', - 'projects:merge_requests:creations:new', - 'projects:merge_requests:edit', - 'projects:merge_requests:index', - 'projects:merge_requests:show', - 'projects:network:show', - 'projects:show', - 'projects:tree:show', - 'groups:show', - ]; +function initSearch() { + // Only when search form is present + if ($('.search').length) { + return new SearchAutocomplete(); + } +} - if (shortcutHandlerPages.indexOf(page) !== -1) { - shortcut_handler = true; - } +function initFieldErrors() { + $('.gl-show-field-errors').each((i, form) => { + new GlFieldErrors(form); + }); +} - switch (path[0]) { - case 'admin': - switch (path[1]) { - case 'broadcast_messages': - import('./pages/admin/broadcast_messages') - .then(callDefault) - .catch(fail); - break; - case 'cohorts': - import('./pages/admin/cohorts') - .then(callDefault) - .catch(fail); - break; - case 'groups': - switch (path[2]) { - case 'show': - import('./pages/admin/groups/show') - .then(callDefault) - .catch(fail); - break; - } - break; - case 'projects': - import('./pages/admin/projects') - .then(callDefault) - .catch(fail); - break; - case 'labels': - switch (path[2]) { - case 'new': - import('./pages/admin/labels/new') - .then(callDefault) - .catch(fail); - break; - case 'edit': - import('./pages/admin/labels/edit') - .then(callDefault) - .catch(fail); - break; - } - case 'abuse_reports': - import('./pages/admin/abuse_reports') - .then(callDefault) - .catch(fail); - break; - } - break; - case 'profiles': - import('./pages/profiles/index') - .then(callDefault) - .catch(fail); - break; - case 'projects': - import('./pages/projects') - .then(callDefault) - .catch(fail); - shortcut_handler = true; - switch (path[1]) { - case 'compare': - import('./pages/projects/compare') - .then(callDefault) - .catch(fail); - break; - case 'create': - case 'new': - import('./pages/projects/new') - .then(callDefault) - .catch(fail); - break; - case 'wikis': - import('./pages/projects/wikis') - .then(callDefault) - .catch(fail); - shortcut_handler = true; - break; - } - break; - } - // If we haven't installed a custom shortcut handler, install the default one - if (!shortcut_handler) { - new Shortcuts(); - } +function initPageShortcuts(page) { + const pagesWithCustomShortcuts = [ + 'projects:activity', + 'projects:artifacts:browse', + 'projects:artifacts:file', + 'projects:blame:show', + 'projects:blob:show', + 'projects:commit:show', + 'projects:commits:show', + 'projects:find_file:show', + 'projects:issues:edit', + 'projects:issues:index', + 'projects:issues:new', + 'projects:issues:show', + 'projects:merge_requests:creations:diffs', + 'projects:merge_requests:creations:new', + 'projects:merge_requests:edit', + 'projects:merge_requests:index', + 'projects:merge_requests:show', + 'projects:network:show', + 'projects:show', + 'projects:tree:show', + 'groups:show', + ]; - if (document.querySelector('#peek')) { - import('./performance_bar') - .then(m => new m.default({ container: '#peek' })) // eslint-disable-line new-cap - .catch(fail); - } - }; + if (pagesWithCustomShortcuts.indexOf(page) === -1) { + new Shortcuts(); + } +} - Dispatcher.prototype.initSearch = function() { - // Only when search form is present - if ($('.search').length) { - return new SearchAutocomplete(); - } - }; +function initGFMInput() { + $('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => { + const gfm = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources); + const enableGFM = convertPermissionToBoolean(el.dataset.supportsAutocomplete); + gfm.setup($(el), { + emojis: true, + members: enableGFM, + issues: enableGFM, + milestones: enableGFM, + mergeRequests: enableGFM, + labels: enableGFM, + }); + }); +} - Dispatcher.prototype.initFieldErrors = function() { - $('.gl-show-field-errors').each((i, form) => { - new GlFieldErrors(form); - }); - }; +function initPerformanceBar() { + if (document.querySelector('#peek')) { + import('./performance_bar') + .then(m => new m.default({ container: '#peek' })) // eslint-disable-line new-cap + .catch(() => Flash('Error loading performance bar module')); + } +} - return Dispatcher; - })(); -})(); +export default () => { + initSearch(); + initFieldErrors(); -export default function initDispatcher() { - return new Dispatcher(); -} + const page = $('body').attr('data-page'); + if (page) { + initPageShortcuts(page); + initGFMInput(); + initPerformanceBar(); + } +}; diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index b4eca47957e..22863e926d4 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -2,8 +2,8 @@ /** * Render environments table. */ +import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import environmentItem from './environment_item.vue'; -import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { components: { diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js index 5d2d14c7682..de0fbdb2e91 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js +++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js @@ -5,7 +5,7 @@ import Translate from '../../vue_shared/translate'; Vue.use(Translate); -document.addEventListener('DOMContentLoaded', () => new Vue({ +export default () => new Vue({ el: '#environments-folder-list-view', components: { environmentsFolderApp, @@ -32,4 +32,4 @@ document.addEventListener('DOMContentLoaded', () => new Vue({ }, }); }, -})); +}); diff --git a/app/assets/javascripts/environments/environments_bundle.js b/app/assets/javascripts/environments/index.js index 2e0a4001b7c..afc4aba6554 100644 --- a/app/assets/javascripts/environments/environments_bundle.js +++ b/app/assets/javascripts/environments/index.js @@ -5,7 +5,7 @@ import Translate from '../vue_shared/translate'; Vue.use(Translate); -document.addEventListener('DOMContentLoaded', () => new Vue({ +export default () => new Vue({ el: '#environments-list-view', components: { environmentsComponent, @@ -36,4 +36,4 @@ document.addEventListener('DOMContentLoaded', () => new Vue({ }, }); }, -})); +}); diff --git a/app/assets/javascripts/filtered_search/filtered_search_bundle.js b/app/assets/javascripts/filtered_search/filtered_search_bundle.js deleted file mode 100644 index 293154917fa..00000000000 --- a/app/assets/javascripts/filtered_search/filtered_search_bundle.js +++ /dev/null @@ -1,10 +0,0 @@ -import './dropdown_emoji'; -import './dropdown_hint'; -import './dropdown_non_user'; -import './dropdown_user'; -import './dropdown_utils'; -import './filtered_search_dropdown_manager'; -import './filtered_search_dropdown'; -import './filtered_search_manager'; -import './filtered_search_tokenizer'; -import './filtered_search_visual_tokens'; diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index c64553a1b92..ee49a7be0b2 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -10,13 +10,22 @@ import DropdownUser from './dropdown_user'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; export default class FilteredSearchDropdownManager { - constructor(baseEndpoint = '', tokenizer, page, isGroup, filteredSearchTokenKeys) { + constructor({ + baseEndpoint = '', + tokenizer, + page, + isGroup, + isGroupAncestor, + filteredSearchTokenKeys, + }) { this.container = FilteredSearchContainer.container; this.baseEndpoint = baseEndpoint.replace(/\/$/, ''); this.tokenizer = tokenizer; this.filteredSearchTokenKeys = filteredSearchTokenKeys || FilteredSearchTokenKeys; this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.page = page; + this.groupsOnly = isGroup; + this.groupAncestor = isGroupAncestor; this.setupMapping(); @@ -59,7 +68,7 @@ export default class FilteredSearchDropdownManager { reference: null, gl: DropdownNonUser, extraArguments: { - endpoint: `${this.baseEndpoint}/milestones.json`, + endpoint: this.getMilestoneEndpoint(), symbol: '%', }, element: this.container.querySelector('#js-dropdown-milestone'), @@ -68,7 +77,7 @@ export default class FilteredSearchDropdownManager { reference: null, gl: DropdownNonUser, extraArguments: { - endpoint: `${this.baseEndpoint}/labels.json`, + endpoint: this.getLabelsEndpoint(), symbol: '~', preprocessing: DropdownUtils.duplicateLabelPreprocessing, }, @@ -90,6 +99,18 @@ export default class FilteredSearchDropdownManager { this.mapping = allowedMappings; } + getMilestoneEndpoint() { + const endpoint = `${this.baseEndpoint}/milestones.json`; + + return endpoint; + } + + getLabelsEndpoint() { + const endpoint = `${this.baseEndpoint}/labels.json`; + + return endpoint; + } + static addWordToInput(tokenName, tokenValue = '', clicked = false) { const input = FilteredSearchContainer.container.querySelector('.filtered-search'); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index e294b629bd0..c6970d7837f 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -20,10 +20,13 @@ import DropdownUtils from './dropdown_utils'; export default class FilteredSearchManager { constructor({ page, + isGroup = false, + isGroupAncestor = false, filteredSearchTokenKeys = FilteredSearchTokenKeys, stateFiltersSelector = '.issues-state-filters', }) { - this.isGroup = false; + this.isGroup = isGroup; + this.isGroupAncestor = isGroupAncestor; this.states = ['opened', 'closed', 'merged', 'all']; this.page = page; @@ -75,13 +78,14 @@ export default class FilteredSearchManager { if (this.filteredSearchInput) { this.tokenizer = FilteredSearchTokenizer; - this.dropdownManager = new FilteredSearchDropdownManager( - this.filteredSearchInput.getAttribute('data-base-endpoint') || '', - this.tokenizer, - this.page, - this.isGroup, - this.filteredSearchTokenKeys, - ); + this.dropdownManager = new FilteredSearchDropdownManager({ + baseEndpoint: this.filteredSearchInput.getAttribute('data-base-endpoint') || '', + tokenizer: this.tokenizer, + page: this.page, + isGroup: this.isGroup, + isGroupAncestor: this.isGroupAncestor, + filteredSearchTokenKeys: this.filteredSearchTokenKeys, + }); this.recentSearchesRoot = new RecentSearchesRoot( this.recentSearchesStore, diff --git a/app/assets/javascripts/ide/components/ide_context_bar.vue b/app/assets/javascripts/ide/components/ide_context_bar.vue index dd947f66969..9d933b8891d 100644 --- a/app/assets/javascripts/ide/components/ide_context_bar.vue +++ b/app/assets/javascripts/ide/components/ide_context_bar.vue @@ -1,8 +1,8 @@ <script> import { mapGetters, mapState, mapActions } from 'vuex'; + import icon from '~/vue_shared/components/icon.vue'; + import panelResizer from '~/vue_shared/components/panel_resizer.vue'; import repoCommitSection from './repo_commit_section.vue'; - import icon from '../../vue_shared/components/icon.vue'; - import panelResizer from '../../vue_shared/components/panel_resizer.vue'; export default { components: { diff --git a/app/assets/javascripts/ide/components/ide_project_branches_tree.vue b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue index af2f7341a91..2fbff2bd789 100644 --- a/app/assets/javascripts/ide/components/ide_project_branches_tree.vue +++ b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue @@ -1,6 +1,6 @@ <script> +import icon from '~/vue_shared/components/icon.vue'; import repoTree from './ide_repo_tree.vue'; -import icon from '../../vue_shared/components/icon.vue'; import newDropdown from './new_dropdown/index.vue'; export default { diff --git a/app/assets/javascripts/ide/components/ide_project_tree.vue b/app/assets/javascripts/ide/components/ide_project_tree.vue index ed49a0e72a2..32bf7175c88 100644 --- a/app/assets/javascripts/ide/components/ide_project_tree.vue +++ b/app/assets/javascripts/ide/components/ide_project_tree.vue @@ -1,6 +1,6 @@ <script> +import projectAvatarImage from '~/vue_shared/components/project_avatar/image.vue'; import branchesTree from './ide_project_branches_tree.vue'; -import projectAvatarImage from '../../vue_shared/components/project_avatar/image.vue'; export default { components: { diff --git a/app/assets/javascripts/ide/components/ide_repo_tree.vue b/app/assets/javascripts/ide/components/ide_repo_tree.vue index 4651e345d75..4a324264992 100644 --- a/app/assets/javascripts/ide/components/ide_repo_tree.vue +++ b/app/assets/javascripts/ide/components/ide_repo_tree.vue @@ -1,8 +1,8 @@ <script> import { mapState } from 'vuex'; +import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; import repoPreviousDirectory from './repo_prev_directory.vue'; import repoFile from './repo_file.vue'; -import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue'; import { treeList } from '../stores/utils'; export default { diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue index a68f8ce0169..18b5059a17f 100644 --- a/app/assets/javascripts/ide/components/ide_side_bar.vue +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -1,9 +1,9 @@ <script> import { mapState, mapActions } from 'vuex'; + import icon from '~/vue_shared/components/icon.vue'; + import panelResizer from '~/vue_shared/components/panel_resizer.vue'; + import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; import projectTree from './ide_project_tree.vue'; - import icon from '../../vue_shared/components/icon.vue'; - import panelResizer from '../../vue_shared/components/panel_resizer.vue'; - import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue'; export default { components: { diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index e48c446c4a4..97ae64b206d 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -1,8 +1,8 @@ <script> import { mapState } from 'vuex'; - import icon from '../../vue_shared/components/icon.vue'; - import tooltip from '../../vue_shared/directives/tooltip'; - import timeAgoMixin from '../../vue_shared/mixins/timeago'; + import icon from '~/vue_shared/components/icon.vue'; + import tooltip from '~/vue_shared/directives/tooltip'; + import timeAgoMixin from '~/vue_shared/mixins/timeago'; export default { components: { diff --git a/app/assets/javascripts/ide/components/new_branch_form.vue b/app/assets/javascripts/ide/components/new_branch_form.vue index 56e31256132..1e8d5bb6453 100644 --- a/app/assets/javascripts/ide/components/new_branch_form.vue +++ b/app/assets/javascripts/ide/components/new_branch_form.vue @@ -1,7 +1,7 @@ <script> import { mapState, mapActions } from 'vuex'; - import flash, { hideFlash } from '../../flash'; - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + import flash, { hideFlash } from '~/flash'; + import loadingIcon from '~/vue_shared/components/loading_icon.vue'; export default { components: { diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 96b1bb78c1d..37f2cf30a29 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -1,8 +1,8 @@ <script> import { mapGetters, mapState, mapActions } from 'vuex'; -import tooltip from '../../vue_shared/directives/tooltip'; -import icon from '../../vue_shared/components/icon.vue'; -import modal from '../../vue_shared/components/modal.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import icon from '~/vue_shared/components/icon.vue'; +import modal from '~/vue_shared/components/modal.vue'; import commitFilesList from './commit_sidebar/list.vue'; export default { diff --git a/app/assets/javascripts/ide/components/repo_edit_button.vue b/app/assets/javascripts/ide/components/repo_edit_button.vue index c43e9163340..fe4320731d9 100644 --- a/app/assets/javascripts/ide/components/repo_edit_button.vue +++ b/app/assets/javascripts/ide/components/repo_edit_button.vue @@ -1,6 +1,6 @@ <script> import { mapGetters, mapActions, mapState } from 'vuex'; -import modal from '../../vue_shared/components/modal.vue'; +import modal from '~/vue_shared/components/modal.vue'; export default { components: { diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index f99228012f4..f31cc12339b 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -1,7 +1,7 @@ <script> /* global monaco */ import { mapState, mapGetters, mapActions } from 'vuex'; -import flash from '../../flash'; +import flash from '~/flash'; import monacoLoader from '../monaco_loader'; import Editor from '../lib/editor'; diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue index 110918872fb..cbbab765e1c 100644 --- a/app/assets/javascripts/ide/components/repo_file.vue +++ b/app/assets/javascripts/ide/components/repo_file.vue @@ -1,9 +1,9 @@ <script> import { mapState } from 'vuex'; - import timeAgoMixin from '../../vue_shared/mixins/timeago'; - import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue'; + import timeAgoMixin from '~/vue_shared/mixins/timeago'; + import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; + import fileIcon from '~/vue_shared/components/file_icon.vue'; import newDropdown from './new_dropdown/index.vue'; - import fileIcon from '../../vue_shared/components/file_icon.vue'; export default { components: { diff --git a/app/assets/javascripts/ide/components/repo_loading_file.vue b/app/assets/javascripts/ide/components/repo_loading_file.vue index 3aeb6f0b28f..79af8c0b0c7 100644 --- a/app/assets/javascripts/ide/components/repo_loading_file.vue +++ b/app/assets/javascripts/ide/components/repo_loading_file.vue @@ -1,6 +1,6 @@ <script> import { mapState } from 'vuex'; - import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue'; + import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; export default { components: { diff --git a/app/assets/javascripts/ide/components/repo_preview.vue b/app/assets/javascripts/ide/components/repo_preview.vue index e47270a9855..a216269e292 100644 --- a/app/assets/javascripts/ide/components/repo_preview.vue +++ b/app/assets/javascripts/ide/components/repo_preview.vue @@ -1,7 +1,7 @@ <script> import { mapGetters } from 'vuex'; - import LineHighlighter from '../../line_highlighter'; - import syntaxHighlight from '../../syntax_highlight'; + import LineHighlighter from '~/line_highlighter'; + import syntaxHighlight from '~/syntax_highlight'; export default { computed: { diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index 5ed7bddf6ae..5656081c598 100644 --- a/app/assets/javascripts/ide/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -1,6 +1,6 @@ <script> import { mapActions } from 'vuex'; - import fileIcon from '../../vue_shared/components/file_icon.vue'; + import fileIcon from '~/vue_shared/components/file_icon.vue'; export default { components: { diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index d007d0ae78f..2c690b1f635 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import { visitUrl } from '../../lib/utils/url_utility'; -import flash from '../../flash'; +import { visitUrl } from '~/lib/utils/url_utility'; +import flash from '~/flash'; import service from '../services'; import * as types from './mutation_types'; import { stripHtml } from '../../lib/utils/text_utility'; diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 7151ac05a09..9b46bbf83da 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -21,7 +21,7 @@ export default class LabelsSelect { } $els.each(function(i, dropdown) { - var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer; + var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer; $dropdown = $(dropdown); $dropdownContainer = $dropdown.closest('.labels-filter'); $toggleText = $dropdown.find('.dropdown-toggle-text'); @@ -53,13 +53,6 @@ export default class LabelsSelect { .map(function () { return this.value; }).get(); - if (issueUpdateURL != null) { - issueURLSplit = issueUpdateURL.split('/'); - } - if (issueUpdateURL) { - labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>'); - labelNoneHTMLTemplate = '<span class="no-value">None</span>'; - } const handleClick = options.handleClick; $sidebarLabelTooltip.tooltip(); @@ -91,14 +84,17 @@ export default class LabelsSelect { $loading.fadeOut(); $dropdown.trigger('loaded.gl.dropdown'); $selectbox.hide(); - data.issueURLSplit = issueURLSplit; + data.issueUpdateURL = issueUpdateURL; labelCount = 0; - if (data.labels.length) { - template = labelHTMLTemplate(data); + if (data.labels.length && issueUpdateURL) { + template = LabelsSelect.getLabelTemplate({ + labels: data.labels, + issueUpdateURL, + }); labelCount = data.labels.length; } else { - template = labelNoneHTMLTemplate; + template = '<span class="no-value">None</span>'; } $value.removeAttr('style').html(template); $sidebarCollapsedValue.text(labelCount); @@ -242,10 +238,16 @@ export default class LabelsSelect { filterable: true, selected: $dropdown.data('selected') || [], toggleLabel: function(selected, el) { + var $dropdownParent = $dropdown.parent(); + var $dropdownInputField = $dropdownParent.find('.dropdown-input-field'); var isSelected = el !== null ? el.hasClass('is-active') : false; var title = selected.title; var selectedLabels = this.selected; + if ($dropdownInputField.length && $dropdownInputField.val().length) { + $dropdownParent.find('.dropdown-input-clear').trigger('click'); + } + if (selected.id === 0) { this.selected = []; return 'No Label'; @@ -412,6 +414,26 @@ export default class LabelsSelect { this.bindEvents(); } + static getLabelTemplate(tplData) { + // We could use ES6 template string here + // and properly indent markup for readability + // but that also introduces unintended white-space + // so best approach is to use traditional way of + // concatenation + // see: http://2ality.com/2016/05/template-literal-whitespace.html#joining-arrays + const tpl = _.template([ + '<% _.each(labels, function(label){ %>', + '<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>">', + '<span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;">', + '<%- label.title %>', + '</span>', + '</a>', + '<% }); %>', + ].join('')); + + return tpl(tplData); + } + bindEvents() { return $('body').on('change', '.selected_issue', this.onSelectCheckboxIssue); } diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 017f3b986fd..e741789fbb6 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -1,3 +1,5 @@ +import jQuery from 'jquery'; +import Cookies from 'js-cookie'; import axios from './axios_utils'; import { getLocationHash } from './url_utility'; import { convertToCamelCase } from './text_utility'; @@ -22,13 +24,18 @@ export const getGroupSlug = () => { return null; }; -export const isInIssuePage = () => { - const page = getPagePath(1); - const action = getPagePath(2); +export const checkPageAndAction = (page, action) => { + const pagePath = getPagePath(1); + const actionPath = getPagePath(2); - return page === 'issues' && action === 'show'; + return pagePath === page && actionPath === action; }; +export const isInIssuePage = () => checkPageAndAction('issues', 'show'); +export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); +export const isInNoteablePage = () => isInIssuePage() || isInMRPage(); +export const hasVueMRDiscussionsCookie = () => Cookies.get('vue_mr_discussions'); + export const ajaxGet = url => axios.get(url, { params: { format: 'js' }, responseType: 'text', @@ -133,7 +140,11 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; // 3) Middle-click or Mouse Wheel Click (e.which is 2) export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2; -export const scrollToElement = ($el) => { +export const scrollToElement = (element) => { + let $el = element; + if (!(element instanceof jQuery)) { + $el = $(element); + } const top = $el.offset().top; const mrTabsHeight = $('.merge-request-tabs').height() || 0; const headerHeight = $('.navbar-gitlab').height() || 0; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 94d03621bff..c0ce0786518 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -65,6 +65,20 @@ export function capitalizeFirstCharacter(text) { return `${text[0].toUpperCase()}${text.slice(1)}`; } +export function camelCase(str) { + return str.replace(/_+([a-z])/gi, ($1, $2) => $2.toUpperCase()); +} + +export function camelCaseKeys(obj = {}) { + return Object.keys(obj).reduce((acc, key) => { + const camelKey = camelCase(key); + return { + ...acc, + [camelKey]: obj[key], + }; + }, {}); +} + /** * Replaces all html tags from a string with the given replacement. * diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js index b4b3c15108d..66b258839ae 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js @@ -12,7 +12,7 @@ import './components/inline_conflict_lines'; import './components/parallel_conflict_lines'; import syntaxHighlight from '../syntax_highlight'; -$(() => { +export default function initMergeConflicts() { const INTERACTIVE_RESOLVE_MODE = 'interactive'; const conflictsEl = document.querySelector('#conflicts'); const mergeConflictsStore = gl.mergeConflicts.mergeConflictsStore; @@ -91,4 +91,4 @@ $(() => { } } }); -}); +} diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 41971e92ec0..46789e324c2 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -241,6 +241,10 @@ export default class MergeRequestTabs { return newState; } + getCurrentAction() { + return this.currentAction; + } + loadCommits(source) { if (this.commitsLoaded) { return; diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js new file mode 100644 index 00000000000..f4cba998fa7 --- /dev/null +++ b/app/assets/javascripts/mr_notes/index.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import notesApp from '../notes/components/notes_app.vue'; +import discussionCounter from '../notes/components/discussion_counter.vue'; +import store from '../notes/stores'; + +document.addEventListener('DOMContentLoaded', () => { + new Vue({ // eslint-disable-line + el: '#js-vue-mr-discussions', + components: { + notesApp, + }, + data() { + const notesDataset = document.getElementById('js-vue-mr-discussions').dataset; + return { + noteableData: JSON.parse(notesDataset.noteableData), + currentUserData: JSON.parse(notesDataset.currentUserData), + notesData: JSON.parse(notesDataset.notesData), + }; + }, + render(createElement) { + return createElement('notes-app', { + props: { + noteableData: this.noteableData, + notesData: this.notesData, + userData: this.currentUserData, + }, + }); + }, + }); + + new Vue({ // eslint-disable-line + el: '#js-vue-discussion-counter', + components: { + discussionCounter, + }, + store, + render(createElement) { + return createElement('discussion-counter'); + }, + }); +}); diff --git a/app/assets/javascripts/network/network_bundle.js b/app/assets/javascripts/network/network_bundle.js deleted file mode 100644 index 129f1724cb8..00000000000 --- a/app/assets/javascripts/network/network_bundle.js +++ /dev/null @@ -1,17 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, quotes, no-var, vars-on-top, camelcase, comma-dangle, consistent-return, max-len */ - -import ShortcutsNetwork from '../shortcuts_network'; -import Network from './network'; - -$(function() { - if (!$(".network-graph").length) return; - - var network_graph; - network_graph = new Network({ - url: $(".network-graph").attr('data-url'), - commit_url: $(".network-graph").attr('data-commit-url'), - ref: $(".network-graph").attr('data-ref'), - commit_id: $(".network-graph").attr('data-commit-id') - }); - return new ShortcutsNetwork(network_graph.branch_graph); -}); diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index f17b432cffd..c640003d958 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -24,7 +24,7 @@ import GLForm from './gl_form'; import loadAwardsHandler from './awards_handler'; import Autosave from './autosave'; import TaskList from './task_list'; -import { isInViewport, getPagePath, scrollToElement, isMetaKey } from './lib/utils/common_utils'; +import { isInViewport, getPagePath, scrollToElement, isMetaKey, hasVueMRDiscussionsCookie } from './lib/utils/common_utils'; import imageDiffHelper from './image_diff/helpers/index'; import { localTimeAgo } from './lib/utils/datetime_utility'; @@ -44,6 +44,10 @@ export default class Notes { } } + static getInstance() { + return this.instance; + } + constructor(notes_url, note_ids, last_fetched_at, view, enableGFM = true) { this.updateTargetButtons = this.updateTargetButtons.bind(this); this.updateComment = this.updateComment.bind(this); @@ -102,67 +106,77 @@ export default class Notes { } addBinding() { + this.$wrapperEl = hasVueMRDiscussionsCookie() ? $(document).find('.diffs') : $(document); + // Edit note link - $(document).on('click', '.js-note-edit', this.showEditForm.bind(this)); - $(document).on('click', '.note-edit-cancel', this.cancelEdit); + this.$wrapperEl.on('click', '.js-note-edit', this.showEditForm.bind(this)); + this.$wrapperEl.on('click', '.note-edit-cancel', this.cancelEdit); // Reopen and close actions for Issue/MR combined with note form submit - $(document).on('click', '.js-comment-submit-button', this.postComment); - $(document).on('click', '.js-comment-save-button', this.updateComment); - $(document).on('keyup input', '.js-note-text', this.updateTargetButtons); + this.$wrapperEl.on('click', '.js-comment-submit-button', this.postComment); + this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment); + this.$wrapperEl.on('keyup input', '.js-note-text', this.updateTargetButtons); // resolve a discussion - $(document).on('click', '.js-comment-resolve-button', this.postComment); + this.$wrapperEl.on('click', '.js-comment-resolve-button', this.postComment); // remove a note (in general) - $(document).on('click', '.js-note-delete', this.removeNote); + this.$wrapperEl.on('click', '.js-note-delete', this.removeNote); // delete note attachment - $(document).on('click', '.js-note-attachment-delete', this.removeAttachment); + this.$wrapperEl.on('click', '.js-note-attachment-delete', this.removeAttachment); // reset main target form when clicking discard - $(document).on('click', '.js-note-discard', this.resetMainTargetForm); + this.$wrapperEl.on('click', '.js-note-discard', this.resetMainTargetForm); // update the file name when an attachment is selected - $(document).on('change', '.js-note-attachment-input', this.updateFormAttachment); + this.$wrapperEl.on('change', '.js-note-attachment-input', this.updateFormAttachment); // reply to diff/discussion notes - $(document).on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote); + this.$wrapperEl.on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote); // add diff note - $(document).on('click', '.js-add-diff-note-button', this.onAddDiffNote); + this.$wrapperEl.on('click', '.js-add-diff-note-button', this.onAddDiffNote); // add diff note for images - $(document).on('click', '.js-add-image-diff-note-button', this.onAddImageDiffNote); + this.$wrapperEl.on('click', '.js-add-image-diff-note-button', this.onAddImageDiffNote); // hide diff note form - $(document).on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm); + this.$wrapperEl.on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm); // toggle commit list - $(document).on('click', '.system-note-commit-list-toggler', this.toggleCommitList); + this.$wrapperEl.on('click', '.system-note-commit-list-toggler', this.toggleCommitList); // fetch notes when tab becomes visible - $(document).on('visibilitychange', this.visibilityChange); + this.$wrapperEl.on('visibilitychange', this.visibilityChange); // when issue status changes, we need to refresh data - $(document).on('issuable:change', this.refresh); + this.$wrapperEl.on('issuable:change', this.refresh); // ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs. - $(document).on('ajax:success', '.js-main-target-form', this.addNote); - $(document).on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote); - $(document).on('ajax:success', '.js-main-target-form', this.resetMainTargetForm); - $(document).on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton); + this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.addNote); + this.$wrapperEl.on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote); + this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.resetMainTargetForm); + this.$wrapperEl.on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton); // when a key is clicked on the notes - $(document).on('keydown', '.js-note-text', this.keydownNoteText); + this.$wrapperEl.on('keydown', '.js-note-text', this.keydownNoteText); // When the URL fragment/hash has changed, `#note_xxx` - return $(window).on('hashchange', this.onHashChange); + $(window).on('hashchange', this.onHashChange); + this.boundGetContent = this.getContent.bind(this); + document.addEventListener('refreshLegacyNotes', this.boundGetContent); + this.eventsBound = true; } cleanBinding() { - $(document).off('click', '.js-note-edit'); - $(document).off('click', '.note-edit-cancel'); - $(document).off('click', '.js-note-delete'); - $(document).off('click', '.js-note-attachment-delete'); - $(document).off('click', '.js-discussion-reply-button'); - $(document).off('click', '.js-add-diff-note-button'); - $(document).off('click', '.js-add-image-diff-note-button'); - $(document).off('visibilitychange'); - $(document).off('keyup input', '.js-note-text'); - $(document).off('click', '.js-note-target-reopen'); - $(document).off('click', '.js-note-target-close'); - $(document).off('click', '.js-note-discard'); - $(document).off('keydown', '.js-note-text'); - $(document).off('click', '.js-comment-resolve-button'); - $(document).off('click', '.system-note-commit-list-toggler'); - $(document).off('ajax:success', '.js-main-target-form'); - $(document).off('ajax:success', '.js-discussion-note-form'); - $(document).off('ajax:complete', '.js-main-target-form'); + if (!this.eventsBound) { + return; + } + + this.$wrapperEl.off('click', '.js-note-edit'); + this.$wrapperEl.off('click', '.note-edit-cancel'); + this.$wrapperEl.off('click', '.js-note-delete'); + this.$wrapperEl.off('click', '.js-note-attachment-delete'); + this.$wrapperEl.off('click', '.js-discussion-reply-button'); + this.$wrapperEl.off('click', '.js-add-diff-note-button'); + this.$wrapperEl.off('click', '.js-add-image-diff-note-button'); + this.$wrapperEl.off('visibilitychange'); + this.$wrapperEl.off('keyup input', '.js-note-text'); + this.$wrapperEl.off('click', '.js-note-target-reopen'); + this.$wrapperEl.off('click', '.js-note-target-close'); + this.$wrapperEl.off('click', '.js-note-discard'); + this.$wrapperEl.off('keydown', '.js-note-text'); + this.$wrapperEl.off('click', '.js-comment-resolve-button'); + this.$wrapperEl.off('click', '.system-note-commit-list-toggler'); + this.$wrapperEl.off('ajax:success', '.js-main-target-form'); + this.$wrapperEl.off('ajax:success', '.js-discussion-note-form'); + this.$wrapperEl.off('ajax:complete', '.js-main-target-form'); + document.removeEventListener('refreshLegacyNotes', this.boundGetContent); $(window).off('hashchange', this.onHashChange); } @@ -252,8 +266,10 @@ export default class Notes { if (this.refreshing) { return; } + this.refreshing = true; - axios.get(this.notes_url, { + + axios.get(`${this.notes_url}?html=true`, { headers: { 'X-Last-Fetched-At': this.last_fetched_at, }, @@ -350,7 +366,7 @@ export default class Notes { } if (!noteEntity.valid) { - if (noteEntity.errors.commands_only) { + if (noteEntity.errors && noteEntity.errors.commands_only) { if (noteEntity.commands_changes && Object.keys(noteEntity.commands_changes).length > 0) { $notesList.find('.system-note.being-posted').remove(); @@ -363,6 +379,10 @@ export default class Notes { const $note = $notesList.find(`#note_${noteEntity.id}`); if (Notes.isNewNote(noteEntity, this.note_ids)) { + if (hasVueMRDiscussionsCookie()) { + return; + } + this.note_ids.push(noteEntity.id); if ($notesList.length) { @@ -399,6 +419,8 @@ export default class Notes { this.setupNewNote($updatedNote); } } + + Notes.refreshVueNotes(); } isParallelView() { @@ -406,12 +428,11 @@ export default class Notes { } /** - * Render note in discussion area. - * - * Note: for rendering inline notes use renderDiscussionNote + * Render note in discussion area. To render inline notes use renderDiscussionNote. */ renderDiscussionNote(noteEntity, $form) { var discussionContainer, form, row, lineType, diffAvatarContainer; + if (!Notes.isNewNote(noteEntity, this.note_ids)) { return; } @@ -452,7 +473,9 @@ export default class Notes { // Init discussion on 'Discussion' page if it is merge request page const page = $('body').attr('data-page'); if ((page && page.indexOf('projects:merge_request') !== -1) || !noteEntity.diff_discussion_html) { - Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); + if (!hasVueMRDiscussionsCookie()) { + Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); + } } } else { // append new note to all matching discussions @@ -634,7 +657,6 @@ export default class Notes { var $noteEntityEl, $note_li; // Convert returned HTML to a jQuery object so we can modify it further $noteEntityEl = $(noteEntity.html); - $noteEntityEl.addClass('fade-in-full'); this.revertNoteEditForm($targetNote); $noteEntityEl.renderGFM(); // Find the note's `li` element by ID and replace it with the updated HTML @@ -730,7 +752,7 @@ export default class Notes { var selector = this.getEditFormSelector($target); var $editForm = $(selector); - $editForm.insertBefore('.notes-form'); + $editForm.insertBefore('.diffs'); $editForm.find('.js-comment-save-button').enable(); $editForm.find('.js-finish-edit-warning').hide(); } @@ -746,7 +768,8 @@ export default class Notes { } removeNoteEditForm($note) { - var form = $note.find('.current-note-edit-form'); + var form = $note.find('.diffs .current-note-edit-form'); + $note.removeClass('is-editing'); form.removeClass('current-note-edit-form'); form.find('.js-finish-edit-warning').hide(); @@ -818,6 +841,7 @@ export default class Notes { }; })(this)); + Notes.refreshVueNotes(); Notes.checkMergeRequestStatus(); return this.updateNotesCount(-1); } @@ -1157,7 +1181,7 @@ export default class Notes { this.glForm = new GLForm($editForm.find('form'), this.enableGFM); $editForm.find('form') - .attr('action', postUrl) + .attr('action', `${postUrl}?html=true`) .attr('data-remote', 'true'); $editForm.find('.js-form-target-id').val(targetId); $editForm.find('.js-form-target-type').val(targetType); @@ -1280,6 +1304,10 @@ export default class Notes { return $updatedNote; } + static refreshVueNotes() { + document.dispatchEvent(new CustomEvent('refreshVueNotes')); + } + /** * Get data from Form attributes to use for saving/submitting comment. */ @@ -1481,7 +1509,7 @@ export default class Notes { /* eslint-disable promise/catch-or-return */ // Make request to submit comment on server - axios.post(formAction, formData) + axios.post(`${formAction}?html=true`, formData) .then((res) => { const note = res.data; @@ -1546,6 +1574,8 @@ export default class Notes { if ($notesContainer.length) { $notesContainer.append('<div class="flash-container" style="display: none;"></div>'); } + + Notes.refreshVueNotes(); } else if (isMainForm) { // Check if this was main thread comment // Show final note element on UI and perform form and action buttons cleanup this.addNote($form, note); @@ -1627,7 +1657,7 @@ export default class Notes { /* eslint-disable promise/catch-or-return */ // Make request to update comment on server - axios.post(formAction, formData) + axios.post(`${formAction}?html=true`, formData) .then(({ data }) => { // Submission successful! render final note element this.updateNote(data, $editingNote); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index df796050e0d..b85c1a6ad72 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -2,10 +2,11 @@ import { mapActions, mapGetters } from 'vuex'; import _ from 'underscore'; import Autosize from 'autosize'; - import { __ } from '~/locale'; + import { __, sprintf } from '~/locale'; import Flash from '../../flash'; import Autosave from '../../autosave'; import TaskList from '../../task_list'; + import { capitalizeFirstCharacter, convertToCamelCase } from '../../lib/utils/text_utility'; import * as constants from '../constants'; import eventHub from '../event_hub'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; @@ -29,6 +30,12 @@ mixins: [ issuableStateMixin, ], + props: { + noteableType: { + type: String, + required: true, + }, + }, data() { return { note: '', @@ -43,37 +50,51 @@ 'getUserData', 'getNoteableData', 'getNotesData', - 'issueState', + 'openState', ]), + noteableDisplayName() { + return this.noteableType.replace(/_/g, ' '); + }, isLoggedIn() { return this.getUserData.id; }, commentButtonTitle() { return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion'; }, - isIssueOpen() { - return this.issueState === constants.OPENED || this.issueState === constants.REOPENED; + isOpen() { + return this.openState === constants.OPENED || this.openState === constants.REOPENED; }, canCreateNote() { return this.getNoteableData.current_user.can_create_note; }, issueActionButtonTitle() { - if (this.note.length) { - const actionText = this.isIssueOpen ? 'close' : 'reopen'; + const openOrClose = this.isOpen ? 'close' : 'reopen'; - return this.noteType === constants.COMMENT ? - `Comment & ${actionText} issue` : - `Start discussion & ${actionText} issue`; + if (this.note.length) { + return sprintf( + __('%{actionText} & %{openOrClose} %{noteable}'), + { + actionText: this.commentButtonTitle, + openOrClose, + noteable: this.noteableDisplayName, + }, + ); } - return this.isIssueOpen ? 'Close issue' : 'Reopen issue'; + return sprintf( + __('%{openOrClose} %{noteable}'), + { + openOrClose: capitalizeFirstCharacter(openOrClose), + noteable: this.noteableDisplayName, + }, + ); }, actionButtonClassNames() { return { - 'btn-reopen': !this.isIssueOpen, - 'btn-close': this.isIssueOpen, - 'js-note-target-close': this.isIssueOpen, - 'js-note-target-reopen': !this.isIssueOpen, + 'btn-reopen': !this.isOpen, + 'btn-close': this.isOpen, + 'js-note-target-close': this.isOpen, + 'js-note-target-reopen': !this.isOpen, }; }, markdownDocsPath() { @@ -138,7 +159,7 @@ flashContainer: this.$el, data: { note: { - noteable_type: constants.NOTEABLE_TYPE, + noteable_type: this.noteableType, noteable_id: this.getNoteableData.id, note: this.note, }, @@ -193,19 +214,29 @@ Please check your network connection and try again.`; this.isSubmitting = false; }, toggleIssueState() { - if (this.isIssueOpen) { + if (this.isOpen) { this.closeIssue() .then(() => this.enableButton()) .catch(() => { this.enableButton(); - Flash(__('Something went wrong while closing the issue. Please try again later')); + Flash( + sprintf( + __('Something went wrong while closing the %{issuable}. Please try again later'), + { issuable: this.noteableDisplayName }, + ), + ); }); } else { this.reopenIssue() .then(() => this.enableButton()) .catch(() => { this.enableButton(); - Flash(__('Something went wrong while reopening the issue. Please try again later')); + Flash( + sprintf( + __('Something went wrong while reopening the %{issuable}. Please try again later'), + { issuable: this.noteableDisplayName }, + ), + ); }); } }, @@ -221,7 +252,6 @@ Please check your network connection and try again.`; this.$refs.markdownField.previewMarkdown = false; } - // reset autostave this.autosave.reset(); }, setNoteType(type) { @@ -240,10 +270,11 @@ Please check your network connection and try again.`; }, initAutoSave() { if (this.isLoggedIn) { + const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType)); + this.autosave = new Autosave( $(this.$refs.textarea), - ['Note', 'Issue', this.getNoteableData.id], - 'issue', + ['Note', noteableType, this.getNoteableData.id], ); } }, @@ -331,7 +362,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" :disabled="isSubmitButtonDisabled" class="btn btn-create comment-btn js-comment-button js-comment-submit-button" type="submit"> - {{ commentButtonTitle }} + {{ __(commentButtonTitle) }} </button> <button :disabled="isSubmitButtonDisabled" @@ -359,7 +390,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" <div class="description"> <strong>Comment</strong> <p> - Add a general comment to this issue. + Add a general comment to this {{ noteableDisplayName }}. </p> </div> </button> diff --git a/app/assets/javascripts/notes/components/diff_file_header.vue b/app/assets/javascripts/notes/components/diff_file_header.vue new file mode 100644 index 00000000000..fe5baa3537f --- /dev/null +++ b/app/assets/javascripts/notes/components/diff_file_header.vue @@ -0,0 +1,92 @@ +<script> + import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + import Icon from '~/vue_shared/components/icon.vue'; + + export default { + components: { + ClipboardButton, + Icon, + }, + props: { + diffFile: { + type: Object, + required: true, + }, + }, + computed: { + titleTag() { + return this.diffFile.discussionPath ? 'a' : 'span'; + }, + }, + }; +</script> + +<template> + <div class="file-header-content"> + <div + v-if="diffFile.submodule" + > + <span> + <icon name="archive" /> + <strong + v-html="diffFile.submoduleLink" + class="file-title-name" + ></strong> + <clipboard-button + title="Copy file path to clipboard" + :text="diffFile.submoduleLink" + /> + </span> + </div> + <template v-else> + <component + ref="titleWrapper" + :is="titleTag" + :href="diffFile.discussionPath" + > + <span v-html="diffFile.blobIcon"></span> + <span v-if="diffFile.renamedFile"> + <strong + class="file-title-name has-tooltip" + :title="diffFile.oldPath" + data-container="body" + > + {{ diffFile.oldPath }} + </strong> + → + <strong + class="file-title-name has-tooltip" + :title="diffFile.newPath" + data-container="body" + > + {{ diffFile.newPath }} + </strong> + </span> + + <strong + v-else + class="file-title-name has-tooltip" + :title="diffFile.oldPath" + data-container="body" + > + {{ diffFile.filePath }} + <span v-if="diffFile.deletedFile"> + deleted + </span> + </strong> + </component> + + <clipboard-button + title="Copy file path to clipboard" + :text="diffFile.filePath" + /> + + <small + v-if="diffFile.modeChanged" + ref="fileMode" + > + {{ diffFile.aMode }} → {{ diffFile.bMode }} + </small> + </template> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue new file mode 100644 index 00000000000..75a32709ad5 --- /dev/null +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -0,0 +1,96 @@ +<script> + import syntaxHighlight from '~/syntax_highlight'; + import imageDiffHelper from '~/image_diff/helpers/index'; + import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + import DiffFileHeader from './diff_file_header.vue'; + + export default { + components: { + DiffFileHeader, + }, + props: { + discussion: { + type: Object, + required: true, + }, + }, + computed: { + isImageDiff() { + return !this.diffFile.text; + }, + diffFileClass() { + const { text } = this.diffFile; + return text ? 'text-file' : 'js-image-file'; + }, + diffRows() { + return $(this.discussion.truncatedDiffLines); + }, + diffFile() { + return convertObjectPropsToCamelCase(this.discussion.diffFile); + }, + imageDiffHtml() { + return this.discussion.imageDiffHtml; + }, + }, + mounted() { + if (this.isImageDiff) { + const canCreateNote = false; + const renderCommentBadge = true; + imageDiffHelper.initImageDiff(this.$refs.fileHolder, canCreateNote, renderCommentBadge); + } else { + const fileHolder = $(this.$refs.fileHolder); + this.$nextTick(() => { + syntaxHighlight(fileHolder); + }); + } + }, + methods: { + rowTag(html) { + return html.outerHTML ? 'tr' : 'template'; + }, + }, + }; +</script> + +<template> + <div + ref="fileHolder" + class="diff-file file-holder" + :class="diffFileClass" + > + <div class="js-file-title file-title file-title-flex-parent"> + <diff-file-header + :diff-file="diffFile" + /> + </div> + <div + v-if="diffFile.text" + class="diff-content code js-syntax-highlight" + > + <table> + <component + :is="rowTag(html)" + :class="html.className" + v-for="(html, index) in diffRows" + v-html="html.outerHTML" + :key="index" + /> + <tr class="notes_holder"> + <td + class="notes_line" + colspan="2" + ></td> + <td class="notes_content"> + <slot></slot> + </td> + </tr> + </table> + </div> + <div + v-else + > + <div v-html="imageDiffHtml"></div> + <slot></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue new file mode 100644 index 00000000000..0158f58b569 --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -0,0 +1,119 @@ +<script> + import { mapGetters } from 'vuex'; + import resolveSvg from 'icons/_icon_resolve_discussion.svg'; + import resolvedSvg from 'icons/_icon_status_success_solid.svg'; + import mrIssueSvg from 'icons/_icon_mr_issue.svg'; + import nextDiscussionSvg from 'icons/_next_discussion.svg'; + import { pluralize } from '../../lib/utils/text_utility'; + import { scrollToElement } from '../../lib/utils/common_utils'; + import tooltip from '../../vue_shared/directives/tooltip'; + + export default { + directives: { + tooltip, + }, + computed: { + ...mapGetters([ + 'getUserData', + 'getNoteableData', + 'discussionCount', + 'unresolvedDiscussions', + 'resolvedDiscussionCount', + ]), + isLoggedIn() { + return this.getUserData.id; + }, + hasNextButton() { + return this.isLoggedIn && !this.allResolved; + }, + countText() { + return pluralize('discussion', this.discussionCount); + }, + allResolved() { + return this.resolvedDiscussionCount === this.discussionCount; + }, + resolveAllDiscussionsIssuePath() { + return this.getNoteableData.create_issue_to_resolve_discussions_path; + }, + firstUnresolvedDiscussionId() { + const item = this.unresolvedDiscussions[0] || {}; + + return item.id; + }, + }, + created() { + this.resolveSvg = resolveSvg; + this.resolvedSvg = resolvedSvg; + this.mrIssueSvg = mrIssueSvg; + this.nextDiscussionSvg = nextDiscussionSvg; + }, + methods: { + jumpToFirstDiscussion() { + const el = document.querySelector(`[data-discussion-id="${this.firstUnresolvedDiscussionId}"]`); + const activeTab = window.mrTabs.currentAction; + + if (activeTab === 'commits' || activeTab === 'pipelines') { + window.mrTabs.activateTab('show'); + } + + if (el) { + scrollToElement(el); + } + }, + }, + }; +</script> + +<template> + <div class="line-resolve-all-container prepend-top-10"> + <div> + <div + v-if="discussionCount > 0" + :class="{ 'has-next-btn': hasNextButton }" + class="line-resolve-all"> + <span + :class="{ 'is-active': allResolved }" + class="line-resolve-btn is-disabled" + type="button"> + <span + v-if="allResolved" + v-html="resolvedSvg" + ></span> + <span + v-else + v-html="resolveSvg" + ></span> + </span> + <span class=".line-resolve-text"> + {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved + </span> + </div> + <div + v-if="resolveAllDiscussionsIssuePath && !allResolved" + class="btn-group" + role="group"> + <a + :href="resolveAllDiscussionsIssuePath" + v-tooltip + title="Resolve all discussions in new issue" + data-container="body" + class="new-issue-for-discussion btn btn-default discussion-create-issue-btn"> + <span v-html="mrIssueSvg"></span> + </a> + </div> + <div + v-if="isLoggedIn && !allResolved" + class="btn-group" + role="group"> + <button + @click="jumpToFirstDiscussion" + v-tooltip + title="Jump to first unresolved discussion" + data-container="body" + class="btn btn-default discussion-next-btn"> + <span v-html="nextDiscussionSvg"></span> + </button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 46ffb60aa60..c26aa6fa15d 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -4,6 +4,8 @@ import emojiSmile from 'icons/_emoji_smile.svg'; import emojiSmiley from 'icons/_emoji_smiley.svg'; import editSvg from 'icons/_icon_pencil.svg'; + import resolveDiscussionSvg from 'icons/_icon_resolve_discussion.svg'; + import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg'; import ellipsisSvg from 'icons/_ellipsis_v.svg'; import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; @@ -42,6 +44,26 @@ type: Boolean, required: true, }, + resolvable: { + type: Boolean, + required: false, + default: false, + }, + isResolved: { + type: Boolean, + required: false, + default: false, + }, + isResolving: { + type: Boolean, + required: false, + default: false, + }, + resolvedBy: { + type: Object, + required: false, + default: () => ({}), + }, canReportAsAbuse: { type: Boolean, required: true, @@ -63,6 +85,15 @@ currentUserId() { return this.getUserDataByProp('id'); }, + resolveButtonTitle() { + let title = 'Mark as resolved'; + + if (this.resolvedBy) { + title = `Resolved by ${this.resolvedBy.name}`; + } + + return title; + }, }, created() { this.emojiSmiling = emojiSmiling; @@ -70,6 +101,8 @@ this.emojiSmiley = emojiSmiley; this.editSvg = editSvg; this.ellipsisSvg = ellipsisSvg; + this.resolveDiscussionSvg = resolveDiscussionSvg; + this.resolvedDiscussionSvg = resolvedDiscussionSvg; }, methods: { onEdit() { @@ -78,6 +111,9 @@ onDelete() { this.$emit('handleDelete'); }, + onResolve() { + this.$emit('handleResolve'); + }, }, }; </script> @@ -90,6 +126,31 @@ {{ accessLevel }} </span> <div + v-if="resolvable" + class="note-actions-item"> + <button + v-tooltip + @click="onResolve" + :class="{ 'is-disabled': !resolvable, 'is-active': isResolved }" + :title="resolveButtonTitle" + :aria-label="resolveButtonTitle" + type="button" + class="line-resolve-btn note-action-button"> + <template v-if="!isResolving"> + <div + v-if="isResolved" + v-html="resolvedDiscussionSvg"></div> + <div + v-else + v-html="resolveDiscussionSvg"></div> + </template> + <loading-icon + v-else + :inline="true" + /> + </button> + </div> + <div v-if="canAddAwardEmoji" class="note-actions-item"> <a diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 2d7cd30115d..ca12df9db64 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -41,7 +41,7 @@ this.initTaskList(); if (this.isEditing) { - this.initAutoSave(); + this.initAutoSave(this.note.noteable_type); } }, updated() { @@ -50,7 +50,7 @@ if (this.isEditing) { if (!this.autosave) { - this.initAutoSave(); + this.initAutoSave(this.note.noteable_type); } else { this.setAutoSave(); } diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index d382a9bb642..1a13fdbeb7c 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -1,9 +1,10 @@ <script> - import { mapGetters } from 'vuex'; + import { mapGetters, mapActions } from 'vuex'; import eventHub from '../event_hub'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue'; import issuableStateMixin from '../mixins/issuable_state'; + import resolvable from '../mixins/resolvable'; export default { name: 'IssueNoteForm', @@ -13,6 +14,7 @@ }, mixins: [ issuableStateMixin, + resolvable, ], props: { noteBody: { @@ -30,7 +32,7 @@ required: false, default: 'Save comment', }, - discussion: { + note: { type: Object, required: false, default: () => ({}), @@ -42,9 +44,11 @@ }, data() { return { - note: this.noteBody, + updatedNoteBody: this.noteBody, conflictWhileEditing: false, isSubmitting: false, + isResolving: false, + resolveAsThread: true, }; }, computed: { @@ -71,13 +75,13 @@ return this.getUserDataByProp('id'); }, isDisabled() { - return !this.note.length || this.isSubmitting; + return !this.updatedNoteBody.length || this.isSubmitting; }, }, watch: { noteBody() { - if (this.note === this.noteBody) { - this.note = this.noteBody; + if (this.updatedNoteBody === this.noteBody) { + this.updatedNoteBody = this.noteBody; } else { this.conflictWhileEditing = true; } @@ -87,16 +91,24 @@ this.$refs.textarea.focus(); }, methods: { - handleUpdate() { + ...mapActions([ + 'toggleResolveNote', + ]), + handleUpdate(shouldResolve) { + const beforeSubmitDiscussionState = this.discussionResolved; this.isSubmitting = true; - this.$emit('handleFormUpdate', this.note, this.$refs.editNoteForm, () => { + this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => { this.isSubmitting = false; + + if (shouldResolve) { + this.resolveHandler(beforeSubmitDiscussionState); + } }); }, editMyLastNote() { - if (this.note === '') { - const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion); + if (this.updatedNoteBody === '') { + const lastNoteInDiscussion = this.getDiscussionLastNote(this.updatedNoteBody); if (lastNoteInDiscussion) { eventHub.$emit('enterEditMode', { @@ -107,7 +119,7 @@ }, cancelHandler(shouldConfirm = false) { // Sends information about confirm message and if the textarea has changed - this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note); + this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.updatedNoteBody); }, }, }; @@ -150,7 +162,7 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" :data-supports-quick-actions="!isEditing" aria-label="Description" - v-model="note" + v-model="updatedNoteBody" ref="textarea" slot="textarea" placeholder="Write a comment or drag your files here..." @@ -169,6 +181,13 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" {{ saveButtonTitle }} </button> <button + v-if="note.resolvable" + @click.prevent="handleUpdate(true)" + class="btn btn-nr btn-default append-right-10 js-comment-resolve-button" + > + {{ resolveButtonTitle }} + </button> + <button @click="cancelHandler()" class="btn btn-cancel note-edit-cancel" type="button"> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 5b255d4a710..4743d95b951 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -34,15 +34,15 @@ required: false, default: false, }, - }, - data() { - return { - isExpanded: true, - }; + expanded: { + type: Boolean, + required: false, + default: true, + }, }, computed: { toggleChevronClass() { - return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down'; + return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down'; }, noteTimestampLink() { return `#note_${this.noteId}`; @@ -53,7 +53,6 @@ 'setTargetNoteHash', ]), handleToggle() { - this.isExpanded = !this.isExpanded; this.$emit('toggleHandler'); }, updateTargetNoteHash() { diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 98a06c5fc71..76bb53eaf2f 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -1,5 +1,7 @@ <script> import { mapActions, mapGetters } from 'vuex'; + import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg'; + import nextDiscussionsSvg from 'icons/_next_discussion.svg'; import Flash from '../../flash'; import { SYSTEM_NOTE } from '../constants'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -8,13 +10,19 @@ import noteSignedOutWidget from './note_signed_out_widget.vue'; import noteEditedText from './note_edited_text.vue'; import noteForm from './note_form.vue'; + import diffWithNote from './diff_with_note.vue'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import autosave from '../mixins/autosave'; + import noteable from '../mixins/noteable'; + import resolvable from '../mixins/resolvable'; + import tooltip from '../../vue_shared/directives/tooltip'; + import { scrollToElement } from '../../lib/utils/common_utils'; export default { components: { noteableNote, + diffWithNote, userAvatarLink, noteHeader, noteSignedOutWidget, @@ -23,8 +31,13 @@ placeholderNote, placeholderSystemNote, }, + directives: { + tooltip, + }, mixins: [ autosave, + noteable, + resolvable, ], props: { note: { @@ -35,14 +48,25 @@ data() { return { isReplying: false, + isResolving: false, + resolveAsThread: true, }; }, computed: { ...mapGetters([ 'getNoteableData', + 'discussionCount', + 'resolvedDiscussionCount', + 'unresolvedDiscussions', ]), discussion() { - return this.note.notes[0]; + return { + ...this.note.notes[0], + truncatedDiffLines: this.note.truncated_diff_lines, + diffFile: this.note.diff_file, + diffDiscussion: this.note.diff_discussion, + imageDiffHtml: this.note.image_diff_html, + }; }, author() { return this.discussion.author; @@ -71,26 +95,40 @@ return null; }, + hasUnresolvedDiscussion() { + return this.unresolvedDiscussions.length > 0; + }, + wrapperComponent() { + return (this.discussion.diffDiscussion && this.discussion.diffFile) ? diffWithNote : 'div'; + }, + wrapperClass() { + return this.isDiffDiscussion ? '' : 'panel panel-default'; + }, }, mounted() { if (this.isReplying) { - this.initAutoSave(); + this.initAutoSave(this.discussion.noteable_type); } }, updated() { if (this.isReplying) { if (!this.autosave) { - this.initAutoSave(); + this.initAutoSave(this.discussion.noteable_type); } else { this.setAutoSave(); } } }, + created() { + this.resolveDiscussionsSvg = resolveDiscussionsSvg; + this.nextDiscussionsSvg = nextDiscussionsSvg; + }, methods: { ...mapActions([ 'saveNote', 'toggleDiscussion', 'removePlaceholderNotes', + 'toggleResolveNote', ]), componentName(note) { if (note.isPlaceholderNote) { @@ -103,7 +141,7 @@ return noteableNote; }, componentData(note) { - return note.isPlaceholderNote ? note.notes[0] : note; + return note.isPlaceholderNote ? this.note.notes[0] : note; }, toggleDiscussionHandler() { this.toggleDiscussion({ discussionId: this.note.id }); @@ -128,7 +166,7 @@ flashContainer: this.$el, data: { in_reply_to_discussion_id: this.note.reply_id, - target_type: 'issue', + target_type: this.noteableType, target_id: this.discussion.noteable_id, note: { note: noteText }, }, @@ -152,12 +190,27 @@ Please check your network connection and try again.`; }); }); }, + jumpToDiscussion() { + const unresolvedIds = this.unresolvedDiscussions.map(d => d.id); + const index = unresolvedIds.indexOf(this.note.id); + + if (index >= 0 && index !== unresolvedIds.length) { + const nextId = unresolvedIds[index + 1]; + const el = document.querySelector(`[data-discussion-id="${nextId}"]`); + + if (el) { + scrollToElement(el); + } + } + }, }, }; </script> <template> - <li class="note note-discussion timeline-entry"> + <li + :data-discussion-id="note.id" + class="note note-discussion timeline-entry"> <div class="timeline-entry-inner"> <div class="timeline-icon"> <user-avatar-link @@ -175,6 +228,7 @@ Please check your network connection and try again.`; :created-at="discussion.created_at" :note-id="discussion.id" :include-toggle="true" + :expanded="note.expanded" @toggleHandler="toggleDiscussionHandler" action-text="started a discussion" class="discussion" @@ -187,43 +241,103 @@ Please check your network connection and try again.`; class-name="discussion-headline-light js-discussion-headline" /> </div> - </div> - <div - v-if="note.expanded" - class="discussion-body"> - <div class="panel panel-default"> - <div class="discussion-notes"> - <ul class="notes"> - <component - v-for="note in note.notes" - :is="componentName(note)" - :note="componentData(note)" - :key="note.id" - /> - </ul> - <div - :class="{ 'is-replying': isReplying }" - class="discussion-reply-holder"> - <button - v-if="canReply && !isReplying" - @click="showReplyForm" - type="button" - class="js-vue-discussion-reply btn btn-text-field" - title="Add a reply"> - Reply... - </button> - <note-form - v-if="isReplying" - save-button-title="Comment" - :discussion="note" - :is-editing="false" - @handleFormUpdate="saveReply" - @cancelFormEdition="cancelReplyForm" - ref="noteForm" - /> - <note-signed-out-widget v-if="!canReply" /> + <div + v-if="note.expanded" + class="discussion-body"> + <component + :is="wrapperComponent" + :discussion="discussion" + :class="wrapperClass" + > + <div class="discussion-notes"> + <ul class="notes"> + <component + v-for="note in note.notes" + :is="componentName(note)" + :note="componentData(note)" + :key="note.id" + /> + </ul> + <div + :class="{ 'is-replying': isReplying }" + class="discussion-reply-holder"> + <template v-if="!isReplying && canReply"> + <div + class="btn-group-justified discussion-with-resolve-btn" + role="group"> + <div + class="btn-group" + role="group"> + <button + @click="showReplyForm" + type="button" + class="js-vue-discussion-reply btn btn-text-field" + title="Add a reply">Reply...</button> + </div> + <div + v-if="note.resolvable" + class="btn-group" + role="group"> + <button + @click="resolveHandler()" + type="button" + class="btn btn-default" + > + <i + v-if="isResolving" + aria-hidden="true" + class="fa fa-spinner fa-spin" + ></i> + {{ resolveButtonTitle }} + </button> + </div> + <div + class="btn-group discussion-actions" + role="group"> + <div + v-if="note.resolvable && !discussionResolved" + class="btn-group" + role="group"> + <a + :href="note.resolve_with_issue_path" + v-tooltip + class="new-issue-for-discussion btn + btn-default discussion-create-issue-btn" + title="Resolve this discussion in a new issue" + data-container="body" + > + <span v-html="resolveDiscussionsSvg"></span> + </a> + </div> + <div + v-if="hasUnresolvedDiscussion" + class="btn-group" + role="group"> + <button + @click="jumpToDiscussion" + v-tooltip + class="btn btn-default discussion-next-btn" + title="Jump to next unresolved discussion" + data-container="body" + > + <span v-html="nextDiscussionsSvg"></span> + </button> + </div> + </div> + </div> + </template> + <note-form + v-if="isReplying" + save-button-title="Comment" + :note="note" + :is-editing="false" + @handleFormUpdate="saveReply" + @cancelFormEdition="cancelReplyForm" + ref="noteForm" /> + <note-signed-out-widget v-if="!canReply" /> + </div> </div> - </div> + </component> </div> </div> </div> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 045077de383..4d17bd5acc2 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -7,6 +7,8 @@ import noteActions from './note_actions.vue'; import noteBody from './note_body.vue'; import eventHub from '../event_hub'; + import noteable from '../mixins/noteable'; + import resolvable from '../mixins/resolvable'; export default { components: { @@ -15,6 +17,10 @@ noteActions, noteBody, }, + mixins: [ + noteable, + resolvable, + ], props: { note: { type: Object, @@ -26,6 +32,7 @@ isEditing: false, isDeleting: false, isRequesting: false, + isResolving: false, }; }, computed: { @@ -65,6 +72,7 @@ ...mapActions([ 'deleteNote', 'updateNote', + 'toggleResolveNote', 'scrollToNoteIfNeeded', ]), editHandler() { @@ -89,7 +97,7 @@ const data = { endpoint: this.note.path, note: { - target_type: 'issue', + target_type: this.noteableType, target_id: this.note.noteable_id, note: { note: noteText }, }, @@ -134,7 +142,7 @@ // we need to do this to prevent noteForm inconsistent content warning // this is something we intentionally do so we need to recover the content this.note.note = noteText; - this.$refs.noteBody.$refs.noteForm.note = noteText; + this.$refs.noteBody.$refs.noteForm.note.note = noteText; }, }, }; @@ -171,8 +179,13 @@ :can-delete="note.current_user.can_edit" :can-report-as-abuse="canReportAsAbuse" :report-abuse-path="note.report_abuse_path" + :resolvable="note.resolvable" + :is-resolved="note.resolved" + :is-resolving="isResolving" + :resolved-by="note.resolved_by" @handleEdit="editHandler" @handleDelete="deleteHandler" + @handleResolve="resolveHandler" /> </div> <note-body diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 92db4830704..74afed5560b 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -11,6 +11,7 @@ import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; export default { name: 'NotesApp', @@ -48,7 +49,24 @@ ...mapGetters([ 'notes', 'getNotesDataByProp', + 'discussionCount', ]), + noteableType() { + // FIXME -- @fatihacet Get this from JSON data. + const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } = constants; + + return this.noteableData.merge_params ? MERGE_REQUEST_NOTEABLE_TYPE : ISSUE_NOTEABLE_TYPE; + }, + allNotes() { + if (this.isLoading) { + const totalNotes = parseInt(this.notesData.totalNotes, 10) || 0; + + return new Array(totalNotes).fill({ + isSkeletonNote: true, + }); + } + return this.notes; + }, }, created() { this.setNotesData(this.notesData); @@ -67,6 +85,10 @@ this.actionToggleAward({ awardName, noteId }); }); } + document.addEventListener('refreshVueNotes', this.fetchNotes); + }, + beforeDestroy() { + document.removeEventListener('refreshVueNotes', this.fetchNotes); }, methods: { ...mapActions({ @@ -81,6 +103,9 @@ setTargetNoteHash: 'setTargetNoteHash', }), getComponentName(note) { + if (note.isSkeletonNote) { + return skeletonLoadingContainer; + } if (note.isPlaceholderNote) { if (note.placeholderType === constants.SYSTEM_NOTE) { return placeholderSystemNote; @@ -109,9 +134,14 @@ }); }, initPolling() { + if (this.isPollingInitialized) { + return; + } + this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt')); this.poll(); + this.isPollingInitialized = true; }, checkLocationHash() { const hash = getLocationHash(); @@ -128,25 +158,20 @@ <template> <div id="notes"> - <div - v-if="isLoading" - class="js-loading loading"> - <loading-icon /> - </div> - <ul - v-if="!isLoading" id="notes-list" class="notes main-notes-list timeline"> <component - v-for="note in notes" + v-for="note in allNotes" :is="getComponentName(note)" :note="getComponentData(note)" :key="note.id" /> </ul> - <comment-form /> + <comment-form + :noteable-type="noteableType" + /> </div> </template> diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index a6961063c01..f4f407ffd8a 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -1,4 +1,5 @@ export const DISCUSSION_NOTE = 'DiscussionNote'; +export const DIFF_NOTE = 'DiffNote'; export const DISCUSSION = 'discussion'; export const NOTE = 'note'; export const SYSTEM_NOTE = 'systemNote'; @@ -8,4 +9,7 @@ export const REOPENED = 'reopened'; export const CLOSED = 'closed'; export const EMOJI_THUMBSUP = 'thumbsup'; export const EMOJI_THUMBSDOWN = 'thumbsdown'; -export const NOTEABLE_TYPE = 'Issue'; +export const ISSUE_NOTEABLE_TYPE = 'issue'; +export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request'; +export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; +export const RESOLVE_NOTE_METHOD_NAME = 'post'; diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 48e7cfddb63..545bf2c99a7 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -20,17 +20,7 @@ document.addEventListener('DOMContentLoaded', () => new Vue({ return { noteableData: JSON.parse(notesDataset.noteableData), currentUserData, - notesData: { - lastFetchedAt: notesDataset.lastFetchedAt, - discussionsPath: notesDataset.discussionsPath, - newSessionPath: notesDataset.newSessionPath, - registerPath: notesDataset.registerPath, - notesPath: notesDataset.notesPath, - markdownDocsPath: notesDataset.markdownDocsPath, - quickActionsDocsPath: notesDataset.quickActionsDocsPath, - closeIssuePath: notesDataset.closeIssuePath, - reopenIssuePath: notesDataset.reopenIssuePath, - }, + notesData: JSON.parse(notesDataset.notesData), }; }, render(createElement) { diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js index a008171beda..a3d897f2f12 100644 --- a/app/assets/javascripts/notes/mixins/autosave.js +++ b/app/assets/javascripts/notes/mixins/autosave.js @@ -1,9 +1,10 @@ import Autosave from '../../autosave'; +import { capitalizeFirstCharacter } from '../../lib/utils/text_utility'; export default { methods: { - initAutoSave() { - this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', 'Issue', this.note.id], 'issue'); + initAutoSave(noteableType) { + this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', capitalizeFirstCharacter(noteableType), this.note.id]); }, resetAutoSave() { this.autosave.reset(); diff --git a/app/assets/javascripts/notes/mixins/noteable.js b/app/assets/javascripts/notes/mixins/noteable.js new file mode 100644 index 00000000000..0da4ff49f08 --- /dev/null +++ b/app/assets/javascripts/notes/mixins/noteable.js @@ -0,0 +1,22 @@ +import * as constants from '../constants'; + +export default { + props: { + note: { + type: Object, + required: true, + }, + }, + computed: { + noteableType() { + switch (this.note.noteable_type) { + case 'MergeRequest': + return constants.MERGE_REQUEST_NOTEABLE_TYPE; + case 'Issue': + return constants.ISSUE_NOTEABLE_TYPE; + default: + return ''; + } + }, + }, +}; diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js new file mode 100644 index 00000000000..ab1ae115e52 --- /dev/null +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -0,0 +1,50 @@ +import Flash from '~/flash'; +import { __ } from '~/locale'; + +export default { + props: { + note: { + type: Object, + required: true, + }, + }, + computed: { + discussionResolved() { + const { notes, resolved } = this.note; + + if (notes) { // Decide resolved state using store. Only valid for discussions. + return notes.every(note => note.resolved && !note.system); + } + + return resolved; + }, + resolveButtonTitle() { + if (this.updatedNoteBody) { + if (this.discussionResolved) { + return __('Comment and unresolve discussion'); + } + + return __('Comment and resolve discussion'); + } + return this.discussionResolved ? __('Unresolve discussion') : __('Resolve discussion'); + }, + }, + methods: { + resolveHandler(resolvedState = false) { + this.isResolving = true; + const endpoint = this.note.resolve_path || `${this.note.path}/resolve`; + const isResolved = this.discussionResolved || resolvedState; + const discussion = this.resolveAsThread; + + this.toggleResolveNote({ endpoint, isResolved, discussion }) + .then(() => { + this.isResolving = false; + }) + .catch(() => { + this.isResolving = false; + const msg = __('Something went wrong while resolving this discussion. Please try again.'); + Flash(msg, 'alert', this.$el); + }); + }, + }, +}; diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js index b8e7ffc8c46..4766351dfc5 100644 --- a/app/assets/javascripts/notes/services/notes_service.js +++ b/app/assets/javascripts/notes/services/notes_service.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueResource from 'vue-resource'; +import * as constants from '../constants'; Vue.use(VueResource); @@ -19,6 +20,12 @@ export default { createNewNote(endpoint, data) { return Vue.http.post(endpoint, data, { emulateJSON: true }); }, + toggleResolveNote(endpoint, isResolved) { + const { RESOLVE_NOTE_METHOD_NAME, UNRESOLVE_NOTE_METHOD_NAME } = constants; + const method = isResolved ? UNRESOLVE_NOTE_METHOD_NAME : RESOLVE_NOTE_METHOD_NAME; + + return Vue.http[method](endpoint); + }, poll(data = {}) { const { endpoint, lastFetchedAt } = data; const options = { diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 4c846d69b86..42fc2a131b8 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -61,8 +61,17 @@ export const createNewNote = ({ commit }, { endpoint, data }) => service export const removePlaceholderNotes = ({ commit }) => commit(types.REMOVE_PLACEHOLDER_NOTES); +export const toggleResolveNote = ({ commit }, { endpoint, isResolved, discussion }) => service + .toggleResolveNote(endpoint, isResolved) + .then(res => res.json()) + .then((res) => { + const mutationType = discussion ? types.UPDATE_DISCUSSION : types.UPDATE_NOTE; + + commit(mutationType, res); + }); + export const closeIssue = ({ commit, dispatch, state }) => service - .toggleIssueState(state.notesData.closeIssuePath) + .toggleIssueState(state.notesData.closePath) .then(res => res.json()) .then((data) => { commit(types.CLOSE_ISSUE); @@ -70,7 +79,7 @@ export const closeIssue = ({ commit, dispatch, state }) => service }); export const reopenIssue = ({ commit, dispatch, state }) => service - .toggleIssueState(state.notesData.reopenIssuePath) + .toggleIssueState(state.notesData.reopenPath) .then(res => res.json()) .then((data) => { commit(types.REOPEN_ISSUE); @@ -80,7 +89,7 @@ export const reopenIssue = ({ commit, dispatch, state }) => service export const emitStateChangedEvent = ({ commit, getters }, data) => { const event = new CustomEvent('issuable_vue_app:change', { detail: { data, - isClosed: getters.issueState === constants.CLOSED, + isClosed: getters.openState === constants.CLOSED, } }); document.dispatchEvent(event); @@ -174,7 +183,7 @@ const pollSuccessCallBack = (resp, commit, state, getters) => { resp.notes.forEach((note) => { if (notesById[note.id]) { commit(types.UPDATE_NOTE, note); - } else if (note.type === constants.DISCUSSION_NOTE) { + } else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) { const discussion = utils.findNoteObjectById(state.notes, note.discussion_id); if (discussion) { diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 82024104d73..e6180101c58 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -8,7 +8,7 @@ export const getNotesDataByProp = state => prop => state.notesData[prop]; export const getNoteableData = state => state.noteableData; export const getNoteableDataByProp = state => prop => state.noteableData[prop]; -export const issueState = state => state.noteableData.state; +export const openState = state => state.noteableData.state; export const getUserData = state => state.userData || {}; export const getUserDataByProp = state => prop => state.userData && state.userData[prop]; @@ -30,3 +30,37 @@ export const getCurrentUserLastNote = state => _.flatten( export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes) .find(el => isLastNote(el, state)); + +export const discussionCount = (state) => { + const discussions = state.notes.filter(n => !n.individual_note); + + return discussions.length; +}; + +export const unresolvedDiscussions = (state, getters) => { + const resolvedMap = getters.resolvedDiscussionsById; + + return state.notes.filter(n => !n.individual_note && !resolvedMap[n.id]); +}; + +export const resolvedDiscussionsById = (state) => { + const map = {}; + + state.notes.forEach((n) => { + if (n.notes) { + const resolved = n.notes.every(note => note.resolved && !note.system); + + if (resolved) { + map[n.id] = n; + } + } + }); + + return map; +}; + +export const resolvedDiscussionCount = (state, getters) => { + const resolvedMap = getters.resolvedDiscussionsById; + + return Object.keys(resolvedMap).length; +}; diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 6d7c3bbae0f..da1b5a9e51a 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -12,6 +12,7 @@ export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE'; export const TOGGLE_AWARD = 'TOGGLE_AWARD'; export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION'; export const UPDATE_NOTE = 'UPDATE_NOTE'; +export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION'; // Issue export const CLOSE_ISSUE = 'CLOSE_ISSUE'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index b3f66578c9a..963b40be3fd 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -1,22 +1,32 @@ import * as utils from './utils'; import * as types from './mutation_types'; import * as constants from '../constants'; +import { isInMRPage } from '../../lib/utils/common_utils'; export default { [types.ADD_NEW_NOTE](state, note) { const { discussion_id, type } = note; const [exists] = state.notes.filter(n => n.id === note.discussion_id); + const isDiscussion = (type === constants.DISCUSSION_NOTE); if (!exists) { const noteData = { expanded: true, id: discussion_id, - individual_note: !(type === constants.DISCUSSION_NOTE), + individual_note: !isDiscussion, notes: [note], reply_id: discussion_id, }; + if (isDiscussion && isInMRPage()) { + noteData.resolvable = note.resolvable; + noteData.resolved = false; + noteData.resolve_path = note.resolve_path; + noteData.resolve_with_issue_path = note.resolve_with_issue_path; + } + state.notes.push(noteData); + document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); } }, @@ -25,6 +35,7 @@ export default { if (noteObj) { noteObj.notes.push(note); + document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); } }, @@ -41,6 +52,8 @@ export default { state.notes.splice(state.notes.indexOf(noteObj), 1); } } + + document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); }, [types.REMOVE_PLACEHOLDER_NOTES](state) { @@ -77,15 +90,19 @@ export default { const notes = []; notesData.forEach((note) => { + const nn = Object.assign({}, note); + // To support legacy notes, should be very rare case. if (note.individual_note && note.notes.length > 1) { note.notes.forEach((n) => { - const nn = Object.assign({}, note); nn.notes = [n]; // override notes array to only have one item to mimick individual_note notes.push(nn); }); } else { - notes.push(note); + const oldNote = utils.findNoteObjectById(state.notes, note.id); + nn.expanded = oldNote ? oldNote.expanded : note.expanded; + + notes.push(nn); } }); @@ -134,6 +151,8 @@ export default { user: { id, name, username }, }); } + + document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); }, [types.TOGGLE_DISCUSSION](state, { discussionId }) { @@ -151,6 +170,24 @@ export default { const comment = utils.findNoteObjectById(noteObj.notes, note.id); noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note); } + + // document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); + }, + + [types.UPDATE_DISCUSSION](state, noteData) { + const note = noteData; + let index = 0; + + state.notes.forEach((n, i) => { + if (n.id === note.id) { + index = i; + } + }); + + note.expanded = true; // override expand flag to prevent collapse + state.notes.splice(index, 1, note); + + document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); }, [types.CLOSE_ISSUE](state) { diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js index 6074115e855..275263a2aaa 100644 --- a/app/assets/javascripts/notes/stores/utils.js +++ b/app/assets/javascripts/notes/stores/utils.js @@ -28,4 +28,3 @@ export const getQuickActionText = (note) => { export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note); export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim(); - diff --git a/app/assets/javascripts/pages/admin/abuse_reports/index.js b/app/assets/javascripts/pages/admin/abuse_reports/index.js index c0b6e8d4095..d76b1f174fc 100644 --- a/app/assets/javascripts/pages/admin/abuse_reports/index.js +++ b/app/assets/javascripts/pages/admin/abuse_reports/index.js @@ -1,3 +1,3 @@ import AbuseReports from './abuse_reports'; -export default () => new AbuseReports(); +document.addEventListener('DOMContentLoaded', () => new AbuseReports()); diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js index b68ce5d32d8..f92450cbaa7 100644 --- a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js +++ b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js @@ -3,7 +3,7 @@ import axios from '~/lib/utils/axios_utils'; import flash from '~/flash'; import { __ } from '~/locale'; -export default function initBroadcastMessagesForm() { +export default () => { $('input#broadcast_message_color').on('input', function onMessageColorInput() { const previewColor = $(this).val(); $('div.broadcast-message-preview').css('background-color', previewColor); @@ -32,4 +32,4 @@ export default function initBroadcastMessagesForm() { .catch(() => flash(__('An error occurred while rendering preview broadcast message'))); } }, 250)); -} +}; diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/index.js b/app/assets/javascripts/pages/admin/broadcast_messages/index.js index b548c48282a..d6cc6a850eb 100644 --- a/app/assets/javascripts/pages/admin/broadcast_messages/index.js +++ b/app/assets/javascripts/pages/admin/broadcast_messages/index.js @@ -1,3 +1,3 @@ import initBroadcastMessagesForm from './broadcast_message'; -export default () => initBroadcastMessagesForm(); +document.addEventListener('DOMContentLoaded', initBroadcastMessagesForm); diff --git a/app/assets/javascripts/pages/admin/cohorts/index.js b/app/assets/javascripts/pages/admin/cohorts/index.js index 42ef9d38ef7..2d5020dbef4 100644 --- a/app/assets/javascripts/pages/admin/cohorts/index.js +++ b/app/assets/javascripts/pages/admin/cohorts/index.js @@ -1,3 +1,3 @@ import initUsagePing from './usage_ping'; -export default () => initUsagePing(); +document.addEventListener('DOMContentLoaded', initUsagePing); diff --git a/app/assets/javascripts/pages/admin/groups/show/index.js b/app/assets/javascripts/pages/admin/groups/show/index.js index 5defea104d4..b0cdad627a6 100644 --- a/app/assets/javascripts/pages/admin/groups/show/index.js +++ b/app/assets/javascripts/pages/admin/groups/show/index.js @@ -1,3 +1,3 @@ import UsersSelect from '../../../../users_select'; -export default () => new UsersSelect(); +document.addEventListener('DOMContentLoaded', () => new UsersSelect()); diff --git a/app/assets/javascripts/pages/admin/labels/edit/index.js b/app/assets/javascripts/pages/admin/labels/edit/index.js index d7ec6e47f67..5de1d4d6344 100644 --- a/app/assets/javascripts/pages/admin/labels/edit/index.js +++ b/app/assets/javascripts/pages/admin/labels/edit/index.js @@ -1,3 +1,3 @@ import Labels from '../../../../labels'; -export default () => new Labels(); +document.addEventListener('DOMContentLoaded', () => new Labels()); diff --git a/app/assets/javascripts/pages/admin/labels/new/index.js b/app/assets/javascripts/pages/admin/labels/new/index.js index d7ec6e47f67..5de1d4d6344 100644 --- a/app/assets/javascripts/pages/admin/labels/new/index.js +++ b/app/assets/javascripts/pages/admin/labels/new/index.js @@ -1,3 +1,3 @@ import Labels from '../../../../labels'; -export default () => new Labels(); +document.addEventListener('DOMContentLoaded', () => new Labels()); diff --git a/app/assets/javascripts/pages/admin/projects/index.js b/app/assets/javascripts/pages/admin/projects/index.js index 71e0ddcd7b6..31c96eb87af 100644 --- a/app/assets/javascripts/pages/admin/projects/index.js +++ b/app/assets/javascripts/pages/admin/projects/index.js @@ -1,9 +1,9 @@ import ProjectsList from '../../../projects_list'; import NamespaceSelect from '../../../namespace_select'; -export default () => { +document.addEventListener('DOMContentLoaded', () => { new ProjectsList(); // eslint-disable-line no-new document.querySelectorAll('.js-namespace-select') .forEach(dropdown => new NamespaceSelect({ dropdown })); -}; +}); diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index b3f6a72fdcb..42f7460ad55 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js @@ -2,9 +2,9 @@ import { visitUrl } from '~/lib/utils/url_utility'; import UsersSelect from '~/users_select'; import { isMetaClick } from '~/lib/utils/common_utils'; -import { __ } from '../../../../locale'; -import flash from '../../../../flash'; -import axios from '../../../../lib/utils/axios_utils'; +import { __ } from '~/locale'; +import flash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; export default class Todos { constructor() { diff --git a/app/assets/javascripts/pages/profiles/index.js b/app/assets/javascripts/pages/profiles/index.js new file mode 100644 index 00000000000..c52ad7bc335 --- /dev/null +++ b/app/assets/javascripts/pages/profiles/index.js @@ -0,0 +1,16 @@ +import '~/profile/gl_crop'; +import Profile from '~/profile/profile'; + +document.addEventListener('DOMContentLoaded', () => { + $(document).on('input.ssh_key', '#key_key', function () { // eslint-disable-line func-names + const $title = $('#key_title'); + const comment = $(this).val().match(/^\S+ \S+ (.+)\n?$/); + + // Extract the SSH Key title from its comment + if (comment && comment.length > 1) { + $title.val(comment[1]).change(); + } + }); + + new Profile(); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/profiles/index/index.js b/app/assets/javascripts/pages/profiles/index/index.js index 90eed38777a..9bd430f4f11 100644 --- a/app/assets/javascripts/pages/profiles/index/index.js +++ b/app/assets/javascripts/pages/profiles/index/index.js @@ -1,7 +1,7 @@ import NotificationsForm from '../../../notifications_form'; import notificationsDropdown from '../../../notifications_dropdown'; -export default () => { +document.addEventListener('DOMContentLoaded', () => { new NotificationsForm(); // eslint-disable-line no-new notificationsDropdown(); -}; +}); diff --git a/app/assets/javascripts/pages/projects/commit/pipelines/index.js b/app/assets/javascripts/pages/projects/commit/pipelines/index.js index 7889704a324..cd923f13ce8 100644 --- a/app/assets/javascripts/pages/projects/commit/pipelines/index.js +++ b/app/assets/javascripts/pages/projects/commit/pipelines/index.js @@ -1,8 +1,10 @@ import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; +import initPipelines from '~/commit/pipelines/pipelines_bundle'; document.addEventListener('DOMContentLoaded', () => { new MiniPipelineGraph({ container: '.js-commit-pipeline-graph', }).bindEvents(); $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath); + initPipelines(); }); diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index 460a54ab504..1aeed197385 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -5,6 +5,7 @@ import ShortcutsNavigation from '~/shortcuts_navigation'; import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; import initNotes from '~/init_notes'; import initChangesDropdown from '~/init_changes_dropdown'; +import initDiffNotes from '~/diff_notes/diff_notes_bundle'; import { fetchCommitMergeRequests } from '~/commit_merge_requests'; document.addEventListener('DOMContentLoaded', () => { @@ -19,4 +20,5 @@ document.addEventListener('DOMContentLoaded', () => { initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight - stickyBarPaddingTop); $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath); fetchCommitMergeRequests(); + initDiffNotes(); }); diff --git a/app/assets/javascripts/pages/projects/compare/index.js b/app/assets/javascripts/pages/projects/compare/index.js index 890062eeee6..d1c78bd61db 100644 --- a/app/assets/javascripts/pages/projects/compare/index.js +++ b/app/assets/javascripts/pages/projects/compare/index.js @@ -1,5 +1,3 @@ import initCompareAutocomplete from '~/compare_autocomplete'; -export default () => { - initCompareAutocomplete(); -}; +document.addEventListener('DOMContentLoaded', initCompareAutocomplete); diff --git a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js new file mode 100644 index 00000000000..df58e9dd072 --- /dev/null +++ b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js @@ -0,0 +1,3 @@ +import initCycleAnalytics from '~/cycle_analytics/cycle_analytics_bundle'; + +document.addEventListener('DOMContentLoaded', initCycleAnalytics); diff --git a/app/assets/javascripts/pages/projects/environments/folder/index.js b/app/assets/javascripts/pages/projects/environments/folder/index.js new file mode 100644 index 00000000000..5feaf944038 --- /dev/null +++ b/app/assets/javascripts/pages/projects/environments/folder/index.js @@ -0,0 +1,3 @@ +import initEnvironmentsFolderBundle from '~/environments/folder/environments_folder_bundle'; + +document.addEventListener('DOMContentLoaded', initEnvironmentsFolderBundle); diff --git a/app/assets/javascripts/pages/projects/environments/index.js b/app/assets/javascripts/pages/projects/environments/index.js new file mode 100644 index 00000000000..ace8af00ece --- /dev/null +++ b/app/assets/javascripts/pages/projects/environments/index.js @@ -0,0 +1,3 @@ +import initEnviroments from '~/environments/'; + +document.addEventListener('DOMContentLoaded', initEnviroments); diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index 9b1d52692a3..de1e13de7e9 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,7 +1,7 @@ import Project from './project'; import ShortcutsNavigation from '../../shortcuts_navigation'; -export default () => { +document.addEventListener('DOMContentLoaded', () => { new Project(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new -}; +}); diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js new file mode 100644 index 00000000000..37503cc1542 --- /dev/null +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -0,0 +1,14 @@ +import initIssuableSidebar from '~/init_issuable_sidebar'; +import Issue from '~/issue'; +import ShortcutsIssuable from '~/shortcuts_issuable'; +import ZenMode from '~/zen_mode'; +import '~/notes/index'; +import '~/issue_show/index'; + +export default function () { + new Issue(); // eslint-disable-line no-new + new ShortcutsIssuable(); // eslint-disable-line no-new + new ZenMode(); // eslint-disable-line no-new + initIssuableSidebar(); +} + diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js index 1e56aa58da2..7968dfd7a12 100644 --- a/app/assets/javascripts/pages/projects/issues/show/index.js +++ b/app/assets/javascripts/pages/projects/issues/show/index.js @@ -1,13 +1,7 @@ -import initIssuableSidebar from '~/init_issuable_sidebar'; -import Issue from '~/issue'; -import ShortcutsIssuable from '~/shortcuts_issuable'; -import ZenMode from '~/zen_mode'; -import '~/notes/index'; -import '~/issue_show/index'; +import initSidebarBundle from '~/sidebar/sidebar_bundle'; +import initShow from '../show'; document.addEventListener('DOMContentLoaded', () => { - new Issue(); // eslint-disable-line no-new - new ShortcutsIssuable(); // eslint-disable-line no-new - new ZenMode(); // eslint-disable-line no-new - initIssuableSidebar(); + initShow(); + initSidebarBundle(); }); diff --git a/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js b/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js new file mode 100644 index 00000000000..28641104c58 --- /dev/null +++ b/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js @@ -0,0 +1,7 @@ +import initSidebarBundle from '~/sidebar/sidebar_bundle'; +import initMergeConflicts from '~/merge_conflicts/merge_conflicts_bundle'; + +document.addEventListener('DOMContentLoaded', () => { + initSidebarBundle(); + initMergeConflicts(); +}); diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js index 1d5aec4001d..6c9afddefac 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js @@ -1,5 +1,6 @@ import Compare from '~/compare'; import MergeRequest from '~/merge_request'; +import initPipelines from '~/commit/pipelines/pipelines_bundle'; document.addEventListener('DOMContentLoaded', () => { const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare'); @@ -14,5 +15,6 @@ document.addEventListener('DOMContentLoaded', () => { new MergeRequest({ // eslint-disable-line no-new action: mrNewSubmitNode.dataset.mrSubmitAction, }); + initPipelines(); } }); diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js new file mode 100644 index 00000000000..da27c22f537 --- /dev/null +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -0,0 +1,33 @@ +import MergeRequest from '~/merge_request'; +import ZenMode from '~/zen_mode'; +import initNotes from '~/init_notes'; +import initIssuableSidebar from '~/init_issuable_sidebar'; +import initDiffNotes from '~/diff_notes/diff_notes_bundle'; +import ShortcutsIssuable from '~/shortcuts_issuable'; +import Diff from '~/diff'; +import { handleLocationHash } from '~/lib/utils/common_utils'; +import howToMerge from '~/how_to_merge'; +import initPipelines from '~/commit/pipelines/pipelines_bundle'; +import initWidget from '../../../vue_merge_request_widget'; + +export default function () { + new Diff(); // eslint-disable-line no-new + new ZenMode(); // eslint-disable-line no-new + + initIssuableSidebar(); + initNotes(); + initDiffNotes(); + initPipelines(); + + const mrShowNode = document.querySelector('.merge-request'); + + window.mergeRequest = new MergeRequest({ + action: mrShowNode.dataset.mrAction, + }); + + new ShortcutsIssuable(true); // eslint-disable-line no-new + handleLocationHash(); + howToMerge(); + initWidget(); +} + diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js index 07f3e579c97..3e72f7a6f37 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -1,28 +1,7 @@ -import MergeRequest from '~/merge_request'; -import ZenMode from '~/zen_mode'; -import initNotes from '~/init_notes'; -import initIssuableSidebar from '~/init_issuable_sidebar'; -import initDiffNotes from '~/diff_notes/diff_notes_bundle'; -import ShortcutsIssuable from '~/shortcuts_issuable'; -import Diff from '~/diff'; -import { handleLocationHash } from '~/lib/utils/common_utils'; -import howToMerge from '~/how_to_merge'; +import initSidebarBundle from '~/sidebar/sidebar_bundle'; +import initShow from '../init_merge_request_show'; document.addEventListener('DOMContentLoaded', () => { - new Diff(); // eslint-disable-line no-new - new ZenMode(); // eslint-disable-line no-new - - initIssuableSidebar(); - initNotes(); - initDiffNotes(); - - const mrShowNode = document.querySelector('.merge-request'); - - window.mergeRequest = new MergeRequest({ - action: mrShowNode.dataset.mrAction, - }); - - new ShortcutsIssuable(true); // eslint-disable-line no-new - handleLocationHash(); - howToMerge(); + initShow(); + initSidebarBundle(); }); diff --git a/app/assets/javascripts/network/network.js b/app/assets/javascripts/pages/projects/network/network.js index a3fd22aff2a..7354243e4c8 100644 --- a/app/assets/javascripts/network/network.js +++ b/app/assets/javascripts/pages/projects/network/network.js @@ -1,6 +1,6 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, quotes, quote-props, prefer-template, comma-dangle, max-len */ -import BranchGraph from './branch_graph'; +import BranchGraph from '../../../network/branch_graph'; export default (function() { function Network(opts) { diff --git a/app/assets/javascripts/pages/projects/network/show/index.js b/app/assets/javascripts/pages/projects/network/show/index.js new file mode 100644 index 00000000000..e7dfd2d0128 --- /dev/null +++ b/app/assets/javascripts/pages/projects/network/show/index.js @@ -0,0 +1,16 @@ +import ShortcutsNetwork from '../../../../shortcuts_network'; +import Network from '../network'; + +document.addEventListener('DOMContentLoaded', () => { + if (!$('.network-graph').length) return; + + const networkGraph = new Network({ + url: $('.network-graph').attr('data-url'), + commit_url: $('.network-graph').attr('data-commit-url'), + ref: $('.network-graph').attr('data-ref'), + commit_id: $('.network-graph').attr('data-commit-id'), + }); + + // eslint-disable-next-line no-new + new ShortcutsNetwork(networkGraph.branch_graph); +}); diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js index 71c49deb9d0..ea6fd961393 100644 --- a/app/assets/javascripts/pages/projects/new/index.js +++ b/app/assets/javascripts/pages/projects/new/index.js @@ -2,8 +2,8 @@ import ProjectNew from '../shared/project_new'; import initProjectVisibilitySelector from '../../../project_visibility'; import initProjectNew from '../../../projects/project_new'; -export default () => { +document.addEventListener('DOMContentLoaded', () => { new ProjectNew(); // eslint-disable-line no-new initProjectVisibilitySelector(); initProjectNew.bindEvents(); -}; +}); diff --git a/app/assets/javascripts/pages/projects/pipelines/builds/index.js b/app/assets/javascripts/pages/projects/pipelines/builds/index.js index fbe9824c34b..7a57e417b41 100644 --- a/app/assets/javascripts/pages/projects/pipelines/builds/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/builds/index.js @@ -1,3 +1,7 @@ +import initPipelineDetails from '~/pipelines/pipeline_details_bundle'; import initPipelines from '../init_pipelines'; -document.addEventListener('DOMContentLoaded', initPipelines); +document.addEventListener('DOMContentLoaded', () => { + initPipelines(); + initPipelineDetails(); +}); diff --git a/app/assets/javascripts/pipelines/pipelines_bundle.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js index ab5596e70f0..25dfa99ad9c 100644 --- a/app/assets/javascripts/pipelines/pipelines_bundle.js +++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; -import PipelinesStore from './stores/pipelines_store'; -import pipelinesComponent from './components/pipelines.vue'; -import Translate from '../vue_shared/translate'; +import PipelinesStore from '../../../../pipelines/stores/pipelines_store'; +import pipelinesComponent from '../../../../pipelines/components/pipelines.vue'; +import Translate from '../../../../vue_shared/translate'; Vue.use(Translate); diff --git a/app/assets/javascripts/pages/projects/pipelines/show/index.js b/app/assets/javascripts/pages/projects/pipelines/show/index.js index fbe9824c34b..7a57e417b41 100644 --- a/app/assets/javascripts/pages/projects/pipelines/show/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/show/index.js @@ -1,3 +1,7 @@ +import initPipelineDetails from '~/pipelines/pipeline_details_bundle'; import initPipelines from '../init_pipelines'; -document.addEventListener('DOMContentLoaded', initPipelines); +document.addEventListener('DOMContentLoaded', () => { + initPipelines(); + initPipelineDetails(); +}); diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js index 5a6f4138b10..001128ead59 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js @@ -1,7 +1,13 @@ +/* eslint-disable no-new */ + +import ProtectedTagCreate from '~/protected_tags/protected_tag_create'; +import ProtectedTagEditList from '~/protected_tags/protected_tag_edit_list'; import initSettingsPanels from '~/settings_panels'; import initDeployKeys from '~/deploy_keys'; document.addEventListener('DOMContentLoaded', () => { + new ProtectedTagCreate(); + new ProtectedTagEditList(); initDeployKeys(); initSettingsPanels(); }); diff --git a/app/assets/javascripts/pages/projects/snippets/edit/index.js b/app/assets/javascripts/pages/projects/snippets/edit/index.js index caf9ee9b398..c15f798b630 100644 --- a/app/assets/javascripts/pages/projects/snippets/edit/index.js +++ b/app/assets/javascripts/pages/projects/snippets/edit/index.js @@ -1,3 +1,7 @@ +import initSnippet from '~/snippet/snippet_bundle'; import initForm from '~/pages/projects/init_form'; -document.addEventListener('DOMContentLoaded', () => initForm($('.snippet-form'))); +document.addEventListener('DOMContentLoaded', () => { + initSnippet(); + initForm($('.snippet-form')); +}); diff --git a/app/assets/javascripts/pages/projects/snippets/new/index.js b/app/assets/javascripts/pages/projects/snippets/new/index.js index caf9ee9b398..c15f798b630 100644 --- a/app/assets/javascripts/pages/projects/snippets/new/index.js +++ b/app/assets/javascripts/pages/projects/snippets/new/index.js @@ -1,3 +1,7 @@ +import initSnippet from '~/snippet/snippet_bundle'; import initForm from '~/pages/projects/init_form'; -document.addEventListener('DOMContentLoaded', () => initForm($('.snippet-form'))); +document.addEventListener('DOMContentLoaded', () => { + initSnippet(); + initForm($('.snippet-form')); +}); diff --git a/app/assets/javascripts/pages/projects/wikis/index.js b/app/assets/javascripts/pages/projects/wikis/index.js index eb14c7a0e78..b9f8707fd6e 100644 --- a/app/assets/javascripts/pages/projects/wikis/index.js +++ b/app/assets/javascripts/pages/projects/wikis/index.js @@ -3,9 +3,9 @@ import ShortcutsWiki from '../../../shortcuts_wiki'; import ZenMode from '../../../zen_mode'; import GLForm from '../../../gl_form'; -export default () => { +document.addEventListener('DOMContentLoaded', () => { new Wikis(); // eslint-disable-line no-new new ShortcutsWiki(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new new GLForm($('.wiki-form'), true); // eslint-disable-line no-new -}; +}); diff --git a/app/assets/javascripts/pages/search/init_filtered_search.js b/app/assets/javascripts/pages/search/init_filtered_search.js index de8d4168d71..57f08701a4f 100644 --- a/app/assets/javascripts/pages/search/init_filtered_search.js +++ b/app/assets/javascripts/pages/search/init_filtered_search.js @@ -1,9 +1,21 @@ import FilteredSearchManager from '~/filtered_search/filtered_search_manager'; -export default ({ page }) => { +export default ({ + page, + filteredSearchTokenKeys, + isGroup, + isGroupAncestor, + stateFiltersSelector, +}) => { const filteredSearchEnabled = FilteredSearchManager && document.querySelector('.filtered-search'); if (filteredSearchEnabled) { - const filteredSearchManager = new FilteredSearchManager({ page }); + const filteredSearchManager = new FilteredSearchManager({ + page, + isGroup, + isGroupAncestor, + filteredSearchTokenKeys, + stateFiltersSelector, + }); filteredSearchManager.setup(); } }; diff --git a/app/assets/javascripts/pages/snippets/edit/index.js b/app/assets/javascripts/pages/snippets/edit/index.js index 2ee38b64ca1..d86e1632ae5 100644 --- a/app/assets/javascripts/pages/snippets/edit/index.js +++ b/app/assets/javascripts/pages/snippets/edit/index.js @@ -1,3 +1,7 @@ +import initSnippet from '~/snippet/snippet_bundle'; import form from '../form'; -document.addEventListener('DOMContentLoaded', form); +document.addEventListener('DOMContentLoaded', () => { + initSnippet(); + form(); +}); diff --git a/app/assets/javascripts/pages/snippets/new/index.js b/app/assets/javascripts/pages/snippets/new/index.js index 2ee38b64ca1..d86e1632ae5 100644 --- a/app/assets/javascripts/pages/snippets/new/index.js +++ b/app/assets/javascripts/pages/snippets/new/index.js @@ -1,3 +1,7 @@ +import initSnippet from '~/snippet/snippet_bundle'; import form from '../form'; -document.addEventListener('DOMContentLoaded', form); +document.addEventListener('DOMContentLoaded', () => { + initSnippet(); + form(); +}); diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue index 2ba59051773..4cbd67e0372 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue @@ -316,7 +316,7 @@ v-if="pipeline.flags.cancelable" :endpoint="pipeline.cancel_path" css-class="js-pipelines-cancel-button btn-remove" - title="Cancel" + title="Stop" icon="close" :pipeline-id="pipeline.id" data-toggle="modal" diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 705a60b3ba2..6b26708148c 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -9,7 +9,7 @@ import eventHub from './event_hub'; Vue.use(Translate); -document.addEventListener('DOMContentLoaded', () => { +export default () => { const dataset = document.querySelector('.js-pipeline-details-vue').dataset; const mediator = new PipelinesMediator({ endpoint: dataset.endpoint }); @@ -70,4 +70,4 @@ document.addEventListener('DOMContentLoaded', () => { }); }, }); -}); +}; diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js index e2285494e62..47736fc5f42 100644 --- a/app/assets/javascripts/pipelines/services/pipelines_service.js +++ b/app/assets/javascripts/pipelines/services/pipelines_service.js @@ -1,6 +1,7 @@ /* eslint-disable class-methods-use-this */ import Vue from 'vue'; import VueResource from 'vue-resource'; +import '../../vue_shared/vue_resource_interceptor'; Vue.use(VueResource); diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index 930f0fb381e..a811781853b 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -1,103 +1,85 @@ /* eslint-disable comma-dangle, no-unused-vars, class-methods-use-this, quotes, consistent-return, func-names, prefer-arrow-callback, space-before-function-paren, max-len */ import Cookies from 'js-cookie'; -import { getPagePath } from '~/lib/utils/common_utils'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import flash from '../flash'; -((global) => { - class Profile { - constructor({ form } = {}) { - this.onSubmitForm = this.onSubmitForm.bind(this); - this.form = form || $('.edit-user'); - this.newRepoActivated = Cookies.get('new_repo'); - this.setRepoRadio(); - this.bindEvents(); - this.initAvatarGlCrop(); - } - - initAvatarGlCrop() { - const cropOpts = { - filename: '.js-avatar-filename', - previewImage: '.avatar-image .avatar', - modalCrop: '.modal-profile-crop', - pickImageEl: '.js-choose-user-avatar-button', - uploadImageBtn: '.js-upload-user-avatar', - modalCropImg: '.modal-profile-crop-image' - }; - this.avatarGlCrop = $('.js-user-avatar-input').glCrop(cropOpts).data('glcrop'); - } +export default class Profile { + constructor({ form } = {}) { + this.onSubmitForm = this.onSubmitForm.bind(this); + this.form = form || $('.edit-user'); + this.newRepoActivated = Cookies.get('new_repo'); + this.setRepoRadio(); + this.bindEvents(); + this.initAvatarGlCrop(); + } - bindEvents() { - $('.js-preferences-form').on('change.preference', 'input[type=radio]', this.submitForm); - $('input[name="user[multi_file]"]').on('change', this.setNewRepoCookie); - $('#user_notification_email').on('change', this.submitForm); - $('#user_notified_of_own_activity').on('change', this.submitForm); - this.form.on('submit', this.onSubmitForm); - } + initAvatarGlCrop() { + const cropOpts = { + filename: '.js-avatar-filename', + previewImage: '.avatar-image .avatar', + modalCrop: '.modal-profile-crop', + pickImageEl: '.js-choose-user-avatar-button', + uploadImageBtn: '.js-upload-user-avatar', + modalCropImg: '.modal-profile-crop-image' + }; + this.avatarGlCrop = $('.js-user-avatar-input').glCrop(cropOpts).data('glcrop'); + } - submitForm() { - return $(this).parents('form').submit(); - } + bindEvents() { + $('.js-preferences-form').on('change.preference', 'input[type=radio]', this.submitForm); + $('input[name="user[multi_file]"]').on('change', this.setNewRepoCookie); + $('#user_notification_email').on('change', this.submitForm); + $('#user_notified_of_own_activity').on('change', this.submitForm); + this.form.on('submit', this.onSubmitForm); + } - onSubmitForm(e) { - e.preventDefault(); - return this.saveForm(); - } + submitForm() { + return $(this).parents('form').submit(); + } - saveForm() { - const self = this; - const formData = new FormData(this.form[0]); - const avatarBlob = this.avatarGlCrop.getBlob(); + onSubmitForm(e) { + e.preventDefault(); + return this.saveForm(); + } - if (avatarBlob != null) { - formData.append('user[avatar]', avatarBlob, 'avatar.png'); - } + saveForm() { + const self = this; + const formData = new FormData(this.form[0]); + const avatarBlob = this.avatarGlCrop.getBlob(); - axios({ - method: this.form.attr('method'), - url: this.form.attr('action'), - data: formData, - }) - .then(({ data }) => flash(data.message, 'notice')) - .then(() => { - window.scrollTo(0, 0); - // Enable submit button after requests ends - self.form.find(':input[disabled]').enable(); - }) - .catch(error => flash(error.message)); + if (avatarBlob != null) { + formData.append('user[avatar]', avatarBlob, 'avatar.png'); } - setNewRepoCookie() { - if (this.value === 'off') { - Cookies.remove('new_repo'); - } else { - Cookies.set('new_repo', true, { expires_in: 365 }); - } - } + axios({ + method: this.form.attr('method'), + url: this.form.attr('action'), + data: formData, + }) + .then(({ data }) => flash(data.message, 'notice')) + .then(() => { + window.scrollTo(0, 0); + // Enable submit button after requests ends + self.form.find(':input[disabled]').enable(); + }) + .catch(error => flash(error.message)); + } - setRepoRadio() { - const multiEditRadios = $('input[name="user[multi_file]"]'); - if (this.newRepoActivated || this.newRepoActivated === 'true') { - multiEditRadios.filter('[value=on]').prop('checked', true); - } else { - multiEditRadios.filter('[value=off]').prop('checked', true); - } + setNewRepoCookie() { + if (this.value === 'off') { + Cookies.remove('new_repo'); + } else { + Cookies.set('new_repo', true, { expires_in: 365 }); } } - $(function() { - $(document).on('input.ssh_key', '#key_key', function() { - const $title = $('#key_title'); - const comment = $(this).val().match(/^\S+ \S+ (.+)\n?$/); - - // Extract the SSH Key title from its comment - if (comment && comment.length > 1) { - return $title.val(comment[1]).change(); - } - }); - if (getPagePath() === 'profiles') { - return new Profile(); + setRepoRadio() { + const multiEditRadios = $('input[name="user[multi_file]"]'); + if (this.newRepoActivated || this.newRepoActivated === 'true') { + multiEditRadios.filter('[value=on]').prop('checked', true); + } else { + multiEditRadios.filter('[value=off]').prop('checked', true); } - }); -})(window.gl || (window.gl = {})); + } +} diff --git a/app/assets/javascripts/profile/profile_bundle.js b/app/assets/javascripts/profile/profile_bundle.js deleted file mode 100644 index ff35a9bcb83..00000000000 --- a/app/assets/javascripts/profile/profile_bundle.js +++ /dev/null @@ -1,2 +0,0 @@ -import './gl_crop'; -import './profile'; diff --git a/app/assets/javascripts/protected_tags/index.js b/app/assets/javascripts/protected_tags/index.js deleted file mode 100644 index b1618e24e49..00000000000 --- a/app/assets/javascripts/protected_tags/index.js +++ /dev/null @@ -1,9 +0,0 @@ -/* eslint-disable no-unused-vars */ - -import ProtectedTagCreate from './protected_tag_create'; -import ProtectedTagEditList from './protected_tag_edit_list'; - -$(() => { - const protectedtTagCreate = new ProtectedTagCreate(); - const protectedtTagEditList = new ProtectedTagEditList(); -}); diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js index 689befc742e..14545824e74 100644 --- a/app/assets/javascripts/shortcuts_issuable.js +++ b/app/assets/javascripts/shortcuts_issuable.js @@ -9,13 +9,12 @@ export default class ShortcutsIssuable extends Shortcuts { super(); this.$replyField = isMergeRequest ? $('.js-main-target-form #note_note') : $('.js-main-target-form .js-vue-comment-form'); - this.editBtn = document.querySelector('.js-issuable-edit'); Mousetrap.bind('a', () => ShortcutsIssuable.openSidebarDropdown('assignee')); Mousetrap.bind('m', () => ShortcutsIssuable.openSidebarDropdown('milestone')); Mousetrap.bind('l', () => ShortcutsIssuable.openSidebarDropdown('labels')); Mousetrap.bind('r', this.replyWithSelectedText.bind(this)); - Mousetrap.bind('e', this.editIssue.bind(this)); + Mousetrap.bind('e', ShortcutsIssuable.editIssue); if (isMergeRequest) { this.enabledHelp.push('.hidden-shortcut.merge_requests'); @@ -58,10 +57,10 @@ export default class ShortcutsIssuable extends Shortcuts { return false; } - editIssue() { + static editIssue() { // Need to click the element as on issues, editing is inline // on merge request, editing is on a different page - this.editBtn.click(); + document.querySelector('.js-issuable-edit').click(); return false; } diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.js b/app/assets/javascripts/sidebar/components/assignees/assignees.js deleted file mode 100644 index 643877b9d47..00000000000 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.js +++ /dev/null @@ -1,224 +0,0 @@ -export default { - name: 'Assignees', - data() { - return { - defaultRenderCount: 5, - defaultMaxCounter: 99, - showLess: true, - }; - }, - props: { - rootPath: { - type: String, - required: true, - }, - users: { - type: Array, - required: true, - }, - editable: { - type: Boolean, - required: true, - }, - }, - computed: { - firstUser() { - return this.users[0]; - }, - hasMoreThanTwoAssignees() { - return this.users.length > 2; - }, - hasMoreThanOneAssignee() { - return this.users.length > 1; - }, - hasAssignees() { - return this.users.length > 0; - }, - hasNoUsers() { - return !this.users.length; - }, - hasOneUser() { - return this.users.length === 1; - }, - renderShowMoreSection() { - return this.users.length > this.defaultRenderCount; - }, - numberOfHiddenAssignees() { - return this.users.length - this.defaultRenderCount; - }, - isHiddenAssignees() { - return this.numberOfHiddenAssignees > 0; - }, - hiddenAssigneesLabel() { - return `+ ${this.numberOfHiddenAssignees} more`; - }, - collapsedTooltipTitle() { - const maxRender = Math.min(this.defaultRenderCount, this.users.length); - const renderUsers = this.users.slice(0, maxRender); - const names = renderUsers.map(u => u.name); - - if (this.users.length > maxRender) { - names.push(`+ ${this.users.length - maxRender} more`); - } - - return names.join(', '); - }, - sidebarAvatarCounter() { - let counter = `+${this.users.length - 1}`; - - if (this.users.length > this.defaultMaxCounter) { - counter = `${this.defaultMaxCounter}+`; - } - - return counter; - }, - }, - methods: { - assignSelf() { - this.$emit('assign-self'); - }, - toggleShowLess() { - this.showLess = !this.showLess; - }, - renderAssignee(index) { - return !this.showLess || (index < this.defaultRenderCount && this.showLess); - }, - avatarUrl(user) { - return user.avatar || user.avatar_url || gon.default_avatar_url; - }, - assigneeUrl(user) { - return `${this.rootPath}${user.username}`; - }, - assigneeAlt(user) { - return `${user.name}'s avatar`; - }, - assigneeUsername(user) { - return `@${user.username}`; - }, - shouldRenderCollapsedAssignee(index) { - const firstTwo = this.users.length <= 2 && index <= 2; - - return index === 0 || firstTwo; - }, - }, - template: ` - <div> - <div - class="sidebar-collapsed-icon sidebar-collapsed-user" - :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }" - data-container="body" - data-placement="left" - :title="collapsedTooltipTitle" - > - <i - v-if="hasNoUsers" - aria-label="No Assignee" - class="fa fa-user" - /> - <button - type="button" - class="btn-link" - v-for="(user, index) in users" - v-if="shouldRenderCollapsedAssignee(index)" - > - <img - width="24" - class="avatar avatar-inline s24" - :alt="assigneeAlt(user)" - :src="avatarUrl(user)" - /> - <span class="author"> - {{ user.name }} - </span> - </button> - <button - v-if="hasMoreThanTwoAssignees" - class="btn-link" - type="button" - > - <span - class="avatar-counter sidebar-avatar-counter" - > - {{ sidebarAvatarCounter }} - </span> - </button> - </div> - <div class="value hide-collapsed"> - <template v-if="hasNoUsers"> - <span class="assign-yourself no-value"> - No assignee - <template v-if="editable"> - - - <button - type="button" - class="btn-link" - @click="assignSelf" - > - assign yourself - </button> - </template> - </span> - </template> - <template v-else-if="hasOneUser"> - <a - class="author_link bold" - :href="assigneeUrl(firstUser)" - > - <img - width="32" - class="avatar avatar-inline s32" - :alt="assigneeAlt(firstUser)" - :src="avatarUrl(firstUser)" - /> - <span class="author"> - {{ firstUser.name }} - </span> - <span class="username"> - {{ assigneeUsername(firstUser) }} - </span> - </a> - </template> - <template v-else> - <div class="user-list"> - <div - class="user-item" - v-for="(user, index) in users" - v-if="renderAssignee(index)" - > - <a - class="user-link has-tooltip" - data-placement="bottom" - :href="assigneeUrl(user)" - :data-title="user.name" - > - <img - width="32" - class="avatar avatar-inline s32" - :alt="assigneeAlt(user)" - :src="avatarUrl(user)" - /> - </a> - </div> - </div> - <div - v-if="renderShowMoreSection" - class="user-list-more" - > - <button - type="button" - class="btn-link" - @click="toggleShowLess" - > - <template v-if="showLess"> - {{ hiddenAssigneesLabel }} - </template> - <template v-else> - - show less - </template> - </button> - </div> - </template> - </div> - </div> - `, -}; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue new file mode 100644 index 00000000000..1e7f46454bf --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -0,0 +1,232 @@ +<script> +export default { + name: 'Assignees', + props: { + rootPath: { + type: String, + required: true, + }, + users: { + type: Array, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + }, + data() { + return { + defaultRenderCount: 5, + defaultMaxCounter: 99, + showLess: true, + }; + }, + computed: { + firstUser() { + return this.users[0]; + }, + hasMoreThanTwoAssignees() { + return this.users.length > 2; + }, + hasMoreThanOneAssignee() { + return this.users.length > 1; + }, + hasAssignees() { + return this.users.length > 0; + }, + hasNoUsers() { + return !this.users.length; + }, + hasOneUser() { + return this.users.length === 1; + }, + renderShowMoreSection() { + return this.users.length > this.defaultRenderCount; + }, + numberOfHiddenAssignees() { + return this.users.length - this.defaultRenderCount; + }, + isHiddenAssignees() { + return this.numberOfHiddenAssignees > 0; + }, + hiddenAssigneesLabel() { + return `+ ${this.numberOfHiddenAssignees} more`; + }, + collapsedTooltipTitle() { + const maxRender = Math.min(this.defaultRenderCount, this.users.length); + const renderUsers = this.users.slice(0, maxRender); + const names = renderUsers.map(u => u.name); + + if (this.users.length > maxRender) { + names.push(`+ ${this.users.length - maxRender} more`); + } + + return names.join(', '); + }, + sidebarAvatarCounter() { + let counter = `+${this.users.length - 1}`; + + if (this.users.length > this.defaultMaxCounter) { + counter = `${this.defaultMaxCounter}+`; + } + + return counter; + }, + }, + methods: { + assignSelf() { + this.$emit('assign-self'); + }, + toggleShowLess() { + this.showLess = !this.showLess; + }, + renderAssignee(index) { + return !this.showLess || (index < this.defaultRenderCount && this.showLess); + }, + avatarUrl(user) { + return user.avatar || user.avatar_url || gon.default_avatar_url; + }, + assigneeUrl(user) { + return `${this.rootPath}${user.username}`; + }, + assigneeAlt(user) { + return `${user.name}'s avatar`; + }, + assigneeUsername(user) { + return `@${user.username}`; + }, + shouldRenderCollapsedAssignee(index) { + const firstTwo = this.users.length <= 2 && index <= 2; + + return index === 0 || firstTwo; + }, + }, +}; +</script> + +<template> + <div> + <div + class="sidebar-collapsed-icon sidebar-collapsed-user" + :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }" + data-container="body" + data-placement="left" + :title="collapsedTooltipTitle" + > + <i + v-if="hasNoUsers" + aria-label="No Assignee" + class="fa fa-user" + > + </i> + <button + type="button" + class="btn-link" + v-for="(user, index) in users" + v-if="shouldRenderCollapsedAssignee(index)" + :key="user.id" + > + <img + width="24" + class="avatar avatar-inline s24" + :alt="assigneeAlt(user)" + :src="avatarUrl(user)" + /> + <span class="author"> + {{ user.name }} + </span> + </button> + <button + v-if="hasMoreThanTwoAssignees" + class="btn-link" + type="button" + > + <span + class="avatar-counter sidebar-avatar-counter" + > + {{ sidebarAvatarCounter }} + </span> + </button> + </div> + <div class="value hide-collapsed"> + <template v-if="hasNoUsers"> + <span class="assign-yourself no-value"> + No assignee + <template v-if="editable"> + - + <button + type="button" + class="btn-link" + @click="assignSelf" + > + assign yourself + </button> + </template> + </span> + </template> + <template v-else-if="hasOneUser"> + <a + class="author_link bold" + :href="assigneeUrl(firstUser)" + > + <img + width="32" + class="avatar avatar-inline s32" + :alt="assigneeAlt(firstUser)" + :src="avatarUrl(firstUser)" + /> + <span class="author"> + {{ firstUser.name }} + </span> + <span class="username"> + {{ assigneeUsername(firstUser) }} + </span> + </a> + </template> + <template v-else> + <div class="user-list"> + <div + class="user-item" + v-for="(user, index) in users" + v-if="renderAssignee(index)" + :key="user.id" + > + <a + class="user-link has-tooltip" + data-container="body" + data-placement="bottom" + :href="assigneeUrl(user)" + :data-title="user.name" + > + <img + width="32" + class="avatar avatar-inline s32" + :alt="assigneeAlt(user)" + :src="avatarUrl(user)" + /> + </a> + </div> + </div> + <div + v-if="renderShowMoreSection" + class="user-list-more" + > + <button + type="button" + class="btn-link" + @click="toggleShowLess" + > + <template v-if="showLess"> + {{ hiddenAssigneesLabel }} + </template> + <template v-else> + - show less + </template> + </button> + </div> + </template> + </div> + </div> +</template> + diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js index 9e47039d920..8269fe1281d 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js @@ -1,6 +1,6 @@ import Flash from '../../../flash'; import AssigneeTitle from './assignee_title'; -import Assignees from './assignees'; +import Assignees from './assignees.vue'; import Store from '../../stores/sidebar_store'; import eventHub from '../../event_hub'; @@ -28,8 +28,8 @@ export default { }, }, components: { - 'assignee-title': AssigneeTitle, - assignees: Assignees, + AssigneeTitle, + Assignees, }, methods: { assignSelf() { diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js index 04c39d7b6b5..377846db70e 100644 --- a/app/assets/javascripts/sidebar/sidebar_bundle.js +++ b/app/assets/javascripts/sidebar/sidebar_bundle.js @@ -1,13 +1,9 @@ import Mediator from './sidebar_mediator'; import { mountSidebar, getSidebarOptions } from './mount_sidebar'; -function domContentLoaded() { +export default () => { const mediator = new Mediator(getSidebarOptions()); mediator.fetch(); mountSidebar(mediator); -} - -document.addEventListener('DOMContentLoaded', domContentLoaded); - -export default domContentLoaded; +}; diff --git a/app/assets/javascripts/snippet/snippet_bundle.js b/app/assets/javascripts/snippet/snippet_bundle.js index a98403f4cf2..ce0fd3f6ff8 100644 --- a/app/assets/javascripts/snippet/snippet_bundle.js +++ b/app/assets/javascripts/snippet/snippet_bundle.js @@ -1,12 +1,9 @@ -/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, no-var, quotes, max-len */ /* global ace */ -(function() { - $(function() { - var editor = ace.edit("editor"); +export default () => { + const editor = ace.edit('editor'); - $(".snippet-form-holder form").on('submit', function() { - $(".snippet-file-content").val(editor.getValue()); - }); + $('.snippet-form-holder form').on('submit', () => { + $('.snippet-file-content').val(editor.getValue()); }); -}).call(window); +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 109a302a172..54a98abf860 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -1,8 +1,8 @@ <script> /* eslint-disable vue/require-default-prop */ - import pipelineStage from '../../pipelines/components/stage.vue'; - import ciIcon from '../../vue_shared/components/ci_icon.vue'; - import icon from '../../vue_shared/components/icon.vue'; + import pipelineStage from '~/pipelines/components/stage.vue'; + import ciIcon from '~/vue_shared/components/ci_icon.vue'; + import icon from '~/vue_shared/components/icon.vue'; export default { name: 'MRWidgetPipeline', diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index 6b9918b65b0..69a9132a2da 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -6,7 +6,7 @@ import Translate from '../vue_shared/translate'; Vue.use(Translate); -document.addEventListener('DOMContentLoaded', () => { +export default () => { gl.mrWidgetData.gitlabLogo = gon.gitlab_logo; const vm = new Vue(mrWidgetOptions); @@ -14,4 +14,4 @@ document.addEventListener('DOMContentLoaded', () => { window.gl.mrWidget = { checkStatus: vm.checkStatus, }; -}); +}; diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index 31d9b9d9c48..3b6c2da1664 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -1,8 +1,8 @@ <script> - import tooltip from '../directives/tooltip'; /** * Falls back to the code used in `copy_to_clipboard.js` */ + import tooltip from '../directives/tooltip'; export default { name: 'ClipboardButton', @@ -28,6 +28,11 @@ required: false, default: false, }, + cssClass: { + type: String, + required: false, + default: 'btn btn-default btn-transparent btn-clipboard', + }, }, }; </script> @@ -35,7 +40,7 @@ <template> <button type="button" - class="btn btn-transparent btn-clipboard" + :class="cssClass" :title="title" :data-clipboard-text="text" v-tooltip diff --git a/app/assets/javascripts/vue_shared/components/expand_button.vue b/app/assets/javascripts/vue_shared/components/expand_button.vue index 3595a9389e9..c943c8d98a4 100644 --- a/app/assets/javascripts/vue_shared/components/expand_button.vue +++ b/app/assets/javascripts/vue_shared/components/expand_button.vue @@ -39,7 +39,7 @@ @click="onClick"> ... </button> - <span v-show="!isCollapsed"> + <span v-if="!isCollapsed"> <slot name="expanded"></slot> </span> </span> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue index b48828ae81f..3d39b3ab173 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue @@ -11,14 +11,12 @@ default: false, required: false, }, - isConfidential: { type: Boolean, default: false, required: false, }, }, - computed: { warningIcon() { if (this.isConfidential) return 'eye-slash'; @@ -26,7 +24,6 @@ return ''; }, - isLockedAndConfidential() { return this.isConfidential && this.isLocked; }, diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue new file mode 100644 index 00000000000..80e3db52cb0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue @@ -0,0 +1,24 @@ +<template> + <li class="timeline-entry note"> + <div class="timeline-entry-inner"> + <div class="timeline-icon"> + </div> + <div class="timeline-content"> + <div class="note-header"></div> + <div class="note-body"> + <skeleton-loading-container /> + </div> + </div> + </div> + </li> +</template> + +<script> + import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; + + export default { + components: { + skeletonLoadingContainer, + }, + }; +</script> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue index 1413dd69f24..3fcacd156c5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue @@ -14,6 +14,11 @@ collapsedCalendarIcon, }, props: { + blockClass: { + type: String, + required: false, + default: '', + }, collapsed: { type: Boolean, required: false, @@ -91,7 +96,10 @@ </script> <template> - <div class="block"> + <div + class="block" + :class="blockClass" + > <div class="issuable-sidebar-header"> <toggle-sidebar :collapsed="collapsed" diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 17801ed5910..8b680c2dc52 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -196,17 +196,9 @@ @media (min-width: $screen-sm-min) { font-size: 0; - div { - display: inline; - } - .fa-spinner { font-size: 12px; } - - span { - font-size: 6px; - } } .ci-status-link { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 0cf67734237..4c9732c26d9 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -103,6 +103,7 @@ .issuable-show-labels { a { margin-bottom: 5px; + margin-right: 5px; display: inline-block; .color-label { @@ -116,6 +117,12 @@ } &.has-labels { + // this font size is a fix to + // prevent unintended spacing between labels + // which shows up when rendering markup has white-space + // characters present. + // see: https://css-tricks.com/fighting-the-space-between-inline-block-elements/#article-header-id-3 + font-size: 0; margin-bottom: -5px; } } diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 6763af4e98b..b9390450477 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -13,10 +13,20 @@ display: inline-block; } + .issuable-meta { + .author_link { + display: inline-block; + } + + .issuable-comments { + height: 18px; + } + } + .icon-merge-request-unmerged { height: 13px; margin-bottom: 3px; - } + } } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 26e6e8688b6..3c565837383 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -723,7 +723,7 @@ ul.notes { .line-resolve-all { vertical-align: middle; display: inline-block; - padding: 5px 10px 6px; + padding: 6px 10px; background-color: $gray-light; border: 1px solid $border-color; border-radius: $border-radius-default; diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index a94726887d9..cc38608eda5 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -48,7 +48,7 @@ class Admin::GroupsController < Admin::ApplicationController def members_update member_params = params.permit(:user_ids, :access_level, :expires_at) - result = Members::CreateService.new(@group, current_user, member_params.merge(limit: -1)).execute + result = Members::CreateService.new(current_user, member_params.merge(limit: -1)).execute(@group) if result[:status] == :success redirect_to [:admin, @group], notice: 'Users were successfully added.' diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 337957c366d..a21e658fda1 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -77,6 +77,20 @@ module IssuableActions render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" } end + def discussions + notes = issuable.notes + .inc_relations_for_view + .includes(:noteable) + .fresh + + notes = prepare_notes_for_rendering(notes) + notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } + + discussions = Discussion.build_collection(notes, issuable) + + render json: DiscussionSerializer.new(project: project, noteable: issuable, current_user: current_user).represent(discussions, context: self) + end + private def recaptcha_check_if_spammable(should_redirect = true, &block) diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index c6b1e443de6..7a6a00b8e13 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -3,20 +3,31 @@ module MembershipActions def create create_params = params.permit(:user_ids, :access_level, :expires_at) - result = Members::CreateService.new(membershipable, current_user, create_params).execute - - redirect_url = members_page_url + result = Members::CreateService.new(current_user, create_params).execute(membershipable) if result[:status] == :success - redirect_to redirect_url, notice: 'Users were successfully added.' + redirect_to members_page_url, notice: 'Users were successfully added.' else - redirect_to redirect_url, alert: result[:message] + redirect_to members_page_url, alert: result[:message] + end + end + + def update + update_params = params.require(root_params_key).permit(:access_level, :expires_at) + member = membershipable.members_and_requesters.find(params[:id]) + member = Members::UpdateService + .new(current_user, update_params) + .execute(member) + .present(current_user: current_user) + + respond_to do |format| + format.js { render 'shared/members/update', locals: { member: member } } end end def destroy - Members::DestroyService.new(membershipable, current_user, params) - .execute(:all) + member = membershipable.members_and_requesters.find(params[:id]) + Members::DestroyService.new(current_user).execute(member) respond_to do |format| format.html do @@ -36,14 +47,17 @@ module MembershipActions end def approve_access_request - Members::ApproveAccessRequestService.new(membershipable, current_user, params).execute + access_requester = membershipable.requesters.find(params[:id]) + Members::ApproveAccessRequestService + .new(current_user, params) + .execute(access_requester) redirect_to members_page_url end def leave - member = Members::DestroyService.new(membershipable, current_user, user_id: current_user.id) - .execute(:all) + member = membershipable.members_and_requesters.find_by!(user_id: current_user.id) + Members::DestroyService.new(current_user).execute(member) notice = if member.request? @@ -62,17 +76,43 @@ module MembershipActions end end + def resend_invite + member = membershipable.members.find(params[:id]) + + if member.invite? + member.resend_invite + + redirect_to members_page_url, notice: 'The invitation was successfully resent.' + else + redirect_to members_page_url, alert: 'The invitation has already been accepted.' + end + end + protected def membershipable raise NotImplementedError end + def root_params_key + case membershipable + when Namespace + :group_member + when Project + :project_member + else + raise "Unknown membershipable type: #{membershipable}!" + end + end + def members_page_url - if membershipable.is_a?(Project) + case membershipable + when Namespace + polymorphic_url([membershipable, :members]) + when Project project_project_members_path(membershipable) else - polymorphic_url([membershipable, :members]) + raise "Unknown membershipable type: #{membershipable}!" end end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index e82a5650935..03ed5b5310b 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -22,7 +22,7 @@ module NotesActions notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } notes_json[:notes] = - if noteable.discussions_rendered_on_frontend? + if use_note_serializer? note_serializer.represent(notes) else notes.map { |note| note_json(note) } @@ -95,7 +95,7 @@ module NotesActions if note.persisted? attrs[:valid] = true - if noteable.discussions_rendered_on_frontend? + if use_note_serializer? attrs.merge!(note_serializer.represent(note)) else attrs.merge!( @@ -233,4 +233,14 @@ module NotesActions the_project end end + + def use_note_serializer? + return false if params['html'] + + if noteable.is_a?(MergeRequest) + cookies[:vue_mr_discussions] == 'true' + else + noteable.discussions_rendered_on_frontend? + end + end end diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb index 4a2bfc1f887..9f3bb60b4cc 100644 --- a/app/controllers/groups/application_controller.rb +++ b/app/controllers/groups/application_controller.rb @@ -18,10 +18,6 @@ class Groups::ApplicationController < ApplicationController @projects ||= GroupProjectsFinder.new(group: group, current_user: current_user).execute end - def group_merge_requests - @group_merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id).execute - end - def authorize_admin_group! unless can?(current_user, :admin_group, group) return render_404 diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 2c371e76313..f210434b2d7 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -27,35 +27,6 @@ class Groups::GroupMembersController < Groups::ApplicationController @group_member = @group.group_members.new end - def update - @group_member = @group.members_and_requesters.find(params[:id]) - .present(current_user: current_user) - - return render_403 unless can?(current_user, :update_group_member, @group_member) - - @group_member.update_attributes(member_params) - end - - def resend_invite - redirect_path = group_group_members_path(@group) - - @group_member = @group.group_members.find(params[:id]) - - if @group_member.invite? - @group_member.resend_invite - - redirect_to redirect_path, notice: 'The invitation was successfully resent.' - else - redirect_to redirect_path, alert: 'The invitation has already been accepted.' - end - end - - protected - - def member_params - params.require(:group_member).permit(:access_level, :user_id, :expires_at) - end - # MembershipActions concern alias_method :membershipable, :group end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 14b9d6c22bd..283c3e5f1e0 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -14,7 +14,6 @@ class GroupsController < Groups::ApplicationController before_action :authorize_create_group!, only: [:new] before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests] - before_action :group_merge_requests, only: [:merge_requests] before_action :event_filter, only: [:activity] before_action :user_actions, only: [:show, :subgroups] diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb index fa72f67c77e..b8ccc6e3c99 100644 --- a/app/controllers/profiles/passwords_controller.rb +++ b/app/controllers/profiles/passwords_controller.rb @@ -1,5 +1,6 @@ class Profiles::PasswordsController < Profiles::ApplicationController skip_before_action :check_password_expiration, only: [:new, :create] + skip_before_action :check_two_factor_requirement, only: [:new, :create] before_action :set_user before_action :authorize_change_password! diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb index 142e8b6e4bc..aeaba3a0acf 100644 --- a/app/controllers/projects/clusters_controller.rb +++ b/app/controllers/projects/clusters_controller.rb @@ -4,6 +4,7 @@ class Projects::ClustersController < Projects::ApplicationController before_action :authorize_create_cluster!, only: [:new] before_action :authorize_update_cluster!, only: [:update] before_action :authorize_admin_cluster!, only: [:destroy] + before_action :update_applications_status, only: [:status] STATUS_POLLING_INTERVAL = 10_000 @@ -114,4 +115,8 @@ class Projects::ClustersController < Projects::ApplicationController def authorize_admin_cluster! access_denied! unless can?(current_user, :admin_cluster, cluster) end + + def update_applications_status + @cluster.applications.each(&:schedule_status_update) + end end diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb index 2e6ab7903b8..ee507009e50 100644 --- a/app/controllers/projects/discussions_controller.rb +++ b/app/controllers/projects/discussions_controller.rb @@ -1,4 +1,7 @@ class Projects::DiscussionsController < Projects::ApplicationController + include NotesHelper + include RendersNotes + before_action :check_merge_requests_available! before_action :merge_request before_action :discussion @@ -7,22 +10,45 @@ class Projects::DiscussionsController < Projects::ApplicationController def resolve Discussions::ResolveService.new(project, current_user, merge_request: merge_request).execute(discussion) - render json: { - resolved_by: discussion.resolved_by.try(:name), - discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion) - } + render_discussion end def unresolve discussion.unresolve! + render_discussion + end + + private + + def render_discussion + if serialize_notes? + # TODO - It is not needed to serialize notes when resolving + # or unresolving discussions. We should remove this behavior + # passing a parameter to DiscussionEntity to return an empty array + # for notes. + # Check issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/42853 + prepare_notes_for_rendering(discussion.notes, merge_request) + render_json_with_discussions_serializer + else + render_json_with_html + end + end + + def render_json_with_discussions_serializer + render json: + DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user) + .represent(discussion, context: self) + end + + # Legacy method used to render discussions notes when not using Vue on views. + def render_json_with_html render json: { + resolved_by: discussion.resolved_by.try(:name), discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion) } end - private - def merge_request @merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).find_by!(iid: params[:merge_request_id]) end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 73806454525..b14939c4216 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -60,20 +60,6 @@ class Projects::IssuesController < Projects::ApplicationController respond_with(@issue) end - def discussions - notes = @issue.notes - .inc_relations_for_view - .includes(:noteable) - .fresh - - notes = prepare_notes_for_rendering(notes) - notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } - - discussions = Discussion.build_collection(notes, @issue) - - render json: DiscussionSerializer.new(project: @project, noteable: @issue, current_user: current_user).represent(discussions) - end - def create create_params = issue_params.merge(spammable_params).merge( merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 4f8978c93c3..dd41b9648e8 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -1,5 +1,6 @@ class Projects::NotesController < Projects::ApplicationController include NotesActions + include NotesHelper include ToggleAwardEmoji before_action :whitelist_query_limiting, only: [:create] @@ -38,10 +39,14 @@ class Projects::NotesController < Projects::ApplicationController discussion = note.discussion - render json: { - resolved_by: note.resolved_by.try(:name), - discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion) - } + if serialize_notes? + render_json_with_notes_serializer + else + render json: { + resolved_by: note.resolved_by.try(:name), + discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion) + } + end end def unresolve @@ -51,16 +56,27 @@ class Projects::NotesController < Projects::ApplicationController discussion = note.discussion - render json: { - discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion) - } + if serialize_notes? + render_json_with_notes_serializer + else + render json: { + discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion) + } + end end private + def render_json_with_notes_serializer + Notes::RenderService.new(current_user).execute([note], project) + + render json: note_serializer.represent(note) + end + def note @note ||= @project.notes.find(params[:id]) end + alias_method :awardable, :note def finder_params diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb index b71f1e5fef4..4856be61e88 100644 --- a/app/controllers/projects/pages_domains_controller.rb +++ b/app/controllers/projects/pages_domains_controller.rb @@ -3,7 +3,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController before_action :require_pages_enabled! before_action :authorize_update_pages!, except: [:show] - before_action :domain, only: [:show, :destroy, :verify] + before_action :domain, except: [:new, :create] def show end @@ -24,8 +24,11 @@ class Projects::PagesDomainsController < Projects::ApplicationController redirect_to project_pages_domain_path(@project, @domain) end + def edit + end + def create - @domain = @project.pages_domains.create(pages_domain_params) + @domain = @project.pages_domains.create(create_params) if @domain.valid? redirect_to project_pages_domain_path(@project, @domain) @@ -34,6 +37,16 @@ class Projects::PagesDomainsController < Projects::ApplicationController end end + def update + if @domain.update(update_params) + redirect_to project_pages_path(@project), + status: 302, + notice: 'Domain was updated' + else + render 'edit' + end + end + def destroy @domain.destroy @@ -49,12 +62,12 @@ class Projects::PagesDomainsController < Projects::ApplicationController private - def pages_domain_params - params.require(:pages_domain).permit( - :certificate, - :key, - :domain - ) + def create_params + params.require(:pages_domain).permit(:key, :certificate, :domain) + end + + def update_params + params.require(:pages_domain).permit(:key, :certificate) end def domain diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index d7372beb9d3..e9b4679f94c 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -26,29 +26,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController @project_member = @project.project_members.new end - def update - @project_member = @project.members_and_requesters.find(params[:id]) - .present(current_user: current_user) - - return render_403 unless can?(current_user, :update_project_member, @project_member) - - @project_member.update_attributes(member_params) - end - - def resend_invite - redirect_path = project_project_members_path(@project) - - @project_member = @project.project_members.find(params[:id]) - - if @project_member.invite? - @project_member.resend_invite - - redirect_to redirect_path, notice: 'The invitation was successfully resent.' - else - redirect_to redirect_path, alert: 'The invitation has already been accepted.' - end - end - def import @projects = current_user.authorized_projects.order_id_desc end @@ -67,12 +44,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController notice: notice) end - protected - - def member_params - params.require(:project_member).permit(:user_id, :access_level, :expires_at) - end - # MembershipActions concern alias_method :membershipable, :project end diff --git a/app/finders/labels_finder.rb b/app/finders/labels_finder.rb index f013e177c5b..5c9fce211ec 100644 --- a/app/finders/labels_finder.rb +++ b/app/finders/labels_finder.rb @@ -39,7 +39,7 @@ class LabelsFinder < UnionFinder end end elsif only_group_labels? - label_ids << Label.where(group_id: group.id) + label_ids << Label.where(group_id: group_ids) else label_ids << Label.where(group_id: projects.group_ids) label_ids << Label.where(project_id: projects.select(:id)) @@ -59,10 +59,11 @@ class LabelsFinder < UnionFinder items.where(title: title) end - def group - strong_memoize(:group) do + def group_ids + strong_memoize(:group_ids) do group = Group.find(params[:group_id]) - authorized_to_read_labels?(group) && group + groups = params[:include_ancestor_groups].present? ? group.self_and_ancestors : [group] + groups_user_can_read_labels(groups).map(&:id) end end @@ -120,4 +121,10 @@ class LabelsFinder < UnionFinder Ability.allowed?(current_user, :read_label, label_parent) end + + def groups_user_can_read_labels(groups) + DeclarativePolicy.user_scope do + groups.select { |group| authorized_to_read_labels?(group) } + end + end end diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index d0687d28c21..068ae7f8c89 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -17,14 +17,42 @@ # sort: string # non_archived: boolean # my_reaction_emoji: string +# source_branch: string +# target_branch: string # class MergeRequestsFinder < IssuableFinder def klass MergeRequest end + def filter_items(_items) + items = by_source_branch(super) + + by_target_branch(items) + end + private + def source_branch + @source_branch ||= params[:source_branch].presence + end + + def by_source_branch(items) + return items unless source_branch + + items.where(source_branch: source_branch) + end + + def target_branch + @target_branch ||= params[:target_branch].presence + end + + def by_target_branch(items) + return items unless target_branch + + items.where(target_branch: target_branch) + end + def item_project_ids(items) items&.reorder(nil)&.select(:target_project_id) end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index a6e1de6ffdc..0e806d16bc5 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -12,75 +12,42 @@ module BlobHelper def edit_blob_path(project = @project, ref = @ref, path = @path, options = {}) project_edit_blob_path(project, - tree_join(ref, path), - options[:link_opts]) - end - - def edit_blob_link(project = @project, ref = @ref, path = @path, options = {}) - blob = options.delete(:blob) - blob ||= project.repository.blob_at(ref, path) rescue nil - - return unless blob && blob.readable_text? - - common_classes = "btn js-edit-blob #{options[:extra_class]}" - - if !on_top_of_branch?(project, ref) - button_tag 'Edit', class: "#{common_classes} disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' } - # This condition applies to anonymous or users who can edit directly - elsif !current_user || (current_user && can_modify_blob?(blob, project, ref)) - link_to 'Edit', edit_blob_path(project, ref, path, options), class: "#{common_classes} btn-sm" - elsif current_user && can?(current_user, :fork_project, project) - continue_params = { - to: edit_blob_path(project, ref, path, options), - notice: edit_in_new_fork_notice, - notice_now: edit_in_new_fork_notice_now - } - fork_path = project_forks_path(project, namespace_key: current_user.namespace.id, continue: continue_params) - - button_tag 'Edit', - class: "#{common_classes} js-edit-blob-link-fork-toggler", - data: { action: 'edit', fork_path: fork_path } - end + tree_join(ref, path), + options[:link_opts]) end def ide_edit_path(project = @project, ref = @ref, path = @path, options = {}) "#{ide_path}/project#{edit_blob_path(project, ref, path, options)}" end - def ide_edit_text - "#{_('Web IDE')}" - end + def edit_blob_button(project = @project, ref = @ref, path = @path, options = {}) + return unless blob = readable_blob(options, path, project, ref) - def ide_blob_link(project = @project, ref = @ref, path = @path, options = {}) - return unless show_new_ide? + common_classes = "btn js-edit-blob #{options[:extra_class]}" - blob = options.delete(:blob) - blob ||= project.repository.blob_at(ref, path) rescue nil + edit_button_tag(blob, + common_classes, + _('Edit'), + edit_blob_path(project, ref, path, options), + project, + ref) + end - return unless blob && blob.readable_text? + def ide_edit_button(project = @project, ref = @ref, path = @path, options = {}) + return unless show_new_ide? + return unless blob = readable_blob(options, path, project, ref) common_classes = "btn js-edit-ide #{options[:extra_class]}" - if !on_top_of_branch?(project, ref) - button_tag ide_edit_text, class: "#{common_classes} disabled has-tooltip", title: _('You can only edit files when you are on a branch'), data: { container: 'body' } - # This condition applies to anonymous or users who can edit directly - elsif current_user && can_modify_blob?(blob, project, ref) - link_to ide_edit_text, ide_edit_path(project, ref, path, options), class: "#{common_classes} btn-sm" - elsif current_user && can?(current_user, :fork_project, project) - continue_params = { - to: ide_edit_path(project, ref, path, options), - notice: edit_in_new_fork_notice, - notice_now: edit_in_new_fork_notice_now - } - fork_path = project_forks_path(project, namespace_key: current_user.namespace.id, continue: continue_params) - - button_tag ide_edit_text, - class: common_classes, - data: { fork_path: fork_path } - end + edit_button_tag(blob, + common_classes, + _('Web IDE'), + ide_edit_path(project, ref, path, options), + project, + ref) end - def modify_file_link(project = @project, ref = @ref, path = @path, label:, action:, btn_class:, modal_type:) + def modify_file_button(project = @project, ref = @ref, path = @path, label:, action:, btn_class:, modal_type:) return unless current_user blob = project.repository.blob_at(ref, path) rescue nil @@ -96,21 +63,12 @@ module BlobHelper elsif can_modify_blob?(blob, project, ref) button_tag label, class: "#{common_classes}", 'data-target' => "#modal-#{modal_type}-blob", 'data-toggle' => 'modal' elsif can?(current_user, :fork_project, project) - continue_params = { - to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to #{action} this file again.", - notice_now: edit_in_new_fork_notice_now - } - fork_path = project_forks_path(project, namespace_key: current_user.namespace.id, continue: continue_params) - - button_tag label, - class: "#{common_classes} js-edit-blob-link-fork-toggler", - data: { action: action, fork_path: fork_path } + edit_fork_button_tag(common_classes, project, label, edit_modify_file_fork_params(action), action) end end def replace_blob_link(project = @project, ref = @ref, path = @path) - modify_file_link( + modify_file_button( project, ref, path, @@ -122,7 +80,7 @@ module BlobHelper end def delete_blob_link(project = @project, ref = @ref, path = @path) - modify_file_link( + modify_file_button( project, ref, path, @@ -332,4 +290,55 @@ module BlobHelper options end + + def readable_blob(options, path, project, ref) + blob = options.delete(:blob) + blob ||= project.repository.blob_at(ref, path) rescue nil + + blob if blob&.readable_text? + end + + def edit_blob_fork_params(path) + { + to: path, + notice: edit_in_new_fork_notice, + notice_now: edit_in_new_fork_notice_now + } + end + + def edit_modify_file_fork_params(action) + { + to: request.fullpath, + notice: edit_in_new_fork_notice_action(action), + notice_now: edit_in_new_fork_notice_now + } + end + + def edit_fork_button_tag(common_classes, project, label, params, action = 'edit') + fork_path = project_forks_path(project, namespace_key: current_user.namespace.id, continue: params) + + button_tag label, + class: "#{common_classes} js-edit-blob-link-fork-toggler", + data: { action: action, fork_path: fork_path } + end + + def edit_disabled_button_tag(button_text, common_classes) + button_tag(button_text, class: "#{common_classes} disabled has-tooltip", title: _('You can only edit files when you are on a branch'), data: { container: 'body' }) + end + + def edit_link_tag(link_text, edit_path, common_classes) + link_to link_text, edit_path, class: "#{common_classes} btn-sm" + end + + def edit_button_tag(blob, common_classes, text, edit_path, project, ref) + if !on_top_of_branch?(project, ref) + edit_disabled_button_tag(text, common_classes) + # This condition only applies to users who are logged in + # Web IDE (Beta) requires the user to have this feature enabled + elsif !current_user || (current_user && can_modify_blob?(blob, project, ref)) + edit_link_tag(text, edit_path, common_classes) + elsif current_user && can?(current_user, :fork_project, project) + edit_fork_button_tag(common_classes, project, text, edit_blob_fork_params(edit_path)) + end + end end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 5fbaa17c40e..7910de73c52 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -19,6 +19,20 @@ module GroupsHelper can?(current_user, :change_share_with_group_lock, group) end + def group_issues_count(state:) + IssuesFinder + .new(current_user, group_id: @group.id, state: state, non_archived: true, include_subgroups: true) + .execute + .count + end + + def group_merge_requests_count(state:) + MergeRequestsFinder + .new(current_user, group_id: @group.id, state: state, non_archived: true, include_subgroups: true) + .execute + .count + end + def group_icon(group, options = {}) img_path = group_icon_url(group, options) image_tag img_path, options @@ -77,10 +91,6 @@ module GroupsHelper end end - def group_issues(group) - IssuesFinder.new(current_user, group_id: group.id).execute - end - def remove_group_message(group) _("You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?") % { group_name: group.name } diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index c219aa3d6a9..e86e43b5ebf 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -151,7 +151,38 @@ module NotesHelper } end + def notes_data(issuable) + discussions_path = + if issuable.is_a?(Issue) + discussions_project_issue_path(@project, issuable, format: :json) + else + discussions_project_merge_request_path(@project, issuable, format: :json) + end + + { + discussionsPath: discussions_path, + registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), + newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'), + markdownDocsPath: help_page_path('user/markdown'), + quickActionsDocsPath: help_page_path('user/project/quick_actions'), + closePath: close_issuable_path(issuable), + reopenPath: reopen_issuable_path(issuable), + notesPath: notes_url, + totalNotes: issuable.discussions.length, + lastFetchedAt: Time.now + + }.to_json + end + def discussion_resolved_intro(discussion) discussion.resolved_by_push? ? 'Automatically resolved' : 'Resolved' end + + def has_vue_discussions_cookie? + cookies[:vue_mr_discussions] == 'true' + end + + def serialize_notes? + has_vue_discussions_cookie? && !params['html'] + end end diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index f5733b4b57c..f6a6d9bebde 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -83,6 +83,10 @@ module TreeHelper " A fork of this project has been created that you can make changes in, so you can submit a merge request." end + def edit_in_new_fork_notice_action(action) + edit_in_new_fork_notice + " Try to #{action} this file again." + end + def commit_in_fork_help "A new branch will be created in your fork and a new merge request will be started." end diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb index f321db75eeb..fbd0f123341 100644 --- a/app/models/chat_name.rb +++ b/app/models/chat_name.rb @@ -1,4 +1,6 @@ class ChatName < ActiveRecord::Base + LAST_USED_AT_INTERVAL = 1.hour + belongs_to :service belongs_to :user @@ -9,4 +11,23 @@ class ChatName < ActiveRecord::Base validates :user_id, uniqueness: { scope: [:service_id] } validates :chat_id, uniqueness: { scope: [:service_id, :team_id] } + + # Updates the "last_used_timestamp" but only if it wasn't already updated + # recently. + # + # The throttling this method uses is put in place to ensure that high chat + # traffic doesn't result in many UPDATE queries being performed. + def update_last_used_at + return unless update_last_used_at? + + obtained = Gitlab::ExclusiveLease + .new("chat_name/last_used_at/#{id}", timeout: LAST_USED_AT_INTERVAL.to_i) + .try_obtain + + touch(:last_used_at) if obtained + end + + def update_last_used_at? + last_used_at.nil? || last_used_at > LAST_USED_AT_INTERVAL.ago + end end diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb index afeae69ba39..1dd0e050ba9 100644 --- a/app/models/ci/group_variable.rb +++ b/app/models/ci/group_variable.rb @@ -6,7 +6,10 @@ module Ci belongs_to :group - validates :key, uniqueness: { scope: :group_id } + validates :key, uniqueness: { + scope: :group_id, + message: "(%{value}) has already been taken" + } scope :unprotected, -> { where(protected: false) } end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 13c784bea0d..609620a62bb 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -49,7 +49,7 @@ module Ci ref_protected: 1 } - cached_attr_reader :version, :revision, :platform, :architecture, :contacted_at + cached_attr_reader :version, :revision, :platform, :architecture, :contacted_at, :ip_address # Searches for runners matching the given query. # @@ -157,7 +157,7 @@ module Ci end def update_cached_info(values) - values = values&.slice(:version, :revision, :platform, :architecture) || {} + values = values&.slice(:version, :revision, :platform, :architecture, :ip_address) || {} values[:contacted_at] = Time.now cache_attributes(values) diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index 67d3ec81b6f..7c71291de84 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -6,7 +6,10 @@ module Ci belongs_to :project - validates :key, uniqueness: { scope: [:project_id, :environment_scope] } + validates :key, uniqueness: { + scope: [:project_id, :environment_scope], + message: "(%{value}) has already been taken" + } scope :unprotected, -> { where(protected: false) } end diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index aa5cf97756f..9f583342c19 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -5,6 +5,7 @@ module Clusters include ::Clusters::Concerns::ApplicationCore include ::Clusters::Concerns::ApplicationStatus + include AfterCommitQueue default_value_for :ingress_type, :nginx default_value_for :version, :nginx @@ -13,6 +14,17 @@ module Clusters nginx: 1 } + FETCH_IP_ADDRESS_DELAY = 30.seconds + + state_machine :status do + before_transition any => [:installed] do |application| + application.run_after_commit do + ClusterWaitForIngressIpAddressWorker.perform_in( + FETCH_IP_ADDRESS_DELAY, application.name, application.id) + end + end + end + def chart 'stable/nginx-ingress' end @@ -24,6 +36,13 @@ module Clusters def install_command Gitlab::Kubernetes::Helm::InstallCommand.new(name, chart: chart, chart_values_file: chart_values_file) end + + def schedule_status_update + return unless installed? + return if external_ip + + ClusterWaitForIngressIpAddressWorker.perform_async(name, id) + end end end end diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb index a98fa85a5ff..623b836c0ed 100644 --- a/app/models/clusters/concerns/application_core.rb +++ b/app/models/clusters/concerns/application_core.rb @@ -23,6 +23,11 @@ module Clusters def name self.class.application_name end + + def schedule_status_update + # Override if you need extra data synchronized + # from K8s after installation + end end end end diff --git a/app/models/concerns/access_requestable.rb b/app/models/concerns/access_requestable.rb index 62bc6b809f4..d502e7e54c6 100644 --- a/app/models/concerns/access_requestable.rb +++ b/app/models/concerns/access_requestable.rb @@ -8,6 +8,6 @@ module AccessRequestable extend ActiveSupport::Concern def request_access(user) - Members::RequestAccessService.new(self, user).execute + Members::RequestAccessService.new(user).execute(self) end end diff --git a/app/models/cycle_analytics.rb b/app/models/cycle_analytics.rb index d2e626c22e8..b34d1382d43 100644 --- a/app/models/cycle_analytics.rb +++ b/app/models/cycle_analytics.rb @@ -6,6 +6,12 @@ class CycleAnalytics @options = options end + def all_medians_per_stage + STAGES.each_with_object({}) do |stage_name, medians_per_stage| + medians_per_stage[stage_name] = self[stage_name].median + end + end + def summary @summary ||= ::Gitlab::CycleAnalytics::StageSummary.new(@project, from: @options[:from], diff --git a/app/models/member.rb b/app/models/member.rb index 2d17795e62d..408e8b2d704 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -128,7 +128,7 @@ class Member < ActiveRecord::Base find_by(invite_token: invite_token) end - def add_user(source, user, access_level, existing_members: nil, current_user: nil, expires_at: nil) + def add_user(source, user, access_level, existing_members: nil, current_user: nil, expires_at: nil, ldap: false) # `user` can be either a User object, User ID or an email to be invited member = retrieve_member(source, user, existing_members) access_level = retrieve_access_level(access_level) @@ -143,11 +143,13 @@ class Member < ActiveRecord::Base if member.request? ::Members::ApproveAccessRequestService.new( - source, current_user, - id: member.id, access_level: access_level - ).execute + ).execute( + member, + skip_authorization: ldap, + skip_log_audit_event: ldap + ) else member.save end diff --git a/app/models/note.rb b/app/models/note.rb index cac60845a49..d7a67ec277c 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -133,6 +133,7 @@ class Note < ActiveRecord::Base def find_discussion(discussion_id) notes = where(discussion_id: discussion_id).fresh.to_a + return if notes.empty? Discussion.build(notes) diff --git a/app/models/project_services/slash_commands_service.rb b/app/models/project_services/slash_commands_service.rb index eb4da68bb7e..37ea45109ae 100644 --- a/app/models/project_services/slash_commands_service.rb +++ b/app/models/project_services/slash_commands_service.rb @@ -30,10 +30,10 @@ class SlashCommandsService < Service def trigger(params) return unless valid_token?(params[:token]) - user = find_chat_user(params) + chat_user = find_chat_user(params) - if user - Gitlab::SlashCommands::Command.new(project, user, params).execute + if chat_user&.user + Gitlab::SlashCommands::Command.new(project, chat_user, params).execute else url = authorize_chat_name_url(params) Gitlab::SlashCommands::Presenters::Access.new(url).authorize diff --git a/app/models/repository.rb b/app/models/repository.rb index 299a3f32a85..242d9d5f125 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -139,7 +139,7 @@ class Repository end end - def commits(ref, path: nil, limit: nil, offset: nil, skip_merges: false, after: nil, before: nil) + def commits(ref = nil, path: nil, limit: nil, offset: nil, skip_merges: false, after: nil, before: nil, all: nil) options = { repo: raw_repository, ref: ref, @@ -149,7 +149,8 @@ class Repository after: after, before: before, follow: Array(path).length == 1, - skip_merges: skip_merges + skip_merges: skip_merges, + all: all } commits = Gitlab::Git::Commit.where(options) @@ -589,15 +590,7 @@ class Repository def license_key return unless exists? - # The licensee gem creates a Rugged object from the path: - # https://github.com/benbalter/licensee/blob/v8.7.0/lib/licensee/projects/git_project.rb - begin - Licensee.license(path).try(:key) - # Normally we would rescue Rugged::Error, but that is banned by lint-rugged - # and we need to migrate this endpoint to Gitaly: - # https://gitlab.com/gitlab-org/gitaly/issues/1026 - rescue - end + raw_repository.license_short_name end cache_method :license_key diff --git a/app/models/tree.rb b/app/models/tree.rb index c89b8eca9be..4c1856b67a8 100644 --- a/app/models/tree.rb +++ b/app/models/tree.rb @@ -9,10 +9,9 @@ class Tree @repository = repository @sha = sha @path = path - @recursive = recursive git_repo = @repository.raw_repository - @entries = get_entries(git_repo, @sha, @path, recursive: @recursive) + @entries = Gitlab::Git::Tree.where(git_repo, @sha, @path, recursive) end def readme @@ -58,21 +57,4 @@ class Tree def sorted_entries trees + blobs + submodules end - - private - - def get_entries(git_repo, sha, path, recursive: false) - current_path_entries = Gitlab::Git::Tree.where(git_repo, sha, path) - ordered_entries = [] - - current_path_entries.each do |entry| - ordered_entries << entry - - if recursive && entry.dir? - ordered_entries.concat(get_entries(git_repo, sha, entry.path, recursive: true)) - end - end - - ordered_entries - end end diff --git a/app/models/user.rb b/app/models/user.rb index 8e881e53706..9547506d33d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -431,7 +431,7 @@ class User < ActiveRecord::Base end def self.non_internal - where(Hash[internal_attributes.zip([[false, nil]] * internal_attributes.size)]) + where(internal_attributes.map { |attr| "#{attr} IS NOT TRUE" }.join(" AND ")) end # diff --git a/app/serializers/analytics_stage_entity.rb b/app/serializers/analytics_stage_entity.rb index 564612202b5..3e355a13e06 100644 --- a/app/serializers/analytics_stage_entity.rb +++ b/app/serializers/analytics_stage_entity.rb @@ -7,6 +7,7 @@ class AnalyticsStageEntity < Grape::Entity expose :description expose :median, as: :value do |stage| - stage.median && !stage.median.zero? ? distance_of_time_in_words(stage.median) : nil + # median returns a BatchLoader instance which we first have to unwrap by using to_i + !stage.median.to_i.zero? ? distance_of_time_in_words(stage.median) : nil end end diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb index 3f9a275ad08..b22a0b666ef 100644 --- a/app/serializers/cluster_application_entity.rb +++ b/app/serializers/cluster_application_entity.rb @@ -2,4 +2,5 @@ class ClusterApplicationEntity < Grape::Entity expose :name expose :status_name, as: :status expose :status_reason + expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) } end diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb new file mode 100644 index 00000000000..6e68d275047 --- /dev/null +++ b/app/serializers/diff_file_entity.rb @@ -0,0 +1,41 @@ +class DiffFileEntity < Grape::Entity + include DiffHelper + include SubmoduleHelper + include BlobHelper + include IconsHelper + include ActionView::Helpers::TagHelper + + expose :submodule?, as: :submodule + + expose :submodule_link do |diff_file| + submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository).first + end + + expose :blob_path do |diff_file| + diff_file.blob.path + end + + expose :blob_icon do |diff_file| + blob_icon(diff_file.b_mode, diff_file.file_path) + end + + expose :file_path + expose :deleted_file?, as: :deleted_file + expose :renamed_file?, as: :renamed_file + expose :old_path + expose :new_path + expose :mode_changed?, as: :mode_changed + expose :a_mode + expose :b_mode + expose :text?, as: :text + + expose :old_path_html do |diff_file| + old_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path) + old_path + end + + expose :new_path_html do |diff_file| + _, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path) + new_path + end +end diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb index 0a92e3f8167..bbbcf6a97c1 100644 --- a/app/serializers/discussion_entity.rb +++ b/app/serializers/discussion_entity.rb @@ -7,4 +7,42 @@ class DiscussionEntity < Grape::Entity expose :notes, using: NoteEntity expose :individual_note?, as: :individual_note + expose :resolvable?, as: :resolvable + expose :resolved?, as: :resolved + expose :resolve_path, if: -> (d, _) { d.resolvable? } do |discussion| + resolve_project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion.id) + end + expose :resolve_with_issue_path do |discussion| + new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id) + end + + expose :diff_file, using: DiffFileEntity, if: -> (d, _) { defined? d.diff_file } + + expose :diff_discussion?, as: :diff_discussion + + expose :truncated_diff_lines, if: -> (d, _) { (defined? d.diff_file) && d.diff_file.text? } do |discussion| + options[:context].render_to_string( + partial: "projects/diffs/line", + collection: discussion.truncated_diff_lines, + as: :line, + locals: { diff_file: discussion.diff_file, + discussion_expanded: true, + plain: true }, + layout: false, + formats: [:html] + ) + end + + expose :image_diff_html, if: -> (d, _) { defined? d.diff_file } do |discussion| + diff_file = discussion.diff_file + partial = diff_file.new_file? || diff_file.deleted_file? ? 'single_image_diff' : 'replaced_image_diff' + options[:context].render_to_string( + partial: "projects/diffs/#{partial}", + locals: { diff_file: diff_file, + position: discussion.position.to_json, + click_to_comment: false }, + layout: false, + formats: [:html] + ) + end end diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index fbfe480503b..4e8ef320af2 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -115,6 +115,14 @@ class MergeRequestWidgetEntity < IssuableEntity expose :can_cherry_pick_on_current_merge_request do |merge_request| presenter(merge_request).can_cherry_pick_on_current_merge_request? end + + expose :can_create_note do |issue| + can?(request.current_user, :create_note, issue.project) + end + + expose :can_update do |issue| + can?(request.current_user, :update_issue, issue) + end end # Paths @@ -189,6 +197,10 @@ class MergeRequestWidgetEntity < IssuableEntity end end + expose :create_note_path do |merge_request| + project_notes_path(merge_request.project, target_type: 'merge_request', target_id: merge_request.id) + end + expose :commit_change_content_path do |merge_request| commit_change_content_project_merge_request_path(merge_request.project, merge_request) end diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index 7d50e0ff10d..4ccf0bca476 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -23,6 +23,10 @@ class NoteEntity < API::Entities::Note end end + expose :resolved?, as: :resolved + expose :resolvable?, as: :resolvable + expose :resolved_by, using: NoteUserEntity + expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note| SystemNoteHelper.system_note_icon_name(note) end @@ -53,6 +57,14 @@ class NoteEntity < API::Entities::Note end end + expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note| + resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id) + end + + expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note| + new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id) + end + expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? } expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note| delete_attachment_project_note_path(note.project, note) diff --git a/app/services/chat_names/find_user_service.rb b/app/services/chat_names/find_user_service.rb index 4f5c5567b42..d458b814183 100644 --- a/app/services/chat_names/find_user_service.rb +++ b/app/services/chat_names/find_user_service.rb @@ -9,8 +9,8 @@ module ChatNames chat_name = find_chat_name return unless chat_name - chat_name.touch(:last_used_at) - chat_name.user + chat_name.update_last_used_at + chat_name end private diff --git a/app/services/clusters/applications/check_ingress_ip_address_service.rb b/app/services/clusters/applications/check_ingress_ip_address_service.rb new file mode 100644 index 00000000000..e572b1e5d99 --- /dev/null +++ b/app/services/clusters/applications/check_ingress_ip_address_service.rb @@ -0,0 +1,36 @@ +module Clusters + module Applications + class CheckIngressIpAddressService < BaseHelmService + include Gitlab::Utils::StrongMemoize + + Error = Class.new(StandardError) + + LEASE_TIMEOUT = 15.seconds.to_i + + def execute + return if app.external_ip + return unless try_obtain_lease + + app.update!(external_ip: ingress_ip) if ingress_ip + end + + private + + def try_obtain_lease + Gitlab::ExclusiveLease + .new("check_ingress_ip_address_service:#{app.id}", timeout: LEASE_TIMEOUT) + .try_obtain + end + + def ingress_ip + service.status.loadBalancer.ingress&.first&.ip + end + + def service + strong_memoize(:ingress_service) do + kubeclient.get_service('ingress-nginx-ingress-controller', Gitlab::Kubernetes::Helm::NAMESPACE) + end + end + end + end +end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 66a9b1f82e0..e87fd49d193 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -77,8 +77,12 @@ class IssuableBaseService < BaseService return unless labels params[:label_ids] = labels.split(",").map do |label_name| - service = Labels::FindOrCreateService.new(current_user, project, title: label_name.strip) - label = service.execute + label = Labels::FindOrCreateService.new( + current_user, + parent, + title: label_name.strip, + available_labels: available_labels + ).execute label.try(:id) end.compact @@ -102,7 +106,7 @@ class IssuableBaseService < BaseService end def available_labels - LabelsFinder.new(current_user, project_id: @project.id).execute + @available_labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute end def merge_quick_actions_into_params!(issuable) @@ -303,4 +307,8 @@ class IssuableBaseService < BaseService def update_project_counter_caches?(issuable) issuable.state_changed? end + + def parent + project + end end diff --git a/app/services/labels/find_or_create_service.rb b/app/services/labels/find_or_create_service.rb index 940c8b333d3..079f611b3f3 100644 --- a/app/services/labels/find_or_create_service.rb +++ b/app/services/labels/find_or_create_service.rb @@ -1,8 +1,9 @@ module Labels class FindOrCreateService - def initialize(current_user, project, params = {}) + def initialize(current_user, parent, params = {}) @current_user = current_user - @project = project + @parent = parent + @available_labels = params.delete(:available_labels) @params = params.dup.with_indifferent_access end @@ -13,12 +14,13 @@ module Labels private - attr_reader :current_user, :project, :params, :skip_authorization + attr_reader :current_user, :parent, :params, :skip_authorization def available_labels @available_labels ||= LabelsFinder.new( current_user, - project_id: project.id + "#{parent_type}_id".to_sym => parent.id, + only_group_labels: parent_is_group? ).execute(skip_authorization: skip_authorization) end @@ -27,8 +29,8 @@ module Labels def find_or_create_label new_label = available_labels.find_by(title: title) - if new_label.nil? && (skip_authorization || Ability.allowed?(current_user, :admin_label, project)) - new_label = Labels::CreateService.new(params).execute(project: project) + if new_label.nil? && (skip_authorization || Ability.allowed?(current_user, :admin_label, parent)) + new_label = Labels::CreateService.new(params).execute(parent_type.to_sym => parent) end new_label @@ -37,5 +39,13 @@ module Labels def title params[:title] || params[:name] end + + def parent_type + parent.model_name.param_key + end + + def parent_is_group? + parent_type == "group" + end end end diff --git a/app/services/members/approve_access_request_service.rb b/app/services/members/approve_access_request_service.rb index 2a2bb0cae5b..6be08b590bc 100644 --- a/app/services/members/approve_access_request_service.rb +++ b/app/services/members/approve_access_request_service.rb @@ -1,51 +1,20 @@ module Members - class ApproveAccessRequestService < BaseService - include MembersHelper - - attr_accessor :source - - # source - The source object that respond to `#requesters` (i.g. project or group) - # current_user - The user that performs the access request approval - # params - A hash of parameters - # :user_id - User ID used to retrieve the access requester - # :id - Member ID used to retrieve the access requester - # :access_level - Optional access level set when the request is accepted - def initialize(source, current_user, params = {}) - @source = source - @current_user = current_user - @params = params.slice(:user_id, :id, :access_level) - end - - # opts - A hash of options - # :force - Bypass permission check: current_user can be nil in that case - def execute(opts = {}) - condition = params[:user_id] ? { user_id: params[:user_id] } : { id: params[:id] } - access_requester = source.requesters.find_by!(condition) - - raise Gitlab::Access::AccessDeniedError unless can_update_access_requester?(access_requester, opts) + class ApproveAccessRequestService < Members::BaseService + def execute(access_requester, skip_authorization: false, skip_log_audit_event: false) + raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_update_access_requester?(access_requester) access_requester.access_level = params[:access_level] if params[:access_level] access_requester.accept_request + after_execute(member: access_requester, skip_log_audit_event: skip_log_audit_event) + access_requester end private - def can_update_access_requester?(access_requester, opts = {}) - access_requester && ( - opts[:force] || - can?(current_user, update_member_permission(access_requester), access_requester) - ) - end - - def update_member_permission(member) - case member - when GroupMember - :update_group_member - when ProjectMember - :update_project_member - end + def can_update_access_requester?(access_requester) + can?(current_user, update_member_permission(access_requester), access_requester) end end end diff --git a/app/services/members/authorized_destroy_service.rb b/app/services/members/authorized_destroy_service.rb deleted file mode 100644 index 2e89f00dad8..00000000000 --- a/app/services/members/authorized_destroy_service.rb +++ /dev/null @@ -1,61 +0,0 @@ -module Members - class AuthorizedDestroyService < BaseService - attr_accessor :member, :user - - def initialize(member, user = nil) - @member, @user = member, user - end - - def execute - return false if member.is_a?(GroupMember) && member.source.last_owner?(member.user) - - Member.transaction do - unassign_issues_and_merge_requests(member) unless member.invite? - member.notification_setting&.destroy - - member.destroy - end - - if member.request? && member.user != user - notification_service.decline_access_request(member) - end - - member - end - - private - - def unassign_issues_and_merge_requests(member) - if member.is_a?(GroupMember) - issues = Issue.unscoped.select(1) - .joins(:project) - .where('issues.id = issue_assignees.issue_id AND projects.namespace_id = ?', member.source_id) - - # DELETE FROM issue_assignees WHERE user_id = X AND EXISTS (...) - IssueAssignee.unscoped - .where('user_id = :user_id AND EXISTS (:sub)', user_id: member.user_id, sub: issues) - .delete_all - - MergeRequestsFinder.new(user, group_id: member.source_id, assignee_id: member.user_id) - .execute - .update_all(assignee_id: nil) - else - project = member.source - - # SELECT 1 FROM issues WHERE issues.id = issue_assignees.issue_id AND issues.project_id = X - issues = Issue.unscoped.select(1) - .where('issues.id = issue_assignees.issue_id') - .where(project_id: project.id) - - # DELETE FROM issue_assignees WHERE user_id = X AND EXISTS (...) - IssueAssignee.unscoped - .where('user_id = :user_id AND EXISTS (:sub)', user_id: member.user_id, sub: issues) - .delete_all - - project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil) - end - - member.user.invalidate_cache_counts - end - end -end diff --git a/app/services/members/base_service.rb b/app/services/members/base_service.rb new file mode 100644 index 00000000000..74556fb20cf --- /dev/null +++ b/app/services/members/base_service.rb @@ -0,0 +1,49 @@ +module Members + class BaseService < ::BaseService + # current_user - The user that performs the action + # params - A hash of parameters + def initialize(current_user = nil, params = {}) + @current_user = current_user + @params = params + end + + def after_execute(args) + # overriden in EE::Members modules + end + + private + + def update_member_permission(member) + case member + when GroupMember + :update_group_member + when ProjectMember + :update_project_member + else + raise "Unknown member type: #{member}!" + end + end + + def override_member_permission(member) + case member + when GroupMember + :override_group_member + when ProjectMember + :override_project_member + else + raise "Unknown member type: #{member}!" + end + end + + def action_member_permission(action, member) + case action + when :update + update_member_permission(member) + when :override + override_member_permission(member) + else + raise "Unknown action '#{action}' on #{member}!" + end + end + end +end diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index 26906ae7167..bc6a9405aac 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -1,15 +1,8 @@ module Members - class CreateService < BaseService + class CreateService < Members::BaseService DEFAULT_LIMIT = 100 - def initialize(source, current_user, params = {}) - @source = source - @current_user = current_user - @params = params - @error = nil - end - - def execute + def execute(source) return error('No users specified.') if params[:user_ids].blank? user_ids = params[:user_ids].split(',').uniq @@ -17,13 +10,15 @@ module Members return error("Too many users specified (limit is #{user_limit})") if user_limit && user_ids.size > user_limit - @source.add_users( + members = source.add_users( user_ids, params[:access_level], expires_at: params[:expires_at], current_user: current_user ) + members.each { |member| after_execute(member: member) } + success end diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb index 05b93ac8fdb..b141bfd5fbc 100644 --- a/app/services/members/destroy_service.rb +++ b/app/services/members/destroy_service.rb @@ -1,42 +1,30 @@ module Members - class DestroyService < BaseService - include MembersHelper + class DestroyService < Members::BaseService + def execute(member, skip_authorization: false) + raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_destroy_member?(member) - attr_accessor :source + return member if member.is_a?(GroupMember) && member.source.last_owner?(member.user) - ALLOWED_SCOPES = %i[members requesters all].freeze + Member.transaction do + unassign_issues_and_merge_requests(member) unless member.invite? + member.notification_setting&.destroy - def initialize(source, current_user, params = {}) - @source = source - @current_user = current_user - @params = params - end - - def execute(scope = :members) - raise "scope :#{scope} is not allowed!" unless ALLOWED_SCOPES.include?(scope) + member.destroy + end - member = find_member!(scope) + if member.request? && member.user != current_user + notification_service.decline_access_request(member) + end - raise Gitlab::Access::AccessDeniedError unless can_destroy_member?(member) + after_execute(member: member) - AuthorizedDestroyService.new(member, current_user).execute + member end private - def find_member!(scope) - condition = params[:user_id] ? { user_id: params[:user_id] } : { id: params[:id] } - case scope - when :all - source.members.find_by(condition) || - source.requesters.find_by!(condition) - else - source.public_send(scope).find_by!(condition) # rubocop:disable GitlabSecurity/PublicSend - end - end - def can_destroy_member?(member) - member && can?(current_user, destroy_member_permission(member), member) + can?(current_user, destroy_member_permission(member), member) end def destroy_member_permission(member) @@ -45,7 +33,42 @@ module Members :destroy_group_member when ProjectMember :destroy_project_member + else + raise "Unknown member type: #{member}!" end end + + def unassign_issues_and_merge_requests(member) + if member.is_a?(GroupMember) + issues = Issue.unscoped.select(1) + .joins(:project) + .where('issues.id = issue_assignees.issue_id AND projects.namespace_id = ?', member.source_id) + + # DELETE FROM issue_assignees WHERE user_id = X AND EXISTS (...) + IssueAssignee.unscoped + .where('user_id = :user_id AND EXISTS (:sub)', user_id: member.user_id, sub: issues) + .delete_all + + MergeRequestsFinder.new(current_user, group_id: member.source_id, assignee_id: member.user_id) + .execute + .update_all(assignee_id: nil) + else + project = member.source + + # SELECT 1 FROM issues WHERE issues.id = issue_assignees.issue_id AND issues.project_id = X + issues = Issue.unscoped.select(1) + .where('issues.id = issue_assignees.issue_id') + .where(project_id: project.id) + + # DELETE FROM issue_assignees WHERE user_id = X AND EXISTS (...) + IssueAssignee.unscoped + .where('user_id = :user_id AND EXISTS (:sub)', user_id: member.user_id, sub: issues) + .delete_all + + project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil) + end + + member.user.invalidate_cache_counts + end end end diff --git a/app/services/members/request_access_service.rb b/app/services/members/request_access_service.rb index 2614153d900..24293b30005 100644 --- a/app/services/members/request_access_service.rb +++ b/app/services/members/request_access_service.rb @@ -1,13 +1,6 @@ module Members - class RequestAccessService < BaseService - attr_accessor :source - - def initialize(source, current_user) - @source = source - @current_user = current_user - end - - def execute + class RequestAccessService < Members::BaseService + def execute(source) raise Gitlab::Access::AccessDeniedError unless can_request_access?(source) source.members.create( @@ -19,7 +12,7 @@ module Members private def can_request_access?(source) - source && can?(current_user, :request_access, source) + can?(current_user, :request_access, source) end end end diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb new file mode 100644 index 00000000000..48b3d59f7bd --- /dev/null +++ b/app/services/members/update_service.rb @@ -0,0 +1,16 @@ +module Members + class UpdateService < Members::BaseService + # returns the updated member + def execute(member, permission: :update) + raise Gitlab::Access::AccessDeniedError unless can?(current_user, action_member_permission(permission, member), member) + + old_access_level = member.human_access + + if member.update_attributes(params) + after_execute(action: permission, old_access_level: old_access_level, member: member) + end + + member + end + end +end diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 0e235a6d2a0..379a8068023 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -15,6 +15,8 @@ module Projects return error("Could not set the default branch") unless project.change_head(params[:default_branch]) end + ensure_wiki_exists if enabling_wiki? + if project.update_attributes(params.except(:default_branch)) if project.previous_changes.include?('path') project.rename_repo @@ -52,5 +54,18 @@ module Projects project.repository.exists? && new_branch && new_branch != project.default_branch end + + def enabling_wiki? + return false if @project.wiki_enabled? + + params[:project_feature_attributes][:wiki_access_level].to_i > ProjectFeature::DISABLED + end + + def ensure_wiki_exists + ProjectWiki.new(project, project.owner).wiki + rescue ProjectWiki::CouldNotCreateWikiError + log_error("Could not create wiki for #{project.full_name}") + Gitlab::Metrics.counter(:wiki_can_not_be_created_total, 'Counts the times we failed to create a wiki') + end end end diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb index a6b7a6e1416..af8c02a10b7 100644 --- a/app/services/system_hooks_service.rb +++ b/app/services/system_hooks_service.rb @@ -11,6 +11,8 @@ class SystemHooksService SystemHook.hooks_for(hooks_scope).find_each do |hook| hook.async_execute(data, 'system_hooks') end + + Gitlab::Plugin.execute_all_async(data) end private diff --git a/app/validators/variable_duplicates_validator.rb b/app/validators/variable_duplicates_validator.rb index 4bfa3c45303..72660be6c43 100644 --- a/app/validators/variable_duplicates_validator.rb +++ b/app/validators/variable_duplicates_validator.rb @@ -5,6 +5,8 @@ # - Use `validates :xxx, uniqueness: { scope: :xxx_id }` in a child model class VariableDuplicatesValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) + return if record.errors.include?(:"#{attribute}.key") + if options[:scope] scoped = value.group_by do |variable| Array(options[:scope]).map { |attr| variable.send(attr) } # rubocop:disable GitlabSecurity/PublicSend diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml index 140688b52d3..e1cee584929 100644 --- a/app/views/admin/runners/_runner.html.haml +++ b/app/views/admin/runners/_runner.html.haml @@ -17,6 +17,8 @@ %td = runner.version %td + = runner.ip_address + %td - if runner.shared? n/a - else diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index abec3607cab..9f13dbbbd82 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -60,6 +60,7 @@ %th Runner token %th Description %th Version + %th IP Address %th Projects %th Jobs %th Tags diff --git a/app/views/groups/group_members/update.js.haml b/app/views/groups/group_members/update.js.haml deleted file mode 100644 index 9d05bff6c4e..00000000000 --- a/app/views/groups/group_members/update.js.haml +++ /dev/null @@ -1,4 +0,0 @@ -:plain - var $listItem = $('#{escape_javascript(render('shared/members/member', member: @group_member))}'); - $("##{dom_id(@group_member)} .list-item-name").replaceWith($listItem.find('.list-item-name')); - gl.utils.localTimeAgo($('.js-timeago'), $("##{dom_id(@group_member)}")); diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index f2ae7c52031..ca3f018c5e6 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -1,12 +1,13 @@ - page_title "Issues" -- group_issues_exists = group_issues(@group).exists? = content_for :meta_tags do = auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@group.name} issues") - content_for :page_specific_javascripts do = webpack_bundle_tag 'common_vue' -- if group_issues_exists +- if group_issues_count(state: 'all').zero? + = render 'shared/empty_states/issues', project_select_button: true +- else .top-area = render 'shared/issuable/nav', type: :issues .nav-controls @@ -19,5 +20,3 @@ = render 'shared/issuable/search_bar', type: :issues = render 'shared/issues' -- else - = render 'shared/empty_states/issues', project_select_button: true diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml index 046b92bd9fb..4ccd16f3e11 100644 --- a/app/views/groups/merge_requests.html.haml +++ b/app/views/groups/merge_requests.html.haml @@ -1,9 +1,6 @@ - page_title "Merge Requests" -- content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_vue' - -- if @group_merge_requests.empty? +- if group_merge_requests_count(state: 'all').zero? = render 'shared/empty_states/merge_requests', project_select_button: true - else .top-area diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 47ae79b7a69..b520f28123f 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -1,6 +1,5 @@ -- issues_count = IssuesFinder.new(current_user, group_id: @group.id, state: 'opened').execute.count -- merge_requests_count = MergeRequestsFinder.new(current_user, group_id: @group.id, state: 'opened', non_archived: true).execute.count - +- issues_count = group_issues_count(state: 'opened') +- merge_requests_count = group_merge_requests_count(state: 'opened') - issues_sub_menu_items = ['groups#issues', 'labels#index', 'milestones#index'] .nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) } diff --git a/app/views/profiles/_head.html.haml b/app/views/profiles/_head.html.haml deleted file mode 100644 index a8eb66ca13c..00000000000 --- a/app/views/profiles/_head.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -- content_for :page_specific_javascripts do - = webpack_bundle_tag('profile') diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 0f849f6f8b7..02263095599 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -1,6 +1,5 @@ - page_title "Account" - @content_class = "limit-container-width" unless fluid_layout -= render 'profiles/head' - if current_user.ldap_user? .alert.alert-info diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml index cbea5ca605a..a924369050b 100644 --- a/app/views/profiles/audit_log.html.haml +++ b/app/views/profiles/audit_log.html.haml @@ -1,6 +1,5 @@ - page_title "Authentication log" - @content_class = "limit-container-width" unless fluid_layout -= render 'profiles/head' .row.prepend-top-default .col-lg-4.profile-settings-sidebar diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml index 8f7121afe02..4b6e419af50 100644 --- a/app/views/profiles/chat_names/index.html.haml +++ b/app/views/profiles/chat_names/index.html.haml @@ -1,6 +1,5 @@ - page_title 'Chat' - @content_class = "limit-container-width" unless fluid_layout -= render 'profiles/head' .row.prepend-top-default .col-lg-4.profile-settings-sidebar diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index df1df4f5d72..e3c2bd1150e 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -1,6 +1,5 @@ - page_title "Emails" - @content_class = "limit-container-width" unless fluid_layout -= render 'profiles/head' .row.prepend-top-default .col-lg-4.profile-settings-sidebar diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml index e44506ec9c9..1d2e41cb437 100644 --- a/app/views/profiles/gpg_keys/index.html.haml +++ b/app/views/profiles/gpg_keys/index.html.haml @@ -1,6 +1,5 @@ - page_title "GPG Keys" - @content_class = "limit-container-width" unless fluid_layout -= render 'profiles/head' .row.prepend-top-default .col-lg-4.profile-settings-sidebar diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml index 5f7b41cf30e..457583cfd35 100644 --- a/app/views/profiles/keys/index.html.haml +++ b/app/views/profiles/keys/index.html.haml @@ -1,6 +1,5 @@ - page_title "SSH Keys" - @content_class = "limit-container-width" unless fluid_layout -= render 'profiles/head' .row.prepend-top-default .col-lg-4.profile-settings-sidebar diff --git a/app/views/profiles/keys/show.html.haml b/app/views/profiles/keys/show.html.haml index 7b7960708c4..28be6172219 100644 --- a/app/views/profiles/keys/show.html.haml +++ b/app/views/profiles/keys/show.html.haml @@ -2,5 +2,4 @@ - breadcrumb_title @key.title - page_title @key.title, "SSH Keys" - @content_class = "limit-container-width" unless fluid_layout -= render 'profiles/head' = render "key_details" diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index 202eccb7bb6..8f099aa6dd7 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -1,6 +1,5 @@ - page_title "Notifications" - @content_class = "limit-container-width" unless fluid_layout -= render 'profiles/head' %div - if @user.errors.any? diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index f445e5a2417..78848542810 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -2,7 +2,6 @@ - page_title "Personal Access Tokens" - @content_class = "limit-container-width" unless fluid_layout -= render 'profiles/head' .row.prepend-top-default .col-lg-4.profile-settings-sidebar diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 66d1d1e8d44..6aefd97bb96 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -1,6 +1,5 @@ - page_title 'Preferences' - @content_class = "limit-container-width" unless fluid_layout -= render 'profiles/head' = form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row prepend-top-default js-preferences-form' } do |f| .col-lg-4.application-theme diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 110736dc557..e497eab32e0 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -1,6 +1,5 @@ - breadcrumb_title "Edit Profile" - @content_class = "limit-container-width" unless fluid_layout -= render 'profiles/head' = bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit' }, authenticity_token: true do |f| = form_errors(@user) diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index e58cd20402c..8707af36e2e 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -2,7 +2,6 @@ - add_to_breadcrumbs("Two-Factor Authentication", profile_account_path) - @content_class = "limit-container-width" unless fluid_layout -= render 'profiles/head' - content_for :page_specific_javascripts do - if inject_u2f_api? diff --git a/app/views/projects/_issuable_by_email.html.haml b/app/views/projects/_issuable_by_email.html.haml index 749e273b2e2..c137e38ed50 100644 --- a/app/views/projects/_issuable_by_email.html.haml +++ b/app/views/projects/_issuable_by_email.html.haml @@ -18,7 +18,14 @@ .email-modal-input-group.input-group = text_field_tag :issuable_email, email, class: "monospace js-select-on-focus form-control", readonly: true .input-group-btn - = clipboard_button(target: '#issuable_email') + = clipboard_button(target: '#issuable_email', class: 'btn btn-clipboard btn-transparent hidden-xs') + = mail_to email, class: 'btn btn-clipboard btn-transparent', + subject: _("Enter the #{name} title"), + body: _("Enter the #{name} description"), + title: _('Send email'), + data: { toggle: 'tooltip', placement: 'bottom' } do + = sprite_icon('mail') + %p = render 'by_email_description' %p diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index 2a77dedd9a2..1b150ec3e5c 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -11,8 +11,8 @@ = view_on_environment_button(@commit.sha, @path, @environment) if @environment .btn-group{ role: "group" }< - = edit_blob_link - = ide_blob_link + = edit_blob_button + = ide_edit_button - if current_user = replace_blob_link = delete_blob_link diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml index b3afd16f900..f1324c61500 100644 --- a/app/views/projects/blob/_upload.html.haml +++ b/app/views/projects/blob/_upload.html.haml @@ -27,6 +27,3 @@ - unless can?(current_user, :push_code, @project) .inline.prepend-left-10 = commit_in_fork_help - -- content_for :page_specific_javascripts do - = webpack_bundle_tag('blob') diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml index e9d8fc75142..c7fc5a98ca8 100644 --- a/app/views/projects/branches/new.html.haml +++ b/app/views/projects/branches/new.html.haml @@ -28,4 +28,5 @@ .form-actions = button_tag 'Create branch', class: 'btn btn-create', tabindex: 3 = link_to 'Cancel', project_branches_path(@project), class: 'btn btn-cancel' +-# haml-lint:disable InlineJavaScript %script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml index 2b1b23ba198..179c45a9867 100644 --- a/app/views/projects/clusters/show.html.haml +++ b/app/views/projects/clusters/show.html.haml @@ -15,6 +15,7 @@ cluster_status_reason: @cluster.status_reason, help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'), ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-ip-address'), + ingress_dns_help_path: help_page_path('topics/autodevops/quick_start_guide.md', anchor: 'point-dns-at-cluster-ip'), manage_prometheus_path: edit_project_service_path(@cluster.project, 'prometheus') } } .js-cluster-application-notice diff --git a/app/views/projects/commit/_pipelines_list.haml b/app/views/projects/commit/_pipelines_list.haml index 3f699882c5f..68b35072f26 100644 --- a/app/views/projects/commit/_pipelines_list.haml +++ b/app/views/projects/commit/_pipelines_list.haml @@ -6,7 +6,3 @@ "empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'), "error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'), } } - -- content_for :page_specific_javascripts do - = webpack_bundle_tag('common_vue') - = webpack_bundle_tag('commit_pipelines') diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index 4058e61eb9a..abb292f8f27 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -6,9 +6,6 @@ - @content_class = limited_container_width - page_title "#{@commit.title} (#{@commit.short_id})", "Commits" - page_description @commit.description -- content_for :page_specific_javascripts do - = webpack_bundle_tag('common_vue') - = webpack_bundle_tag('diff_notes') .container-fluid{ class: [limited_container_width, container_class] } = render "commit_box" diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 6ff7bcae54f..078bd0eee63 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -20,7 +20,7 @@ .avatar-cell.hidden-xs = author_avatar(commit, size: 36) - .commit-detail + .commit-detail.flex-list .commit-content = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title") %span.commit-row-message.visible-xs-inline diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index d98e0564da4..02395b6eb9b 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -2,7 +2,6 @@ - page_title "Cycle Analytics" - content_for :page_specific_javascripts do = webpack_bundle_tag('common_vue') - = webpack_bundle_tag('cycle_analytics') #cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } } - if @cycle_analytics_no_data diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index 0b01e38d23d..47bfcb21cf4 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -17,7 +17,7 @@ \ - if editable_diff?(diff_file) - link_opts = @merge_request.persisted? ? { from_merge_request_iid: @merge_request.iid } : {} - = edit_blob_link(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path, + = edit_blob_button(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path, blob: blob, link_opts: link_opts) - if image_diff && image_replaced diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index b947b91322d..a96485ab155 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -70,6 +70,7 @@ Enable or disable certain project features and choose access levels. .settings-content = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f| + -# haml-lint:disable InlineJavaScript %script.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data(@project) .js-project-permissions-form = f.submit 'Save changes', class: "btn btn-save" diff --git a/app/views/projects/environments/folder.html.haml b/app/views/projects/environments/folder.html.haml index eca10d99908..1ac7dab6775 100644 --- a/app/views/projects/environments/folder.html.haml +++ b/app/views/projects/environments/folder.html.haml @@ -1,10 +1,6 @@ - @no_container = true - page_title "Environments" -- content_for :page_specific_javascripts do - = webpack_bundle_tag('common_vue') - = webpack_bundle_tag("environments_folder") - #environments-folder-list-view{ data: { endpoint: folder_project_environments_path(@project, @folder, format: :json), "folder-name" => @folder, "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s, diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index 31cf173fa9c..0d656b25bc8 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -4,7 +4,6 @@ - content_for :page_specific_javascripts do = webpack_bundle_tag("common_vue") - = webpack_bundle_tag("environments") #environments-list-view{ data: { environments_data: environments_list_data, "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s, diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml index 91b3743e9e7..9d9759ebc5f 100644 --- a/app/views/projects/environments/metrics.html.haml +++ b/app/views/projects/environments/metrics.html.haml @@ -1,7 +1,5 @@ - @no_container = true - page_title "Metrics for environment", @environment.name -- content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_vue' .prometheus-container{ class: container_class } .top-area diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index d4b4a6203f3..14c47a5d91c 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -74,6 +74,7 @@ = _("Commits per day hour (UTC)") %canvas#hour-chart +-# haml-lint:disable InlineJavaScript %script#projectChartData{ type: "application/json" } - projectChartData = {}; - projectChartData['hour'] = @commits_per_time diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 11b5e02f1e0..cdfc3e232c5 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -6,14 +6,6 @@ = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' %section.js-vue-notes-event - #js-vue-notes{ data: { discussions_path: discussions_project_issue_path(@project, @issue, format: :json), - register_path: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), - new_session_path: new_session_path(:user, redirect_to_referer: 'yes'), - markdown_docs_path: help_page_path('user/markdown'), - quick_actions_docs_path: help_page_path('user/project/quick_actions'), - notes_path: notes_url, - close_issue_path: issue_path(@issue, issue: { state_event: :close }, format: 'json'), - reopen_issue_path: issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), - last_fetched_at: Time.now.to_i, - noteable_data: serialize_issuable(@issue), - current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } } + #js-vue-notes{ data: { notes_data: notes_data(@issue), + noteable_data: serialize_issuable(@issue), + current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } } diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index d63443c9da5..ec7e87219f5 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -55,6 +55,7 @@ .issue-details.issuable-details .detail-page-description.content-block + -# haml-lint:disable InlineJavaScript %script#js-issuable-app-initial-data{ type: "application/json" }= issuable_initial_data(@issue).to_json #js-issuable-app %h2.title= markdown_field(@issue, :title) @@ -73,7 +74,7 @@ .content-block.emoji-block .row - .col-sm-8.js-issue-note-awards + .col-sm-8.js-noteable-awards = render 'award_emoji/awards_block', awardable: @issue, inline: true .col-sm-4.new-branch-col = render 'new_branch' unless @issue.confidential? diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml index 2a2e57027be..a6e2565a485 100644 --- a/app/views/projects/merge_requests/conflicts.html.haml +++ b/app/views/projects/merge_requests/conflicts.html.haml @@ -1,7 +1,5 @@ - page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" - content_for :page_specific_javascripts do - = webpack_bundle_tag('common_vue') - = webpack_bundle_tag('merge_conflicts') = page_specific_javascript_tag('lib/ace.js') = render "projects/merge_requests/mr_title" diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml index 2a2e57027be..a6e2565a485 100644 --- a/app/views/projects/merge_requests/conflicts/show.html.haml +++ b/app/views/projects/merge_requests/conflicts/show.html.haml @@ -1,7 +1,5 @@ - page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" - content_for :page_specific_javascripts do - = webpack_bundle_tag('common_vue') - = webpack_bundle_tag('merge_conflicts') = page_specific_javascript_tag('lib/ace.js') = render "projects/merge_requests/mr_title" diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index e29f21b3bec..f2e35ef6e0c 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -1,3 +1,4 @@ +- @gfm_form = true - @content_class = "limit-container-width" unless fluid_layout - add_to_breadcrumbs "Merge Requests", project_merge_requests_path(@project) - breadcrumb_title @merge_request.to_reference @@ -7,6 +8,9 @@ - content_for :page_specific_javascripts do = webpack_bundle_tag('common_vue') + - if has_vue_discussions_cookie? + = webpack_bundle_tag('mr_notes') + .merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project) } } = render "projects/merge_requests/mr_title" @@ -23,10 +27,7 @@ #js-vue-mr-widget.mr-widget - - content_for :page_specific_javascripts do - = webpack_bundle_tag 'vue_merge_request_widget' - - .content-block.content-block-small.emoji-list-container + .content-block.content-block-small.emoji-list-container.js-noteable-awards = render 'award_emoji/awards_block', awardable: @merge_request, inline: true .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } @@ -54,28 +55,37 @@ = tab_link_for @merge_request, :diffs do Changes %span.badge= @merge_request.diff_size - #resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true } - %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" } - %div - .line-resolve-all{ "v-show" => "discussionCount > 0", - ":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" } - %span.line-resolve-btn.is-disabled{ type: "button", - ":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" } - %template{ 'v-if' => 'resolvedDiscussionCount === discussionCount' } - = render 'shared/icons/icon_status_success_solid.svg' - %template{ 'v-else' => '' } - = render 'shared/icons/icon_resolve_discussion.svg' - %span.line-resolve-text - {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved - = render "discussions/new_issue_for_all_discussions", merge_request: @merge_request - = render "discussions/jump_to_next" + + - if has_vue_discussions_cookie? + #js-vue-discussion-counter + - else + #resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true } + %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" } + %div + .line-resolve-all{ "v-show" => "discussionCount > 0", + ":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" } + %span.line-resolve-btn.is-disabled{ type: "button", + ":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" } + %template{ 'v-if' => 'resolvedDiscussionCount === discussionCount' } + = render 'shared/icons/icon_status_success_solid.svg' + %template{ 'v-else' => '' } + = render 'shared/icons/icon_resolve_discussion.svg' + %span.line-resolve-text + {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved + = render "discussions/new_issue_for_all_discussions", merge_request: @merge_request + = render "discussions/jump_to_next" .tab-content#diff-notes-app #notes.notes.tab-pane.voting_notes .row %section.col-md-12 - .issuable-discussion + %script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe + .issuable-discussion.js-vue-notes-event = render "projects/merge_requests/discussion" + - if has_vue_discussions_cookie? + #js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request), + noteable_data: serialize_issuable(@merge_request), + current_user_data: UserSerializer.new.represent(current_user).to_json} } #commits.commits.tab-pane -# This tab is always loaded via AJAX diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml index 97be8950db0..4b7be9a223f 100644 --- a/app/views/projects/network/show.html.haml +++ b/app/views/projects/network/show.html.haml @@ -1,7 +1,5 @@ - breadcrumb_title "Graph" - page_title "Graph", @ref -- content_for :page_specific_javascripts do - = webpack_bundle_tag('network') = render "head" %div{ class: container_class } .project-network diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml index ca1b41b140a..d81b07832bb 100644 --- a/app/views/projects/pages_domains/_form.html.haml +++ b/app/views/projects/pages_domains/_form.html.haml @@ -1,34 +1,30 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'form-horizontal fieldset-form' } do |f| - - if @domain.errors.any? - #error_explanation - .alert.alert-danger - - @domain.errors.full_messages.each do |msg| - %p= msg +- if @domain.errors.any? + #error_explanation + .alert.alert-danger + - @domain.errors.full_messages.each do |msg| + %p= msg +.form-group + = f.label :domain, class: 'control-label' do + Domain + .col-sm-10 + = f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control', disabled: @domain.persisted? + +- if Gitlab.config.pages.external_https .form-group - = f.label :domain, class: 'control-label' do - Domain + = f.label :certificate, class: 'control-label' do + Certificate (PEM) .col-sm-10 - = f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control' - - - if Gitlab.config.pages.external_https - .form-group - = f.label :certificate, class: 'control-label' do - Certificate (PEM) - .col-sm-10 - = f.text_area :certificate, rows: 5, class: 'form-control' - %span.help-inline Upload a certificate for your domain with all intermediates - - .form-group - = f.label :key, class: 'control-label' do - Key (PEM) - .col-sm-10 - = f.text_area :key, rows: 5, class: 'form-control' - %span.help-inline Upload a private key for your certificate - - else - .nothing-here-block - Support for custom certificates is disabled. - Ask your system's administrator to enable it. + = f.text_area :certificate, rows: 5, class: 'form-control' + %span.help-inline Upload a certificate for your domain with all intermediates - .form-actions - = f.submit 'Create New Domain', class: "btn btn-save" + .form-group + = f.label :key, class: 'control-label' do + Key (PEM) + .col-sm-10 + = f.text_area :key, rows: 5, class: 'form-control' + %span.help-inline Upload a private key for your certificate +- else + .nothing-here-block + Support for custom certificates is disabled. + Ask your system's administrator to enable it. diff --git a/app/views/projects/pages_domains/edit.html.haml b/app/views/projects/pages_domains/edit.html.haml new file mode 100644 index 00000000000..5645a4604bf --- /dev/null +++ b/app/views/projects/pages_domains/edit.html.haml @@ -0,0 +1,11 @@ +- add_to_breadcrumbs "Pages", project_pages_path(@project) +- breadcrumb_title @domain.domain +- page_title @domain.domain +%h3.page_title + = @domain.domain +%hr.clearfix +%div + = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'form-horizontal fieldset-form' } do |f| + = render 'form', { f: f } + .form-actions + = f.submit 'Save Changes', class: "btn btn-save" diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml index e1477c71d06..5a397c9d3c7 100644 --- a/app/views/projects/pages_domains/new.html.haml +++ b/app/views/projects/pages_domains/new.html.haml @@ -1,6 +1,10 @@ +- add_to_breadcrumbs "Pages", project_pages_path(@project) - page_title 'New Pages Domain' %h3.page_title New Pages Domain %hr.clearfix %div - = render 'form' + = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'form-horizontal fieldset-form' } do |f| + = render 'form', { f: f } + .form-actions + = f.submit 'Create New Domain', class: "btn btn-save" diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml index 72e9203bdb0..ba0713daee9 100644 --- a/app/views/projects/pages_domains/show.html.haml +++ b/app/views/projects/pages_domains/show.html.haml @@ -1,4 +1,7 @@ +- add_to_breadcrumbs "Pages", project_pages_path(@project) +- breadcrumb_title @domain.domain - page_title "#{@domain.domain}", 'Pages Domains' + - verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? - if verification_enabled && @domain.unverified? %p.alert.alert-warning @@ -8,6 +11,7 @@ %h3.page-title Pages Domain + = link_to 'Edit', edit_project_pages_domain_path(@project, @domain), class: 'btn btn-success pull-right' .table-holder %table.table diff --git a/app/views/projects/pipelines/charts/_pipeline_times.haml b/app/views/projects/pipelines/charts/_pipeline_times.haml index 510697c2ae9..c23fe6ff170 100644 --- a/app/views/projects/pipelines/charts/_pipeline_times.haml +++ b/app/views/projects/pipelines/charts/_pipeline_times.haml @@ -4,4 +4,5 @@ %canvas#build_timesChart{ height: 200 } +-# haml-lint:disable InlineJavaScript %script#pipelinesTimesChartsData{ type: "application/json" }= { :labels => @charts[:pipeline_times].labels, :values => @charts[:pipeline_times].pipeline_times }.to_json.html_safe diff --git a/app/views/projects/pipelines/charts/_pipelines.haml b/app/views/projects/pipelines/charts/_pipelines.haml index 2f4b6def155..14b3d47a9c2 100644 --- a/app/views/projects/pipelines/charts/_pipelines.haml +++ b/app/views/projects/pipelines/charts/_pipelines.haml @@ -26,6 +26,7 @@ = _("Pipelines for last year") %canvas#yearChart.padded{ height: 250 } +-# haml-lint:disable InlineJavaScript %script#pipelinesChartsData{ type: "application/json" } - chartData = [] - [:week, :month, :year].each do |scope| diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index fdcc60f48a5..cf95cdbfec2 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -12,6 +12,3 @@ "has-ci" => @repository.gitlab_ci_yml, "ci-lint-path" => ci_lint_path, "reset-cache-path" => reset_cache_project_settings_ci_cd_path(@project) } } - - = webpack_bundle_tag('common_vue') - = webpack_bundle_tag('pipelines') diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index 4ad37d0e882..877101b05ca 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -20,4 +20,5 @@ = f.submit 'Create pipeline', class: 'btn btn-create', tabindex: 3 = link_to 'Cancel', project_pipelines_path(@project), class: 'btn btn-cancel' +-# haml-lint:disable InlineJavaScript %script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 2174154b207..ffb0ae95f9b 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -13,4 +13,3 @@ - content_for :page_specific_javascripts do = webpack_bundle_tag('common_vue') - = webpack_bundle_tag('pipelines_details') diff --git a/app/views/projects/project_members/update.js.haml b/app/views/projects/project_members/update.js.haml deleted file mode 100644 index d15f4310ff5..00000000000 --- a/app/views/projects/project_members/update.js.haml +++ /dev/null @@ -1,4 +0,0 @@ -:plain - var $listItem = $('#{escape_javascript(render('shared/members/member', member: @project_member))}'); - $("##{dom_id(@project_member)} .list-item-name").replaceWith($listItem.find('.list-item-name')); - gl.utils.localTimeAgo($('.js-timeago'), $("##{dom_id(@project_member)}")); diff --git a/app/views/projects/protected_tags/_index.html.haml b/app/views/projects/protected_tags/_index.html.haml index 74f7f63c941..6b284fda35c 100644 --- a/app/views/projects/protected_tags/_index.html.haml +++ b/app/views/projects/protected_tags/_index.html.haml @@ -1,6 +1,3 @@ -- content_for :page_specific_javascripts do - = webpack_bundle_tag('protected_tags') - - content_for :create_protected_tag do = render 'projects/protected_tags/create_protected_tag' diff --git a/app/views/projects/runners/_form.html.haml b/app/views/projects/runners/_form.html.haml index e660fce652f..49c90869146 100644 --- a/app/views/projects/runners/_form.html.haml +++ b/app/views/projects/runners/_form.html.haml @@ -30,6 +30,11 @@ .col-sm-10 = f.text_field :token, class: 'form-control', readonly: true .form-group + = label_tag :ip_address, class: 'control-label' do + IP Address + .col-sm-10 + = f.text_field :ip_address, class: 'form-control', readonly: true + .form-group = label_tag :description, class: 'control-label' do Description .col-sm-10 diff --git a/app/views/projects/runners/show.html.haml b/app/views/projects/runners/show.html.haml index dfab04aa1fb..4e57f5f844d 100644 --- a/app/views/projects/runners/show.html.haml +++ b/app/views/projects/runners/show.html.haml @@ -41,6 +41,9 @@ %td Version %td= @runner.version %tr + %td IP Address + %td= @runner.ip_address + %tr %td Revision %td= @runner.revision %tr diff --git a/app/views/projects/services/prometheus/_configuration_banner.html.haml b/app/views/projects/services/prometheus/_configuration_banner.html.haml new file mode 100644 index 00000000000..2cc2a6b2b5b --- /dev/null +++ b/app/views/projects/services/prometheus/_configuration_banner.html.haml @@ -0,0 +1,26 @@ +%h4 + = s_('PrometheusService|Auto configuration') + +- if service.manual_configuration? + .well + = s_('PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below') +- else + .container-fluid + .row + - if service.prometheus_installed? + .col-sm-2 + .svg-container + = image_tag 'illustrations/monitoring/getting_started.svg' + .col-sm-10 + %p.text-success.prepend-top-default + = s_('PrometheusService|Prometheus is being automatically managed on your clusters') + = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn' + - else + .col-sm-2 + = image_tag 'illustrations/monitoring/loading.svg' + .col-sm-10 + %p.prepend-top-default + = s_('PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments') + = link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(project), class: 'btn btn-success' + +%hr diff --git a/app/views/projects/services/prometheus/_help.html.haml b/app/views/projects/services/prometheus/_help.html.haml index 5e320a252d8..88acb824ba7 100644 --- a/app/views/projects/services/prometheus/_help.html.haml +++ b/app/views/projects/services/prometheus/_help.html.haml @@ -1,29 +1,5 @@ -%h4 - = s_('PrometheusService|Auto configuration') - -- if @service.manual_configuration? - .well - = s_('PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below') -- else - .container-fluid - .row - - if @service.prometheus_installed? - .col-sm-2 - .svg-container - = image_tag 'illustrations/monitoring/getting_started.svg' - .col-sm-10 - %p.text-success.prepend-top-default - = s_('PrometheusService|Prometheus is being automatically managed on your clusters') - = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(@project), class: 'btn' - - else - .col-sm-2 - = image_tag 'illustrations/monitoring/loading.svg' - .col-sm-10 - %p.prepend-top-default - = s_('PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments') - = link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(@project), class: 'btn btn-success' - -%hr +- if @project + = render 'projects/services/prometheus/configuration_banner', project: @project, service: @service %h4.append-bottom-default = s_('PrometheusService|Manual configuration') diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index 6e105a5521a..1827a3d323c 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -43,4 +43,5 @@ .form-actions = button_tag s_('TagsPage|Create tag'), class: 'btn btn-create' = link_to s_('TagsPage|Cancel'), project_tags_path(@project), class: 'btn btn-cancel' +-# haml-lint:disable InlineJavaScript %script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 05539dfed7c..39511435508 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -75,7 +75,7 @@ - if show_new_ide? = succeed " " do = link_to ide_edit_path(@project, @id), class: 'btn btn-default' do - = ide_edit_text + = _('Web IDE') = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index 3312254f5fb..014b8de1dc9 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -7,6 +7,7 @@ - content_for :page_specific_javascripts do = webpack_bundle_tag 'common_vue' + -# haml-lint:disable InlineJavaScript %script#js-board-template{ type: "text/x-template" }= render "shared/boards/components/board" %script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index dc583d3eb3b..adaddda13eb 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -1,7 +1,4 @@ - todo = issuable_todo(issuable) -- content_for :page_specific_javascripts do - = webpack_bundle_tag('common_vue') - = webpack_bundle_tag('sidebar') %aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: current_user.present? } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } .issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } } @@ -120,10 +117,12 @@ = render partial: "shared/issuable/label_page_create" - if issuable.has_attribute?(:confidential) + -# haml-lint:disable InlineJavaScript %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: @issue.confidential, is_editable: can_edit_issuable }.to_json.html_safe #js-confidential-entry-point - if issuable.has_attribute?(:discussion_locked) + -# haml-lint:disable InlineJavaScript %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 @@ -160,4 +159,5 @@ = _('Move') = icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon') + -# haml-lint:disable InlineJavaScript %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable, can_edit_issuable).to_json.html_safe diff --git a/app/views/shared/members/update.js.haml b/app/views/shared/members/update.js.haml new file mode 100644 index 00000000000..55050bd8a15 --- /dev/null +++ b/app/views/shared/members/update.js.haml @@ -0,0 +1,6 @@ +- member = local_assigns.fetch(:member) + +:plain + var $listItem = $('#{escape_javascript(render('shared/members/member', member: member))}'); + $("##{dom_id(member)} .list-item-name").replaceWith($listItem.find('.list-item-name')); + gl.utils.localTimeAgo($('.js-timeago'), $("##{dom_id(member)}")); diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index cd4188daf5b..a942ebc328b 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -1,7 +1,5 @@ - affix_offset = local_assigns.fetch(:affix_offset, "50") - project = local_assigns[:project] -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('common_vue') %aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix", "always-show-toggle" => true }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } .issuable-sidebar.milestone-sidebar diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index b3f865c5b47..1db7c4e67cf 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -1,13 +1,14 @@ - issuable = @issue || @merge_request - discussion_locked = issuable&.discussion_locked? -%ul#notes-list.notes.main-notes-list.timeline - = render "shared/notes/notes" +- unless has_vue_discussions_cookie? + %ul#notes-list.notes.main-notes-list.timeline + = render "shared/notes/notes" = render 'shared/notes/edit_form', project: @project - if can_create_note? - %ul.notes.notes-form.timeline + %ul.notes.notes-form.timeline{ :class => ('hidden' if has_vue_discussions_cookie?) } %li.timeline-entry .timeline-entry-inner .flash-container.timeline-content @@ -34,4 +35,5 @@ is locked. Only %b project members can comment. +-# haml-lint:disable InlineJavaScript %script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index 2726a4934fb..c75c882a693 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -1,6 +1,5 @@ - content_for :page_specific_javascripts do = page_specific_javascript_tag('lib/ace.js') - = webpack_bundle_tag('snippet') .snippet-form-holder = form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input js-quick-submit common-note-form" } do |f| diff --git a/app/views/u2f/_authenticate.html.haml b/app/views/u2f/_authenticate.html.haml index f878bece2fa..7eb221620ad 100644 --- a/app/views/u2f/_authenticate.html.haml +++ b/app/views/u2f/_authenticate.html.haml @@ -1,6 +1,7 @@ #js-authenticate-u2f %a.btn.btn-block.btn-info#js-login-2fa-device{ href: '#' } Sign in via 2FA code +-# haml-lint:disable InlineJavaScript %script#js-authenticate-u2f-not-supported{ type: "text/template" } %p Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer). diff --git a/app/views/u2f/_register.html.haml b/app/views/u2f/_register.html.haml index 79e8f8d0e89..cc0e93c0755 100644 --- a/app/views/u2f/_register.html.haml +++ b/app/views/u2f/_register.html.haml @@ -1,5 +1,6 @@ #js-register-u2f +-# haml-lint:disable InlineJavaScript %script#js-register-u2f-not-supported{ type: "text/template" } %p Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer). diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 28a5e5da037..328db19be29 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -24,6 +24,7 @@ - gcp_cluster:cluster_wait_for_app_installation - gcp_cluster:wait_for_cluster_creation - gcp_cluster:check_gcp_project_billing +- gcp_cluster:cluster_wait_for_ingress_ip_address - github_import_advance_stage - github_importer:github_import_import_diff_note @@ -84,6 +85,7 @@ - new_note - pages - pages_domain_verification +- plugin - post_receive - process_commit - project_cache diff --git a/app/workers/cluster_wait_for_ingress_ip_address_worker.rb b/app/workers/cluster_wait_for_ingress_ip_address_worker.rb new file mode 100644 index 00000000000..8ba5951750c --- /dev/null +++ b/app/workers/cluster_wait_for_ingress_ip_address_worker.rb @@ -0,0 +1,11 @@ +class ClusterWaitForIngressIpAddressWorker + include ApplicationWorker + include ClusterQueue + include ClusterApplications + + def perform(app_name, app_id) + find_application(app_name, app_id) do |app| + Clusters::Applications::CheckIngressIpAddressService.new(app).execute + end + end +end diff --git a/app/workers/plugin_worker.rb b/app/workers/plugin_worker.rb new file mode 100644 index 00000000000..bfcc683d99a --- /dev/null +++ b/app/workers/plugin_worker.rb @@ -0,0 +1,15 @@ +class PluginWorker + include ApplicationWorker + + sidekiq_options retry: false + + def perform(file_name, data) + success, message = Gitlab::Plugin.execute(file_name, data) + + unless success + Gitlab::PluginLogger.error("Plugin Error => #{file_name}: #{message}") + end + + true + end +end diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb index d80b3b15840..68960f72bf6 100644 --- a/app/workers/remove_expired_members_worker.rb +++ b/app/workers/remove_expired_members_worker.rb @@ -5,7 +5,7 @@ class RemoveExpiredMembersWorker def perform Member.expired.find_each do |member| begin - Members::AuthorizedDestroyService.new(member).execute + Members::DestroyService.new.execute(member, skip_authorization: true) rescue => ex logger.error("Expired Member ID=#{member.id} cannot be removed - #{ex}") end diff --git a/changelogs/unreleased/24774-clear-the-Labels-dropdown-search-filter.yml b/changelogs/unreleased/24774-clear-the-Labels-dropdown-search-filter.yml new file mode 100644 index 00000000000..b909bb2d021 --- /dev/null +++ b/changelogs/unreleased/24774-clear-the-Labels-dropdown-search-filter.yml @@ -0,0 +1,5 @@ +--- +title: Clear the Labels dropdown search filter after a selection is made +merge_request: 17393 +author: Andrew Torres +type: changed diff --git a/changelogs/unreleased/30665-add-email-button-to-new-issue-by-email.yml b/changelogs/unreleased/30665-add-email-button-to-new-issue-by-email.yml new file mode 100644 index 00000000000..175b3103d90 --- /dev/null +++ b/changelogs/unreleased/30665-add-email-button-to-new-issue-by-email.yml @@ -0,0 +1,4 @@ +--- +title: Add email button to new issue by email +merge_request: 10942 +author: Islam Wazery diff --git a/changelogs/unreleased/40502-osw-keep-link-when-redacting-unauthorized-objects.yml b/changelogs/unreleased/40502-osw-keep-link-when-redacting-unauthorized-objects.yml new file mode 100644 index 00000000000..dddd8473df5 --- /dev/null +++ b/changelogs/unreleased/40502-osw-keep-link-when-redacting-unauthorized-objects.yml @@ -0,0 +1,5 @@ +--- +title: Keep link when redacting unauthorized object links +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/41777-include-cycle-time-in-usage-ping.yml b/changelogs/unreleased/41777-include-cycle-time-in-usage-ping.yml new file mode 100644 index 00000000000..8d8a5dfefa3 --- /dev/null +++ b/changelogs/unreleased/41777-include-cycle-time-in-usage-ping.yml @@ -0,0 +1,5 @@ +--- +title: Include cycle time in usage ping data +merge_request: 16973 +author: +type: added diff --git a/changelogs/unreleased/41851-enable-eslint-codeclimate.yml b/changelogs/unreleased/41851-enable-eslint-codeclimate.yml new file mode 100644 index 00000000000..98924f3eae8 --- /dev/null +++ b/changelogs/unreleased/41851-enable-eslint-codeclimate.yml @@ -0,0 +1,5 @@ +--- +title: Enables eslint in codeclimate job +merge_request: 17392 +author: +type: other diff --git a/changelogs/unreleased/42434-allow-commits-endpoint-to-work-over-all-commits.yml b/changelogs/unreleased/42434-allow-commits-endpoint-to-work-over-all-commits.yml new file mode 100644 index 00000000000..c596a88ba0b --- /dev/null +++ b/changelogs/unreleased/42434-allow-commits-endpoint-to-work-over-all-commits.yml @@ -0,0 +1,5 @@ +--- +title: Allow commits endpoint to work over all commits of a repository +merge_request: 17182 +author: +type: added diff --git a/changelogs/unreleased/42643-persist-external-ip-of-ingress-controller-gke.yml b/changelogs/unreleased/42643-persist-external-ip-of-ingress-controller-gke.yml new file mode 100644 index 00000000000..35457db82f4 --- /dev/null +++ b/changelogs/unreleased/42643-persist-external-ip-of-ingress-controller-gke.yml @@ -0,0 +1,5 @@ +--- +title: Display ingress IP address in the Kubernetes page +merge_request: 17052 +author: +type: added diff --git a/changelogs/unreleased/42946-update-pipeline-cancel-tooltip-to-stop.yml b/changelogs/unreleased/42946-update-pipeline-cancel-tooltip-to-stop.yml new file mode 100644 index 00000000000..0e566dd0abf --- /dev/null +++ b/changelogs/unreleased/42946-update-pipeline-cancel-tooltip-to-stop.yml @@ -0,0 +1,5 @@ +--- +title: Update tooltip on pipeline cancel to Stop (#42946) +merge_request: 17444 +author: +type: fixed diff --git a/changelogs/unreleased/43261-fix-prometheus-installation.yml b/changelogs/unreleased/43261-fix-prometheus-installation.yml new file mode 100644 index 00000000000..b5fc7980390 --- /dev/null +++ b/changelogs/unreleased/43261-fix-prometheus-installation.yml @@ -0,0 +1,5 @@ +--- +title: Allow Prometheus application to be installed from Cluster applications +merge_request: 17372 +author: +type: fixed diff --git a/changelogs/unreleased/43275-improve-variables-validation-message.yml b/changelogs/unreleased/43275-improve-variables-validation-message.yml new file mode 100644 index 00000000000..88ef93123a0 --- /dev/null +++ b/changelogs/unreleased/43275-improve-variables-validation-message.yml @@ -0,0 +1,5 @@ +--- +title: Remove duplicated error message on duplicate variable validation +merge_request: 17135 +author: +type: fixed diff --git a/changelogs/unreleased/43315-gpg-popover.yml b/changelogs/unreleased/43315-gpg-popover.yml new file mode 100644 index 00000000000..69238aa8075 --- /dev/null +++ b/changelogs/unreleased/43315-gpg-popover.yml @@ -0,0 +1,5 @@ +--- +title: Fixes gpg popover layout +merge_request: 17323 +author: +type: fixed diff --git a/changelogs/unreleased/43489-display-runner-ip.yml b/changelogs/unreleased/43489-display-runner-ip.yml new file mode 100644 index 00000000000..621c2ec709a --- /dev/null +++ b/changelogs/unreleased/43489-display-runner-ip.yml @@ -0,0 +1,5 @@ +--- +title: Display Runner IP Address +merge_request: 17286 +author: +type: added diff --git a/changelogs/unreleased/43510-merge-requests-and-issues-don-t-show-for-all-subgroups.yml b/changelogs/unreleased/43510-merge-requests-and-issues-don-t-show-for-all-subgroups.yml new file mode 100644 index 00000000000..e163c04f430 --- /dev/null +++ b/changelogs/unreleased/43510-merge-requests-and-issues-don-t-show-for-all-subgroups.yml @@ -0,0 +1,6 @@ +--- +title: Ensure group issues and merge requests pages show results from subgroups when + there are no results from the current group +merge_request: 17312 +author: +type: fixed diff --git a/changelogs/unreleased/43531-500-error-searching-wiki-incompatible-character-encodings-utf-8-and-ascii-8bit.yml b/changelogs/unreleased/43531-500-error-searching-wiki-incompatible-character-encodings-utf-8-and-ascii-8bit.yml new file mode 100644 index 00000000000..173710412a5 --- /dev/null +++ b/changelogs/unreleased/43531-500-error-searching-wiki-incompatible-character-encodings-utf-8-and-ascii-8bit.yml @@ -0,0 +1,5 @@ +--- +title: Fix code and wiki search results pages when non-ASCII text is displayed +merge_request: 17413 +author: +type: fixed diff --git a/changelogs/unreleased/43532-error-on-admin-applications-prometheus-template.yml b/changelogs/unreleased/43532-error-on-admin-applications-prometheus-template.yml new file mode 100644 index 00000000000..25bcbf2fbab --- /dev/null +++ b/changelogs/unreleased/43532-error-on-admin-applications-prometheus-template.yml @@ -0,0 +1,5 @@ +--- +title: Fixes Prometheus admin configuration page +merge_request: 17377 +author: +type: fixed diff --git a/changelogs/unreleased/43643-fix-mr-label-filtering.yml b/changelogs/unreleased/43643-fix-mr-label-filtering.yml new file mode 100644 index 00000000000..32a44aef243 --- /dev/null +++ b/changelogs/unreleased/43643-fix-mr-label-filtering.yml @@ -0,0 +1,5 @@ +--- +title: Enable filtering MR list based on clicked label in MR sidebar +merge_request: 17390 +author: +type: fixed diff --git a/changelogs/unreleased/4826-create-empty-wiki-when-it-s-enabled.yml b/changelogs/unreleased/4826-create-empty-wiki-when-it-s-enabled.yml new file mode 100644 index 00000000000..c0fa8e2e377 --- /dev/null +++ b/changelogs/unreleased/4826-create-empty-wiki-when-it-s-enabled.yml @@ -0,0 +1,5 @@ +--- +title: Make sure wiki exists when it's enabled +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/assignees-vue-component-missing-data-container.yml b/changelogs/unreleased/assignees-vue-component-missing-data-container.yml new file mode 100644 index 00000000000..233d983b415 --- /dev/null +++ b/changelogs/unreleased/assignees-vue-component-missing-data-container.yml @@ -0,0 +1,5 @@ +--- +title: Add Assignees vue component missing data container +merge_request: 17426 +author: George Tsiolis +type: fixed diff --git a/changelogs/unreleased/dz-namespace-id-not-null.yml b/changelogs/unreleased/dz-namespace-id-not-null.yml new file mode 100644 index 00000000000..07b32aeeb86 --- /dev/null +++ b/changelogs/unreleased/dz-namespace-id-not-null.yml @@ -0,0 +1,5 @@ +--- +title: Add NOT NULL constraint to projects.namespace_id +merge_request: 17448 +author: +type: other diff --git a/changelogs/unreleased/dz-system-hooks-plugins.yml b/changelogs/unreleased/dz-system-hooks-plugins.yml new file mode 100644 index 00000000000..e6eb1dfb03b --- /dev/null +++ b/changelogs/unreleased/dz-system-hooks-plugins.yml @@ -0,0 +1,5 @@ +--- +title: Add ability to use external plugins as an alternative to system hooks +merge_request: 17003 +author: +type: added diff --git a/changelogs/unreleased/feature-edit_pages_domain.yml b/changelogs/unreleased/feature-edit_pages_domain.yml new file mode 100644 index 00000000000..bd0af53296c --- /dev/null +++ b/changelogs/unreleased/feature-edit_pages_domain.yml @@ -0,0 +1,5 @@ +--- +title: 'Pages custom domain: allow update of key/certificate' +merge_request: 17376 +author: rfwatson +type: changed diff --git a/changelogs/unreleased/feature-gb-pipeline-variable-expressions.yml b/changelogs/unreleased/feature-gb-pipeline-variable-expressions.yml new file mode 100644 index 00000000000..28820649af3 --- /dev/null +++ b/changelogs/unreleased/feature-gb-pipeline-variable-expressions.yml @@ -0,0 +1,5 @@ +--- +title: Add catch-up background migration to migrate pipeline stages +merge_request: 15741 +author: +type: performance diff --git a/changelogs/unreleased/fj-28141-redirection-loop.yml b/changelogs/unreleased/fj-28141-redirection-loop.yml new file mode 100644 index 00000000000..db7e109a06e --- /dev/null +++ b/changelogs/unreleased/fj-28141-redirection-loop.yml @@ -0,0 +1,5 @@ +--- +title: Removing the two factor check when the user sets a new password +merge_request: 17457 +author: +type: fixed diff --git a/changelogs/unreleased/issue-edit-shortcut.yml b/changelogs/unreleased/issue-edit-shortcut.yml new file mode 100644 index 00000000000..2b29b2bc03f --- /dev/null +++ b/changelogs/unreleased/issue-edit-shortcut.yml @@ -0,0 +1,5 @@ +--- +title: Fixed issue edit shortcut not opening edit form +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/merge-requests-api-filter-by-branch.yml b/changelogs/unreleased/merge-requests-api-filter-by-branch.yml new file mode 100644 index 00000000000..03a7e4d0f71 --- /dev/null +++ b/changelogs/unreleased/merge-requests-api-filter-by-branch.yml @@ -0,0 +1,5 @@ +--- +title: Add support for filtering by source and target branch to merge requests API +merge_request: +author: +type: added diff --git a/changelogs/unreleased/refactor-move-assignees-vue-component.yml b/changelogs/unreleased/refactor-move-assignees-vue-component.yml new file mode 100644 index 00000000000..98cfa6b4c81 --- /dev/null +++ b/changelogs/unreleased/refactor-move-assignees-vue-component.yml @@ -0,0 +1,5 @@ +--- +title: Move Assignees vue component +merge_request: 16952 +author: George Tsiolis +type: performance diff --git a/changelogs/unreleased/refactor-move-board-new-issue-vue-component.yml b/changelogs/unreleased/refactor-move-board-new-issue-vue-component.yml new file mode 100644 index 00000000000..20d05530513 --- /dev/null +++ b/changelogs/unreleased/refactor-move-board-new-issue-vue-component.yml @@ -0,0 +1,5 @@ +--- +title: Move BoardNewIssue vue component +merge_request: 16947 +author: George Tsiolis +type: performance diff --git a/changelogs/unreleased/zj-gitaly-encoding-issue.yml b/changelogs/unreleased/zj-gitaly-encoding-issue.yml new file mode 100644 index 00000000000..073d8f38e4b --- /dev/null +++ b/changelogs/unreleased/zj-gitaly-encoding-issue.yml @@ -0,0 +1,5 @@ +--- +title: Encode branch name as binary before creating a RPC request to copy attributes +merge_request: 17291 +author: +type: fixed diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index b89f0419b91..2079d3acb72 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -103,4 +103,6 @@ Doorkeeper.configure do # Some applications require dynamic query parameters on their request_uri # set to true if you want this to be allowed # wildcard_redirect_uri false + + base_controller 'ApplicationController' end diff --git a/config/initializers/lograge.rb b/config/initializers/lograge.rb index 8560d24526f..114c1cb512f 100644 --- a/config/initializers/lograge.rb +++ b/config/initializers/lograge.rb @@ -12,9 +12,14 @@ unless Sidekiq.server? config.lograge.logger = ActiveSupport::Logger.new(filename) # Add request parameters to log output config.lograge.custom_options = lambda do |event| + params = event.payload[:params] + .except(*%w(controller action format)) + .each_pair + .map { |k, v| { key: k, value: v } } + payload = { time: event.time.utc.iso8601(3), - params: event.payload[:params].except(*%w(controller action format)), + params: params, remote_ip: event.payload[:remote_ip], user_id: event.payload[:user_id], username: event.payload[:username] diff --git a/config/routes/project.rb b/config/routes/project.rb index 8fe545b721e..34636285c51 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -55,7 +55,7 @@ constraints(ProjectUrlConstrainer.new) do end resource :pages, only: [:show, :destroy] do - resources :domains, only: [:show, :new, :create, :destroy], controller: 'pages_domains', constraints: { id: %r{[^/]+} } do + resources :domains, except: :index, controller: 'pages_domains', constraints: { id: %r{[^/]+} } do member do post :verify end @@ -103,6 +103,7 @@ constraints(ProjectUrlConstrainer.new) do post :toggle_subscription post :remove_wip post :assign_related_issues + get :discussions, format: :json post :rebase scope constraints: { format: nil }, action: :show do diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index f037e3d1221..4845dc28a4a 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -68,3 +68,4 @@ - [project_migrate_hashed_storage, 1] - [storage_migrator, 1] - [pages_domain_verification, 1] + - [plugin, 1] diff --git a/config/webpack.config.js b/config/webpack.config.js index be827903a6a..4611544226d 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -1,88 +1,60 @@ 'use strict'; -var crypto = require('crypto'); -var fs = require('fs'); -var path = require('path'); -var glob = require('glob'); -var webpack = require('webpack'); -var StatsWriterPlugin = require('webpack-stats-plugin').StatsWriterPlugin; -var CopyWebpackPlugin = require('copy-webpack-plugin'); -var CompressionPlugin = require('compression-webpack-plugin'); -var NameAllModulesPlugin = require('name-all-modules-plugin'); -var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; -var WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin'); - -var ROOT_PATH = path.resolve(__dirname, '..'); -var IS_PRODUCTION = process.env.NODE_ENV === 'production'; -var IS_DEV_SERVER = process.argv.join(' ').indexOf('webpack-dev-server') !== -1; -var DEV_SERVER_HOST = process.env.DEV_SERVER_HOST || 'localhost'; -var DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 3808; -var DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false'; -var WEBPACK_REPORT = process.env.WEBPACK_REPORT; -var NO_COMPRESSION = process.env.NO_COMPRESSION; - -// generate automatic entry points -var autoEntries = {}; -var pageEntries = glob.sync('pages/**/index.js', { cwd: path.join(ROOT_PATH, 'app/assets/javascripts') }); - -// filter out entries currently imported dynamically in dispatcher.js -var dispatcher = fs.readFileSync(path.join(ROOT_PATH, 'app/assets/javascripts/dispatcher.js')).toString(); -var dispatcherChunks = dispatcher.match(/(?!import\(')\.\/pages\/[^']+/g); - -function generateAutoEntries(path, prefix = '.') { - const chunkPath = path.replace(/\/index\.js$/, ''); - if (!dispatcherChunks.includes(`${prefix}/${chunkPath}`)) { +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const glob = require('glob'); +const webpack = require('webpack'); +const StatsWriterPlugin = require('webpack-stats-plugin').StatsWriterPlugin; +const CopyWebpackPlugin = require('copy-webpack-plugin'); +const CompressionPlugin = require('compression-webpack-plugin'); +const NameAllModulesPlugin = require('name-all-modules-plugin'); +const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; +const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin'); + +const ROOT_PATH = path.resolve(__dirname, '..'); +const IS_PRODUCTION = process.env.NODE_ENV === 'production'; +const IS_DEV_SERVER = process.argv.join(' ').indexOf('webpack-dev-server') !== -1; +const DEV_SERVER_HOST = process.env.DEV_SERVER_HOST || 'localhost'; +const DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 3808; +const DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false'; +const WEBPACK_REPORT = process.env.WEBPACK_REPORT; +const NO_COMPRESSION = process.env.NO_COMPRESSION; + +let autoEntriesCount = 0; +let watchAutoEntries = []; + +function generateEntries() { + // generate automatic entry points + const autoEntries = {}; + const pageEntries = glob.sync('pages/**/index.js', { cwd: path.join(ROOT_PATH, 'app/assets/javascripts') }); + watchAutoEntries = [ + path.join(ROOT_PATH, 'app/assets/javascripts/pages/'), + ]; + + function generateAutoEntries(path, prefix = '.') { + const chunkPath = path.replace(/\/index\.js$/, ''); const chunkName = chunkPath.replace(/\//g, '.'); autoEntries[chunkName] = `${prefix}/${path}`; } -} -pageEntries.forEach(( path ) => generateAutoEntries(path)); + pageEntries.forEach(( path ) => generateAutoEntries(path)); -// report our auto-generated bundle count -var autoEntriesCount = Object.keys(autoEntries).length; -console.log(`${autoEntriesCount} entries from '/pages' automatically added to webpack output.`); + autoEntriesCount = Object.keys(autoEntries).length; -var config = { - // because sqljs requires fs. - node: { - fs: "empty" - }, - context: path.join(ROOT_PATH, 'app/assets/javascripts'), - entry: { + const manualEntries = { balsamiq_viewer: './blob/balsamiq_viewer.js', - blob: './blob_edit/blob_bundle.js', - common: './commons/index.js', - common_vue: './vue_shared/vue_resource_interceptor.js', - cycle_analytics: './cycle_analytics/cycle_analytics_bundle.js', - commit_pipelines: './commit/pipelines/pipelines_bundle.js', - diff_notes: './diff_notes/diff_notes_bundle.js', - environments: './environments/environments_bundle.js', - environments_folder: './environments/folder/environments_folder_bundle.js', - filtered_search: './filtered_search/filtered_search_bundle.js', - help: './help/help.js', - merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js', monitoring: './monitoring/monitoring_bundle.js', - network: './network/network_bundle.js', + mr_notes: './mr_notes/index.js', notebook_viewer: './blob/notebook_viewer.js', pdf_viewer: './blob/pdf_viewer.js', - pipelines: './pipelines/pipelines_bundle.js', - pipelines_details: './pipelines/pipeline_details_bundle.js', - profile: './profile/profile_bundle.js', - project_import_gl: './projects/project_import_gitlab_project.js', protected_branches: './protected_branches', - protected_tags: './protected_tags', registry_list: './registry/index.js', - sidebar: './sidebar/sidebar_bundle.js', - snippet: './snippet/snippet_bundle.js', sketch_viewer: './blob/sketch_viewer.js', stl_viewer: './blob/stl_viewer.js', terminal: './terminal/terminal_bundle.js', - ui_development_kit: './ui_development_kit.js', - vue_merge_request_widget: './vue_merge_request_widget/index.js', two_factor_auth: './two_factor_auth.js', - common: './commons/index.js', common_vue: './vue_shared/vue_resource_interceptor.js', locale: './locale/index.js', @@ -92,7 +64,15 @@ var config = { test: './test.js', u2f: ['vendor/u2f'], webpack_runtime: './webpack.js', - }, + }; + + return Object.assign(manualEntries, autoEntries); +} + +const config = { + context: path.join(ROOT_PATH, 'app/assets/javascripts'), + + entry: generateEntries, output: { path: path.join(ROOT_PATH, 'public/assets/webpack'), @@ -181,7 +161,7 @@ var config = { new StatsWriterPlugin({ filename: 'manifest.json', transform: function(data, opts) { - var stats = opts.compiler.getStats().toJson({ + const stats = opts.compiler.getStats().toJson({ chunkModules: false, source: false, chunks: false, @@ -244,27 +224,12 @@ var config = { new webpack.optimize.CommonsChunkPlugin({ name: 'common_vue', chunks: [ - 'boards', - 'commit_pipelines', - 'cycle_analytics', - 'deploy_keys', - 'diff_notes', - 'environments', - 'environments_folder', - 'filtered_search', - 'groups', - 'merge_conflicts', 'monitoring', + 'mr_notes', 'notebook_viewer', 'pdf_viewer', - 'pipelines', - 'pipelines_details', 'registry_list', 'ide', - 'schedule_form', - 'schedules_index', - 'sidebar', - 'vue_merge_request_widget', ], minChunks: function(module, count) { return module.resource && (/vue_shared/).test(module.resource); @@ -310,11 +275,15 @@ var config = { 'images': path.join(ROOT_PATH, 'app/assets/images'), 'vendor': path.join(ROOT_PATH, 'vendor/assets/javascripts'), 'vue$': 'vue/dist/vue.esm.js', + 'spec': path.join(ROOT_PATH, 'spec/javascripts'), } - } -} + }, -config.entry = Object.assign({}, autoEntries, config.entry); + // sqljs requires fs + node: { + fs: 'empty', + }, +}; if (IS_PRODUCTION) { config.devtool = 'source-map'; @@ -351,7 +320,24 @@ if (IS_DEV_SERVER) { }; config.plugins.push( // watch node_modules for changes if we encounter a missing module compile error - new WatchMissingNodeModulesPlugin(path.join(ROOT_PATH, 'node_modules')) + new WatchMissingNodeModulesPlugin(path.join(ROOT_PATH, 'node_modules')), + + // watch for changes to our automatic entry point modules + { + apply(compiler) { + compiler.plugin('emit', (compilation, callback) => { + compilation.contextDependencies = [ + ...compilation.contextDependencies, + ...watchAutoEntries, + ]; + + // report our auto-generated bundle count + console.log(`${autoEntriesCount} entries from '/pages' automatically added to webpack output.`); + + callback(); + }) + }, + } ); if (DEV_SERVER_LIVERELOAD) { config.plugins.push(new webpack.HotModuleReplacementPlugin()); diff --git a/db/migrate/20180212030105_add_external_ip_to_clusters_applications_ingress.rb b/db/migrate/20180212030105_add_external_ip_to_clusters_applications_ingress.rb new file mode 100644 index 00000000000..dbe09a43aa7 --- /dev/null +++ b/db/migrate/20180212030105_add_external_ip_to_clusters_applications_ingress.rb @@ -0,0 +1,9 @@ +class AddExternalIpToClustersApplicationsIngress < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :clusters_applications_ingress, :external_ip, :string + end +end diff --git a/db/migrate/20180222043024_add_ip_address_to_runner.rb b/db/migrate/20180222043024_add_ip_address_to_runner.rb new file mode 100644 index 00000000000..bf00560b5a8 --- /dev/null +++ b/db/migrate/20180222043024_add_ip_address_to_runner.rb @@ -0,0 +1,9 @@ +class AddIpAddressToRunner < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_runners, :ip_address, :string + end +end diff --git a/db/post_migrate/20180212101828_add_tmp_partial_null_index_to_builds.rb b/db/post_migrate/20180212101828_add_tmp_partial_null_index_to_builds.rb new file mode 100644 index 00000000000..e55e2e6f888 --- /dev/null +++ b/db/post_migrate/20180212101828_add_tmp_partial_null_index_to_builds.rb @@ -0,0 +1,14 @@ +class AddTmpPartialNullIndexToBuilds < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + def up + add_concurrent_index(:ci_builds, :id, where: 'stage_id IS NULL', + name: 'tmp_id_partial_null_index') + end + + def down + remove_concurrent_index_by_name(:ci_builds, 'tmp_id_partial_null_index') + end +end diff --git a/db/post_migrate/20180212101928_schedule_build_stage_migration.rb b/db/post_migrate/20180212101928_schedule_build_stage_migration.rb new file mode 100644 index 00000000000..df15b2cd9d4 --- /dev/null +++ b/db/post_migrate/20180212101928_schedule_build_stage_migration.rb @@ -0,0 +1,29 @@ +class ScheduleBuildStageMigration < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + MIGRATION = 'MigrateBuildStage'.freeze + BATCH_SIZE = 500 + + disable_ddl_transaction! + + class Build < ActiveRecord::Base + include EachBatch + self.table_name = 'ci_builds' + end + + def up + disable_statement_timeout + + Build.where('stage_id IS NULL').tap do |relation| + queue_background_migration_jobs_by_range_at_intervals(relation, + MIGRATION, + 5.minutes, + batch_size: BATCH_SIZE) + end + end + + def down + # noop + end +end diff --git a/db/post_migrate/20180212102028_remove_tmp_partial_null_index_from_builds.rb b/db/post_migrate/20180212102028_remove_tmp_partial_null_index_from_builds.rb new file mode 100644 index 00000000000..ed7b1fc72f4 --- /dev/null +++ b/db/post_migrate/20180212102028_remove_tmp_partial_null_index_from_builds.rb @@ -0,0 +1,14 @@ +class RemoveTmpPartialNullIndexFromBuilds < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + def up + remove_concurrent_index_by_name(:ci_builds, 'tmp_id_partial_null_index') + end + + def down + add_concurrent_index(:ci_builds, :id, where: 'stage_id IS NULL', + name: 'tmp_id_partial_null_index') + end +end diff --git a/db/post_migrate/20180301084653_change_project_namespace_id_not_null.rb b/db/post_migrate/20180301084653_change_project_namespace_id_not_null.rb new file mode 100644 index 00000000000..0342372cbed --- /dev/null +++ b/db/post_migrate/20180301084653_change_project_namespace_id_not_null.rb @@ -0,0 +1,29 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class ChangeProjectNamespaceIdNotNull < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + class Project < ActiveRecord::Base + self.table_name = 'projects' + include EachBatch + end + + BATCH_SIZE = 1000 + + DOWNTIME = false + + disable_ddl_transaction! + + def up + Project.where(namespace_id: nil).each_batch(of: BATCH_SIZE) do |batch| + batch.delete_all + end + + change_column_null :projects, :namespace_id, false + end + + def down + change_column_null :projects, :namespace_id, true + end +end diff --git a/db/schema.rb b/db/schema.rb index 5bb461169f1..773cf8b4d3f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180216121030) do +ActiveRecord::Schema.define(version: 20180301084653) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -437,6 +437,7 @@ ActiveRecord::Schema.define(version: 20180216121030) do t.boolean "run_untagged", default: true, null: false t.boolean "locked", default: false, null: false t.integer "access_level", default: 0, null: false + t.string "ip_address" end add_index "ci_runners", ["contacted_at"], name: "index_ci_runners_on_contacted_at", using: :btree @@ -569,6 +570,7 @@ ActiveRecord::Schema.define(version: 20180216121030) do t.string "version", null: false t.string "cluster_ip" t.text "status_reason" + t.string "external_ip" end create_table "clusters_applications_prometheus", force: :cascade do |t| @@ -1426,7 +1428,7 @@ ActiveRecord::Schema.define(version: 20180216121030) do t.datetime "created_at" t.datetime "updated_at" t.integer "creator_id" - t.integer "namespace_id" + t.integer "namespace_id", null: false t.datetime "last_activity_at" t.string "import_url" t.integer "visibility_level", default: 0, null: false diff --git a/doc/README.md b/doc/README.md index 46fcb7c6baf..fb7a23e2750 100644 --- a/doc/README.md +++ b/doc/README.md @@ -1,5 +1,4 @@ --- -toc: false comments: false --- @@ -8,15 +7,9 @@ comments: false Welcome to [GitLab](https://about.gitlab.com/), a Git-based fully featured platform for software development! -GitLab offers the most scalable Git-based fully integrated platform for software development, with flexible products and subscription plans. - -With GitLab self-hosted, you deploy your own GitLab instance on-premises or on a private cloud of your choice. GitLab self-hosted is available for [free and with paid subscriptions](https://about.gitlab.com/products/): Libre, Starter, Premium, and Ultimate. - -Every feature available in Libre is also available in Starter, Premium, and Ultimate. -Starter features are also available in Premium and Ultimate, and Premium features are also -available in Ultimate. - -GitLab.com is our SaaS offering. It's hosted, managed, and administered by GitLab, with [free and paid plans](https://about.gitlab.com/gitlab-com/) for individuals and teams: Free, Bronze, Silver, and Gold. +GitLab offers the most scalable Git-based fully integrated platform for +software development, with flexible products and subscriptions. +To understand what features you have access to, check the [GitLab subscriptions](#gitlab-subscriptions) below. ## Shortcuts to GitLab's most visited docs @@ -124,8 +117,6 @@ Manage your [repositories](user/project/repository/index.md) from the UI (user i - [GitLab Integration](integration/README.md): Integrate with multiple third-party services with GitLab to allow external issue trackers and external authentication. - [Trello Power-Up](integration/trello_power_up.md): Integrate with GitLab's Trello Power-Up ----- - ## Administrator documentation [Administration documentation](administration/index.md) applies to admin users of GitLab @@ -143,3 +134,42 @@ Learn how to contribute to GitLab: - [Development](development/README.md): All styleguides and explanations how to contribute. - [Legal](legal/README.md): Contributor license agreements. - [Writing documentation](development/writing_documentation.md): Contributing to GitLab Docs. + +## GitLab subscriptions + +You have two options to use GitLab: + +- GitLab self-hosted: Install, administer, and maintain your own GitLab instance. +- GitLab.com: GitLab's SaaS offering. You don't need to install anything to use GitLab.com, +you only need to [sign up](https://gitlab.com/users/sign_in) and start using GitLab +straight away. + +### GitLab self-hosted + +With GitLab self-hosted, you deploy your own GitLab instance on-premises or on a private cloud of your choice. GitLab self-hosted is available for [free and with paid subscriptions](https://about.gitlab.com/products/): Libre, Starter, Premium, and Ultimate. + +Every feature available in Libre is also available in Starter, Premium, and Ultimate. +Starter features are also available in Premium and Ultimate, and Premium features are also +available in Ultimate. + +### GitLab.com + +GitLab.com is hosted, managed, and administered by GitLab, Inc., with +[free and paid subscriptions](https://about.gitlab.com/gitlab-com/) for individuals +and teams: Free, Bronze, Silver, and Gold. + +GitLab.com subscriptions grants access +to the same features available in GitLab self-hosted, **expect +[administration](administration/index.md) tools and settings**: + +- GitLab.com Free includes the same features available in GitLab Libre +- GitLab.com Bronze includes the same features available in GitLab Starter +- GitLab.com Silver includes the same features available in GitLab Premium +- GitLab.com Gold includes the same features available in GitLab Ultimate + +For supporting the open source community and encouraging the development of +open source projects, GitLab grants access to **Gold** features +for all GitLab.com **public** projects, regardless of the subscription. + +To know more about GitLab subscriptions and licensing, please refer to the +[GitLab Product Marketing Handbook](https://about.gitlab.com/handbook/marketing/product-marketing/#tiers). diff --git a/doc/administration/plugins.md b/doc/administration/plugins.md new file mode 100644 index 00000000000..c91ac3012b9 --- /dev/null +++ b/doc/administration/plugins.md @@ -0,0 +1,66 @@ +# Plugins + +**Note:** Plugins must be configured on the filesystem of the GitLab +server. Only GitLab server administrators will be able to complete these tasks. +Please explore [system hooks] or [webhooks] as an option if you do not +have filesystem access. + +Introduced in GitLab 10.6. + +A plugin will run on each event so it's up to you to filter events or projects within a plugin code. You can have as many plugins as you want. Each plugin will be triggered by GitLab asynchronously in case of an event. For a list of events please see [system hooks] documentation. + +## Setup + +Plugins must be placed directly into `plugins` directory, subdirectories will be ignored. +There is an `example` directory inside `plugins` where you can find some basic examples. + +Follow the steps below to set up a custom hook: + +1. On the GitLab server, navigate to the project's plugin directory. + For an installation from source the path is usually + `/home/git/gitlab/plugins/`. For Omnibus installs the path is + usually `/opt/gitlab/embedded/service/gitlab-rails/plugins`. +1. Inside the `plugins` directory, create a file with a name of your choice, but without spaces or special characters. +1. Make the hook file executable and make sure it's owned by the git user. +1. Write the code to make the plugin function as expected. Plugin can be + in any language. Ensure the 'shebang' at the top properly reflects the language + type. For example, if the script is in Ruby the shebang will probably be + `#!/usr/bin/env ruby`. +1. The data to the plugin will be provided as JSON on STDIN. It will be exactly same as one for [system hooks] + +That's it! Assuming the plugin code is properly implemented the hook will fire +as appropriate. Plugins file list is updated for each event. There is no need to restart GitLab to apply a new plugin. + +If a plugin executes with non-zero exit code or GitLab fails to execute it, a +message will be logged to `plugin.log`. + +## Validation + +Writing own plugin can be tricky and its easier if you can check it without altering the system. +We provided a rake task you can use with staging environment to test your plugin before using it in production. +The rake task will use a sample data and execute each of plugins. By output you should be able to determine if +system sees your plugin and if it was executed without errors. + +```bash +# Omnibus installations +sudo gitlab-rake plugins:validate + +# Installations from source +bundle exec rake plugins:validate RAILS_ENV=production +``` + +Example of output can be next: + +``` +-> bundle exec rake plugins:validate RAILS_ENV=production +Validating plugins from /plugins directory +* /home/git/gitlab/plugins/save_to_file.clj succeed (zero exit code) +* /home/git/gitlab/plugins/save_to_file.rb failure (non-zero exit code) +``` + +[hooks]: https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks#Server-Side-Hooks +[system hooks]: ../system_hooks/system_hooks.md +[webhooks]: ../user/project/integrations/webhooks.md +[5073]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5073 +[93]: https://gitlab.com/gitlab-org/gitlab-shell/merge_requests/93 + diff --git a/doc/api/commits.md b/doc/api/commits.md index 2c745d00887..55c673fd06a 100644 --- a/doc/api/commits.md +++ b/doc/api/commits.md @@ -14,6 +14,9 @@ GET /projects/:id/repository/commits | `ref_name` | string | no | The name of a repository branch or tag or if not given the default branch | | `since` | string | no | Only commits after or on this date will be returned in ISO 8601 format YYYY-MM-DDTHH:MM:SSZ | | `until` | string | no | Only commits before or on this date will be returned in ISO 8601 format YYYY-MM-DDTHH:MM:SSZ | +| `path` | string | no | The file path | +| `all` | boolean | no | Retrieve every commit from the repository | + ```bash curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/5/repository/commits" diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index 2957a0a5f48..6ce021cb4bf 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -47,6 +47,8 @@ Parameters: | `author_id` | integer | no | Returns merge requests created by the given user `id`. Combine with `scope=all` or `scope=assigned-to-me` | | `assignee_id` | integer | no | Returns merge requests assigned to the given user `id` | | `my_reaction_emoji` | string | no | Return merge requests reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ | +| `source_branch` | string | no | Return merge requests with the given source branch | +| `target_branch` | string | no | Return merge requests with the given target branch | | `search` | string | no | Search merge requests against their `title` and `description` | ```json @@ -162,6 +164,8 @@ Parameters: | `author_id` | integer | no | Returns merge requests created by the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ | | `assignee_id` | integer | no | Returns merge requests assigned to the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ | | `my_reaction_emoji` | string | no | Return merge requests reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ | +| `source_branch` | string | no | Return merge requests with the given source branch | +| `target_branch` | string | no | Return merge requests with the given target branch | | `search` | string | no | Search merge requests against their `title` and `description` | ```json diff --git a/doc/api/projects.md b/doc/api/projects.md index 4f4ab906c1a..b6442cfac22 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -1194,6 +1194,7 @@ GET /projects/:id/hooks/:hook_id "project_id": 3, "push_events": true, "issues_events": true, + "confidential_issues_events": true, "merge_requests_events": true, "tag_push_events": true, "note_events": true, @@ -1219,6 +1220,7 @@ POST /projects/:id/hooks | `url` | string | yes | The hook URL | | `push_events` | boolean | no | Trigger hook on push events | | `issues_events` | boolean | no | Trigger hook on issues events | +| `confidential_issues_events` | boolean | no | Trigger hook on confidential issues events | | `merge_requests_events` | boolean | no | Trigger hook on merge requests events | | `tag_push_events` | boolean | no | Trigger hook on tag push events | | `note_events` | boolean | no | Trigger hook on note events | @@ -1243,6 +1245,7 @@ PUT /projects/:id/hooks/:hook_id | `url` | string | yes | The hook URL | | `push_events` | boolean | no | Trigger hook on push events | | `issues_events` | boolean | no | Trigger hook on issues events | +| `confidential_issues_events` | boolean | no | Trigger hook on confidential issues events | | `merge_requests_events` | boolean | no | Trigger hook on merge requests events | | `tag_push_events` | boolean | no | Trigger hook on tag push events | | `note_events` | boolean | no | Trigger hook on note events | diff --git a/doc/api/services.md b/doc/api/services.md index 2928ab6cc75..92f12acbc73 100644 --- a/doc/api/services.md +++ b/doc/api/services.md @@ -619,6 +619,7 @@ Example response: "active": true, "push_events": true, "issues_events": true, + "confidential_issues_events": true, "merge_requests_events": true, "tag_push_events": true, "note_events": true, diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index f30a85b114e..23ce6a5f210 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -56,6 +56,9 @@ future GitLab releases.** | **CI_RUNNER_DESCRIPTION** | 8.10 | 0.5 | The description of the runner as saved in GitLab | | **CI_RUNNER_ID** | 8.10 | 0.5 | The unique id of runner being used | | **CI_RUNNER_TAGS** | 8.10 | 0.5 | The defined runner tags | +| **CI_RUNNER_VERSION** | all | 10.6 | GitLab Runner version that is executing the current job | +| **CI_RUNNER_REVISION** | all | 10.6 | GitLab Runner revision that is executing the current job | +| **CI_RUNNER_EXECUTABLE_ARCH** | all | 10.6 | The OS/architecture of the GitLab Runner executable (note that this is not necessarily the same as the environment of the executor) | | **CI_PIPELINE_ID** | 8.10 | 0.5 | The unique id of the current pipeline that GitLab CI uses internally | | **CI_PIPELINE_TRIGGERED** | all | all | The flag to indicate that job was [triggered] | | **CI_PIPELINE_SOURCE** | 10.0 | all | The source for this pipeline, one of: push, web, trigger, schedule, api, external. Pipelines created before 9.5 will have unknown as source | diff --git a/doc/development/ee_features.md b/doc/development/ee_features.md index f6a14de96b2..1eb90c30ebd 100644 --- a/doc/development/ee_features.md +++ b/doc/development/ee_features.md @@ -33,6 +33,40 @@ rest of the code should be as close to the CE files as possible. [single code base]: https://gitlab.com/gitlab-org/gitlab-ee/issues/2952#note_41016454 +### Detection of EE-only files + +For each commit (except on `master`), the `ee-files-location-check` CI job tries +to detect if there are any new files that are EE-only. If any file is detected, +the job fails with an explanation of why and what to do to make it pass. + +Basically, the fix is simple: `git mv <file> ee/<file>`. + +#### How to name your branches? + +For any EE branch, the job will try to detect its CE counterpart by removing any +`ee-` prefix or `-ee` suffix from the EE branch name, and matching the last +branch that contains it. + +For instance, from the EE branch `new-shiny-feature-ee` (or +`ee-new-shiny-feature`), the job would find the corresponding CE branches: + +- `new-shiny-feature` +- `ce-new-shiny-feature` +- `new-shiny-feature-ce` +- `my-super-new-shiny-feature-in-ce` + +#### Whitelist some EE-only files that cannot be moved to `ee/` + +The `ee-files-location-check` CI job provides a whitelist of files or folders +that cannot or should not be moved to `ee/`. Feel free to open an issue to +discuss adding a new file/folder to this whitelist. + +For instance, it was decided that moving EE-only files from `qa/` to `ee/qa/` +would make it difficult to build the `gitLab-{ce,ee}-qa` Docker images and it +was [not worth the complexity]. + +[not worth the complexity]: https://gitlab.com/gitlab-org/gitlab-ee/issues/4997#note_59764702 + ### EE-only features If the feature being developed is not present in any form in CE, we don't @@ -52,6 +86,11 @@ is applied not only to models. Here's a list of other examples: - `ee/app/validators/foo_attr_validator.rb` - `ee/app/workers/foo_worker.rb` +This works because for every path that are present in CE's eager-load/auto-load +paths, we add the same `ee/`-prepended path in [`config/application.rb`]. + +[`config/application.rb`]: https://gitlab.com/gitlab-org/gitlab-ee/blob/d278b76d6600a0e27d8019a0be27971ba23ab640/config/application.rb#L41-51 + ### EE features based on CE features For features that build on existing CE features, write a module in the diff --git a/doc/development/fe_guide/performance.md b/doc/development/fe_guide/performance.md index 14ac1133cc0..98e43931a02 100644 --- a/doc/development/fe_guide/performance.md +++ b/doc/development/fe_guide/performance.md @@ -36,6 +36,15 @@ If you are asynchronously adding content which contains lazy images then you nee `gl.lazyLoader.searchLazyImages()` which will search for lazy images and load them if needed. But in general it should be handled automatically through a `MutationObserver` in the lazy loading function. +### Animations + +Only animate `opacity` & `transform` properties. Other properties (such as `top`, `left`, `margin`, and `padding`) all cause +Layout to be recalculated, which is much more expensive. For details on this, see "Styles that Affect Layout" in +[High Performance Animations][high-perf-animations]. + +If you _do_ need to change layout (e.g. a sidebar that pushes main content over), prefer [FLIP][flip] to change expensive +properties once, and handle the actual animation with transforms. + ## Reducing Asset Footprint ### Page-specific JavaScript @@ -87,6 +96,7 @@ General tips: - Compress and minify assets wherever possible (For CSS/JS, Sprockets and webpack do this for us). - If some functionality can reasonably be achieved without adding extra libraries, avoid them. - Use page-specific JavaScript as described above to dynamically load libraries that are only needed on certain pages. +- [High Performance Animations][high-perf-animations] ------- @@ -105,3 +115,5 @@ General tips: [d3]: https://d3js.org/ [chartjs]: http://www.chartjs.org/ [page-specific-js-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/13bb9ed77f405c5f6ee4fdbc964ecf635c9a223f/app/views/projects/graphs/_head.html.haml#L6-8 +[high-perf-animations]: https://www.html5rocks.com/en/tutorials/speed/high-performance-animations/ +[flip]: https://aerotwist.com/blog/flip-your-animations/ diff --git a/doc/development/fe_guide/vue.md b/doc/development/fe_guide/vue.md index 6c93c29124d..09957feee17 100644 --- a/doc/development/fe_guide/vue.md +++ b/doc/development/fe_guide/vue.md @@ -507,6 +507,7 @@ This is the entry point for our store. You can use the following as a guide: import Vue from 'vue'; import Vuex from 'vuex'; import * as actions from './actions'; +import * as getters from './getters'; import * as mutations from './mutations'; Vue.use(Vuex); @@ -514,6 +515,7 @@ Vue.use(Vuex); export default new Vuex.Store({ actions, getters, + mutations, state: { users: [], }, diff --git a/doc/development/i18n/externalization.md b/doc/development/i18n/externalization.md index c0a325a83e9..c0ce49eb40b 100644 --- a/doc/development/i18n/externalization.md +++ b/doc/development/i18n/externalization.md @@ -107,104 +107,28 @@ You can mark that content for translation with: ### JavaScript files -In JavaScript we added the `__()` (double underscore parenthesis) function -for translations. +In JavaScript we added the `__()` (double underscore parenthesis) function that +you can import from the `~/locale` file. For instance: -In order to test JavaScript translations you have to change the GitLab localization to other language than English and you have to generate JSON files using `bundle exec rake gettext:po_to_json` or `bundle exec rake gettext:compile`. - -## Updating the PO files with the new content - -Now that the new content is marked for translation, we need to update the PO -files with the following command: - -```sh -bundle exec rake gettext:find -``` - -This command will update the `locale/gitlab.pot` file with the newly externalized -strings and remove any strings that aren't used anymore. You should check this -file in. Once the changes are on master, they will be picked up by -[Crowdin](http://translate.gitlab.com) and be presented for translation. - -If there are merge conflicts in the `gitlab.pot` file, you can delete the file -and regenerate it using the same command. Confirm that you are not deleting any strings accidentally by looking over the diff. - -The command also updates the translation files for each language: `locale/*/gitlab.po` -These changes can be discarded, the languange files will be updated by Crowdin -automatically. - -Discard all of them at once like this: - -```sh -git checkout locale/*/gitlab.po -``` - -### Validating PO files - -To make sure we keep our translation files up to date, there's a linter that is -running on CI as part of the `static-analysis` job. - -To lint the adjustments in PO files locally you can run `rake gettext:lint`. - -The linter will take the following into account: - -- Valid PO-file syntax -- Variable usage - - Only one unnamed (`%d`) variable, since the order of variables might change - in different languages - - All variables used in the message-id are used in the translation - - There should be no variables used in a translation that aren't in the - message-id -- Errors during translation. - -The errors are grouped per file, and per message ID: - -``` -Errors in `locale/zh_HK/gitlab.po`: - PO-syntax errors - SimplePoParser::ParserErrorSyntax error in lines - Syntax error in msgctxt - Syntax error in msgid - Syntax error in msgstr - Syntax error in message_line - There should be only whitespace until the end of line after the double quote character of a message text. - Parseing result before error: '{:msgid=>["", "You are going to remove %{project_name_with_namespace}.\\n", "Removed project CANNOT be restored!\\n", "Are you ABSOLUTELY sure?"]}' - SimplePoParser filtered backtrace: SimplePoParser::ParserError -Errors in `locale/zh_TW/gitlab.po`: - 1 pipeline - <%d 條流水線> is using unknown variables: [%d] - Failure translating to zh_TW with []: too few arguments +```js +import { __ } from '~/locale'; +const label = __('Subscribe'); ``` -In this output the `locale/zh_HK/gitlab.po` has syntax errors. -The `locale/zh_TW/gitlab.po` has variables that are used in the translation that -aren't in the message with id `1 pipeline`. - -## Working with special content - - -### Just marking content for parsing - -- In Ruby/HAML: - - ```ruby - _('Subscribe') - ``` - -- In JavaScript: - - ```js - import { __ } from '../../../locale'; - const label = __('Subscribe'); - ``` +In order to test JavaScript translations you have to change the GitLab +localization to other language than English and you have to generate JSON files +using `bin/rake gettext:po_to_json` or `bin/rake gettext:compile`. +### Dynamic translations Sometimes there are some dynamic translations that can't be found by the -parser when running `bundle exec rake gettext:find`. For these scenarios you can -use the [`_N` method](https://github.com/grosser/gettext_i18n_rails/blob/c09e38d481e0899ca7d3fc01786834fa8e7aab97/Readme.md#unfound-translations-with-rake-gettextfind). +parser when running `bin/rake gettext:find`. For these scenarios you can +use the [`N_` method](https://github.com/grosser/gettext_i18n_rails/blob/c09e38d481e0899ca7d3fc01786834fa8e7aab97/Readme.md#unfound-translations-with-rake-gettextfind). There is also and alternative method to [translate messages from validation errors](https://github.com/grosser/gettext_i18n_rails/blob/c09e38d481e0899ca7d3fc01786834fa8e7aab97/Readme.md#option-a). +## Working with special content + ### Interpolation - In Ruby/HAML: @@ -216,7 +140,7 @@ There is also and alternative method to [translate messages from validation erro - In JavaScript: ```js - import { __, sprintf } from '../../../locale'; + import { __, sprintf } from '~/locale'; sprintf(__('Hello %{username}'), { username: 'Joe' }) => 'Hello Joe' ``` @@ -228,24 +152,30 @@ For example use `%{created_at}` in Ruby but `%{createdAt}` in JavaScript. - In Ruby/HAML: ```ruby - n_('Apple', 'Apples', 3) => 'Apples' + n_('Apple', 'Apples', 3) + # => 'Apples' ``` Using interpolation: ```ruby n_("There is a mouse.", "There are %d mice.", size) % size + # => When size == 1: 'There is a mouse.' + # => When size == 2: 'There are 2 mice.' ``` - In JavaScript: ```js - n__('Apple', 'Apples', 3) => 'Apples' + n__('Apple', 'Apples', 3) + // => 'Apples' ``` Using interpolation: ```js - n__('Last day', 'Last %d days', 30) => 'Last 30 days' + n__('Last day', 'Last %d days', x) + // => When x == 1: 'Last day' + // => When x == 2: 'Last 2 days' ``` ### Namespaces @@ -267,12 +197,15 @@ Sometimes you need to add some context to the text that you want to translate s__('OpenedNDaysAgo|Opened') ``` +Note: The namespace should be removed from the translation. See the [translation +guidelines for more details](./translation.md#namespaced-strings). + ### Dates / times - In JavaScript: ```js -import { createDateTimeFormat } from '.../locale'; +import { createDateTimeFormat } from '~/locale'; const dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' }); console.log(dateFormat.format(new Date('2063-04-05'))) // April 5, 2063 @@ -282,6 +215,100 @@ This makes use of [`Intl.DateTimeFormat`]. [`Intl.DateTimeFormat`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat +## Best practices + +### Splitting sentences + +Please never split a sentence as that would assume the sentence grammar and +structure is the same in all languages. + +For instance, the following + +```js +{{ s__("mrWidget|Set by") }} +{{ author.name }} +{{ s__("mrWidget|to be merged automatically when the pipeline succeeds") }} +``` + +should be externalized as follows: + +```js +{{ sprintf(s__("mrWidget|Set by %{author} to be merged automatically when the pipeline succeeds"), { author: author.name }) }} +``` + +When in doubt, try to follow the best practices described in this [Mozilla +Developer documentation][mdn]. + +[mdn]: https://developer.mozilla.org/en-US/docs/Mozilla/Localization/Localization_content_best_practices#Splitting + +## Updating the PO files with the new content + +Now that the new content is marked for translation, we need to update the PO +files with the following command: + +```sh +bin/rake gettext:find +``` + +This command will update the `locale/gitlab.pot` file with the newly externalized +strings and remove any strings that aren't used anymore. You should check this +file in. Once the changes are on master, they will be picked up by +[Crowdin](http://translate.gitlab.com) and be presented for translation. + +If there are merge conflicts in the `gitlab.pot` file, you can delete the file +and regenerate it using the same command. Confirm that you are not deleting any strings accidentally by looking over the diff. + +The command also updates the translation files for each language: `locale/*/gitlab.po` +These changes can be discarded, the languange files will be updated by Crowdin +automatically. + +Discard all of them at once like this: + +```sh +git checkout locale/*/gitlab.po +``` + +### Validating PO files + +To make sure we keep our translation files up to date, there's a linter that is +running on CI as part of the `static-analysis` job. + +To lint the adjustments in PO files locally you can run `rake gettext:lint`. + +The linter will take the following into account: + +- Valid PO-file syntax +- Variable usage + - Only one unnamed (`%d`) variable, since the order of variables might change + in different languages + - All variables used in the message-id are used in the translation + - There should be no variables used in a translation that aren't in the + message-id +- Errors during translation. + +The errors are grouped per file, and per message ID: + +``` +Errors in `locale/zh_HK/gitlab.po`: + PO-syntax errors + SimplePoParser::ParserErrorSyntax error in lines + Syntax error in msgctxt + Syntax error in msgid + Syntax error in msgstr + Syntax error in message_line + There should be only whitespace until the end of line after the double quote character of a message text. + Parseing result before error: '{:msgid=>["", "You are going to remove %{project_name_with_namespace}.\\n", "Removed project CANNOT be restored!\\n", "Are you ABSOLUTELY sure?"]}' + SimplePoParser filtered backtrace: SimplePoParser::ParserError +Errors in `locale/zh_TW/gitlab.po`: + 1 pipeline + <%d 條流水線> is using unknown variables: [%d] + Failure translating to zh_TW with []: too few arguments +``` + +In this output the `locale/zh_HK/gitlab.po` has syntax errors. +The `locale/zh_TW/gitlab.po` has variables that are used in the translation that +aren't in the message with id `1 pipeline`. + ## Adding a new language Let's suppose you want to add translations for a new language, let's say French. @@ -300,14 +327,14 @@ Let's suppose you want to add translations for a new language, let's say French. 1. Next, you need to add the language: ```sh - bundle exec rake gettext:add_language[fr] + bin/rake gettext:add_language[fr] ``` If you want to add a new language for a specific region, the command is similar, you just need to separate the region with an underscore (`_`). For example: ```sh - bundle exec rake gettext:add_language[en_GB] + bin/rake gettext:add_language[en_GB] ``` Please note that you need to specify the region part in capitals. @@ -321,7 +348,7 @@ Let's suppose you want to add translations for a new language, let's say French. containing the translations: ```sh - bundle exec rake gettext:compile + bin/rake gettext:compile ``` 1. In order to see the translated content we need to change our preferred language diff --git a/doc/development/i18n/proofreader.md b/doc/development/i18n/proofreader.md index ece9a9bc0fe..9aa3fb07abf 100644 --- a/doc/development/i18n/proofreader.md +++ b/doc/development/i18n/proofreader.md @@ -15,6 +15,7 @@ are very appreciative of the work done by translators and proofreaders! - Dutch - Esperanto - French + - Rémy Coutable - [GitLab](https://gitlab.com/rymai), [Crowdin](https://crowdin.com/profile/rymai) - German - Italian - Paolo Falomo - [GitLab](https://gitlab.com/paolofalomo), [Crowdin](https://crowdin.com/profile/paolo.falomo) diff --git a/doc/development/i18n/translation.md b/doc/development/i18n/translation.md index b34ec754742..99c0fe6db1d 100644 --- a/doc/development/i18n/translation.md +++ b/doc/development/i18n/translation.md @@ -37,33 +37,43 @@ Comments can be added to discuss a translation with the community. Remember to **Save** each translation. -## Translation Guidelines +## General Translation Guidelines Be sure to check the following guidelines before you translate any strings. +### Namespaced strings + +When an externalized string is prepended with a namespace, e.g. +`s_('OpenedNDaysAgo|Opened')`, the namespace should be removed from the final +translation. +For example in French `OpenedNDaysAgo|Opened` would be translated to +`Ouvert•e`, not `OpenedNDaysAgo|Ouvert•e`. + ### Technical terms -Technical terms should be treated like proper nouns and not be translated. -This helps maintain a logical connection and consistency between tools (e.g. `git` client) and -GitLab. +Some technical terms should be treated like proper nouns and not be translated. -Technical terms that should always be in English are noted in the glossary when using -[translate.gitlab.com](https://translate.gitlab.com). +Technical terms that should always be in English are noted in the glossary when +using [translate.gitlab.com](https://translate.gitlab.com). + +This helps maintain a logical connection and consistency between tools (e.g. +`git` client) and GitLab. ### Formality The level of formality used in software varies by language. -For example, in French we translate `you` as the informal `tu`. +For example, in French we translate `you` as the formal `vous`. -You can refer to other translated strings and notes in the glossary to assist determining a -suitable level of formality. +You can refer to other translated strings and notes in the glossary to assist +determining a suitable level of formality. ### Inclusive language [Diversity] is one of GitLab's values. -We ask you to avoid translations which exclude people based on their gender or ethnicity. -In languages which distinguish between a male and female form, -use both or choose a neutral formulation. +We ask you to avoid translations which exclude people based on their gender or +ethnicity. +In languages which distinguish between a male and female form, use both or +choose a neutral formulation. For example in German, the word "user" can be translated into "Benutzer" (male) or "Benutzerin" (female). Therefore "create a new user" would translate into "Benutzer(in) anlegen". @@ -74,3 +84,14 @@ Therefore "create a new user" would translate into "Benutzer(in) anlegen". To propose additions to the glossary please [open an issue](https://gitlab.com/gitlab-org/gitlab-ce/issues). + +## French Translation Guidelines + +### Inclusive language in French + +In French, we should follow the guidelines from [ecriture-inclusive.fr]. For +instance: + +- Utilisateur•rice•s + +[ecriture-inclusive.fr]: http://www.ecriture-inclusive.fr/ diff --git a/doc/install/installation.md b/doc/install/installation.md index 4dfc03d0fe0..170d92faa09 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -93,9 +93,9 @@ Is the system packaged Git too old? Remove it and compile from source. # Download and compile from source cd /tmp - curl --remote-name --progress https://www.kernel.org/pub/software/scm/git/git-2.14.3.tar.gz - echo '023ffff6d3ba8a1bea779dfecc0ed0bb4ad68ab8601d14435dd8c08416f78d7f git-2.14.3.tar.gz' | shasum -a256 -c - && tar -xzf git-2.14.3.tar.gz - cd git-2.14.3/ + curl --remote-name --progress https://www.kernel.org/pub/software/scm/git/git-2.16.2.tar.gz + echo '9acc4339b7a2ab484eea69d705923271682b7058015219cf5a7e6ed8dee5b5fb git-2.16.2.tar.gz' | shasum -a256 -c - && tar -xzf git-2.16.2.tar.gz + cd git-2.16.2/ ./configure make prefix=/usr/local all diff --git a/doc/integration/saml.md b/doc/integration/saml.md index 3ae98adc465..f8a7dd6b1dc 100644 --- a/doc/integration/saml.md +++ b/doc/integration/saml.md @@ -109,8 +109,7 @@ in your SAML IdP: 1. Change the value of `issuer` to a unique name, which will identify the application to the IdP. -1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you - installed GitLab via Omnibus or from source respectively. +1. For the changes to take effect, you must [reconfigure][] GitLab if you installed via Omnibus or [restart GitLab][] if you installed from source. 1. Register the GitLab SP in your SAML 2.0 IdP, using the application name specified in `issuer`. diff --git a/doc/user/markdown.md b/doc/user/markdown.md index ea7b1c9a0ed..650d60f1585 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -36,12 +36,16 @@ GFM honors the markdown specification in how [paragraphs and line breaks are han A paragraph is simply one or more consecutive lines of text, separated by one or more blank lines. Line-breaks, or softreturns, are rendered if you end a line with two or more spaces: - Roses are red [followed by two or more spaces] +[//]: # (Do *NOT* remove the two ending whitespaces in the following line.) +[//]: # (They are needed for the Markdown text to render correctly.) + Roses are red [followed by two or more spaces] Violets are blue Sugar is sweet -Roses are red +[//]: # (Do *NOT* remove the two ending whitespaces in the following line.) +[//]: # (They are needed for the Markdown text to render correctly.) +Roses are red Violets are blue Sugar is sweet diff --git a/doc/user/project/merge_requests/index.md b/doc/user/project/merge_requests/index.md index 0de89f90e21..16027744164 100644 --- a/doc/user/project/merge_requests/index.md +++ b/doc/user/project/merge_requests/index.md @@ -34,7 +34,7 @@ With **[GitLab Enterprise Edition][ee]**, you can also: - View the deployment process across projects with [Multi-Project Pipeline Graphs](https://docs.gitlab.com/ee/ci/multi_project_pipeline_graphs.html#multi-project-pipeline-graphs) (available only in GitLab Premium) - Request [approvals](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html) from your managers (available in GitLab Starter) - [Squash and merge](https://docs.gitlab.com/ee/user/project/merge_requests/squash_and_merge.html) for a cleaner commit history (available in GitLab Starter) -- Analise the impact of your changes with [Code Quality reports](https://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.html) (available in GitLab Starter) +- Analyze the impact of your changes with [Code Quality reports](https://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.html) (available in GitLab Starter) ## Use cases diff --git a/lib/api/access_requests.rb b/lib/api/access_requests.rb index 60ae5e6b9a2..ae13c248171 100644 --- a/lib/api/access_requests.rb +++ b/lib/api/access_requests.rb @@ -53,7 +53,10 @@ module API put ':id/access_requests/:user_id/approve' do source = find_source(source_type, params[:id]) - member = ::Members::ApproveAccessRequestService.new(source, current_user, declared_params).execute + access_requester = source.requesters.find_by!(user_id: params[:user_id]) + member = ::Members::ApproveAccessRequestService + .new(current_user, declared_params) + .execute(access_requester) status :created present member, with: Entities::Member @@ -70,8 +73,7 @@ module API member = source.requesters.find_by!(user_id: params[:user_id]) destroy_conditionally!(member) do - ::Members::DestroyService.new(source, current_user, params) - .execute(:requesters) + ::Members::DestroyService.new(current_user).execute(member) end end end diff --git a/lib/api/commits.rb b/lib/api/commits.rb index 3d6e78d2d80..982f45425a3 100644 --- a/lib/api/commits.rb +++ b/lib/api/commits.rb @@ -18,25 +18,28 @@ module API optional :since, type: DateTime, desc: 'Only commits after or on this date will be returned' optional :until, type: DateTime, desc: 'Only commits before or on this date will be returned' optional :path, type: String, desc: 'The file path' + optional :all, type: Boolean, desc: 'Every commit will be returned' use :pagination end get ':id/repository/commits' do path = params[:path] before = params[:until] after = params[:since] - ref = params[:ref_name] || user_project.try(:default_branch) || 'master' + ref = params[:ref_name] || user_project.try(:default_branch) || 'master' unless params[:all] offset = (params[:page] - 1) * params[:per_page] + all = params[:all] commits = user_project.repository.commits(ref, path: path, limit: params[:per_page], offset: offset, before: before, - after: after) + after: after, + all: all) commit_count = - if path || before || after - user_project.repository.count_commits(ref: ref, path: path, before: before, after: after) + if all || path || before || after + user_project.repository.count_commits(ref: ref, path: path, before: before, after: after, all: all) else # Cacheable commit count. user_project.repository.commit_count_for_ref(ref) diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 167878ba600..c88fcf9472e 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -71,7 +71,7 @@ module API end class ProjectHook < Hook - expose :project_id, :issues_events + expose :project_id, :issues_events, :confidential_issues_events expose :note_events, :pipeline_events, :wiki_page_events expose :job_events end diff --git a/lib/api/helpers/runner.rb b/lib/api/helpers/runner.rb index fbe30192a16..35ac0b4cbca 100644 --- a/lib/api/helpers/runner.rb +++ b/lib/api/helpers/runner.rb @@ -9,16 +9,22 @@ module API Gitlab::CurrentSettings.runners_registration_token) end - def get_runner_version_from_params - return unless params['info'].present? + def authenticate_runner! + forbidden! unless current_runner - attributes_for_keys(%w(name version revision platform architecture), params['info']) + current_runner + .update_cached_info(get_runner_details_from_request) end - def authenticate_runner! - forbidden! unless current_runner + def get_runner_details_from_request + return get_runner_ip unless params['info'].present? + + attributes_for_keys(%w(name version revision platform architecture), params['info']) + .merge(get_runner_ip) + end - current_runner.update_cached_info(get_runner_version_from_params) + def get_runner_ip + { ip_address: request.ip } end def current_runner diff --git a/lib/api/members.rb b/lib/api/members.rb index bc1de37284a..8b12986d09e 100644 --- a/lib/api/members.rb +++ b/lib/api/members.rb @@ -81,12 +81,16 @@ module API source = find_source(source_type, params.delete(:id)) authorize_admin_source!(source_type, source) - member = source.members.find_by!(user_id: params.delete(:user_id)) + member = source.members.find_by!(user_id: params[:user_id]) + updated_member = + ::Members::UpdateService + .new(current_user, declared_params(include_missing: false)) + .execute(member) - if member.update_attributes(declared_params(include_missing: false)) - present member, with: Entities::Member + if updated_member.valid? + present updated_member, with: Entities::Member else - render_validation_error!(member) + render_validation_error!(updated_member) end end @@ -99,7 +103,7 @@ module API member = source.members.find_by!(user_id: params[:user_id]) destroy_conditionally!(member) do - ::Members::DestroyService.new(source, current_user, declared_params).execute + ::Members::DestroyService.new(current_user).execute(member) end end end diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index 719afa09295..4ffd4895c7e 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -48,6 +48,8 @@ module API optional :scope, type: String, values: %w[created-by-me assigned-to-me all], desc: 'Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all`' optional :my_reaction_emoji, type: String, desc: 'Return issues reacted by the authenticated user by the given emoji' + optional :source_branch, type: String, desc: 'Return merge requests with the given source branch' + optional :target_branch, type: String, desc: 'Return merge requests with the given target branch' optional :search, type: String, desc: 'Search merge requests for text present in the title or description' use :pagination end diff --git a/lib/api/project_hooks.rb b/lib/api/project_hooks.rb index 86066e2b58f..f82241058e5 100644 --- a/lib/api/project_hooks.rb +++ b/lib/api/project_hooks.rb @@ -10,6 +10,7 @@ module API requires :url, type: String, desc: "The URL to send the request to" optional :push_events, type: Boolean, desc: "Trigger hook on push events" optional :issues_events, type: Boolean, desc: "Trigger hook on issues events" + optional :confidential_issues_events, type: Boolean, desc: "Trigger hook on confidential issues events" optional :merge_requests_events, type: Boolean, desc: "Trigger hook on merge request events" optional :tag_push_events, type: Boolean, desc: "Trigger hook on tag push events" optional :note_events, type: Boolean, desc: "Trigger hook on note(comment) events" diff --git a/lib/api/runner.rb b/lib/api/runner.rb index 5469cba69a6..91cdc564002 100644 --- a/lib/api/runner.rb +++ b/lib/api/runner.rb @@ -16,7 +16,8 @@ module API optional :tag_list, type: Array[String], desc: %q(List of Runner's tags) end post '/' do - attributes = attributes_for_keys [:description, :locked, :run_untagged, :tag_list] + attributes = attributes_for_keys([:description, :locked, :run_untagged, :tag_list]) + .merge(get_runner_details_from_request) runner = if runner_registration_token_valid? @@ -30,7 +31,6 @@ module API return forbidden! unless runner if runner.id - runner.update(get_runner_version_from_params) present runner, with: Entities::RunnerRegistrationDetails else not_found! diff --git a/lib/api/services.rb b/lib/api/services.rb index 51e33e2c686..6c97659166d 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -1,6 +1,7 @@ +# frozen_string_literal: true module API class Services < Grape::API - chat_notification_settings = [ + CHAT_NOTIFICATION_SETTINGS = [ { required: true, name: :webhook, @@ -19,9 +20,9 @@ module API type: String, desc: 'The default chat channel' } - ] + ].freeze - chat_notification_flags = [ + CHAT_NOTIFICATION_FLAGS = [ { required: false, name: :notify_only_broken_pipelines, @@ -34,9 +35,9 @@ module API type: Boolean, desc: 'Send notifications only for the default branch' } - ] + ].freeze - chat_notification_channels = [ + CHAT_NOTIFICATION_CHANNELS = [ { required: false, name: :push_channel, @@ -85,9 +86,9 @@ module API type: String, desc: 'The name of the channel to receive wiki_page_events notifications' } - ] + ].freeze - chat_notification_events = [ + CHAT_NOTIFICATION_EVENTS = [ { required: false, name: :push_events, @@ -136,7 +137,7 @@ module API type: Boolean, desc: 'Enable notifications for wiki_page_events' } - ] + ].freeze services = { 'asana' => [ @@ -627,10 +628,10 @@ module API } ], 'slack' => [ - chat_notification_settings, - chat_notification_flags, - chat_notification_channels, - chat_notification_events + CHAT_NOTIFICATION_SETTINGS, + CHAT_NOTIFICATION_FLAGS, + CHAT_NOTIFICATION_CHANNELS, + CHAT_NOTIFICATION_EVENTS ].flatten, 'microsoft-teams' => [ { @@ -641,10 +642,10 @@ module API } ], 'mattermost' => [ - chat_notification_settings, - chat_notification_flags, - chat_notification_channels, - chat_notification_events + CHAT_NOTIFICATION_SETTINGS, + CHAT_NOTIFICATION_FLAGS, + CHAT_NOTIFICATION_CHANNELS, + CHAT_NOTIFICATION_EVENTS ].flatten, 'teamcity' => [ { @@ -724,7 +725,22 @@ module API ] end - trigger_services = { + SERVICES = services.freeze + SERVICE_CLASSES = service_classes.freeze + + SERVICE_CLASSES.each do |service| + event_names = service.try(:event_names) || next + event_names.each do |event_name| + SERVICES[service.to_param.tr("_", "-")] << { + required: false, + name: event_name.to_sym, + type: String, + desc: ServicesHelper.service_event_description(event_name) + } + end + end + + TRIGGER_SERVICES = { 'mattermost-slash-commands' => [ { name: :token, @@ -756,22 +772,9 @@ module API end end - services.each do |service_slug, settings| + SERVICES.each do |service_slug, settings| desc "Set #{service_slug} service for project" params do - service_classes.each do |service| - event_names = service.try(:event_names) || next - event_names.each do |event_name| - services[service.to_param.tr("_", "-")] << { - required: false, - name: event_name.to_sym, - type: String, - desc: ServicesHelper.service_event_description(event_name) - } - end - end - services.freeze - settings.each do |setting| if setting[:required] requires setting[:name], type: setting[:type], desc: setting[:desc] @@ -794,7 +797,7 @@ module API desc "Delete a service for project" params do - requires :service_slug, type: String, values: services.keys, desc: 'The name of the service' + requires :service_slug, type: String, values: SERVICES.keys, desc: 'The name of the service' end delete ":id/services/:service_slug" do service = user_project.find_or_initialize_service(params[:service_slug].underscore) @@ -814,7 +817,7 @@ module API success Entities::ProjectService end params do - requires :service_slug, type: String, values: services.keys, desc: 'The name of the service' + requires :service_slug, type: String, values: SERVICES.keys, desc: 'The name of the service' end get ":id/services/:service_slug" do service = user_project.find_or_initialize_service(params[:service_slug].underscore) @@ -822,7 +825,7 @@ module API end end - trigger_services.each do |service_slug, settings| + TRIGGER_SERVICES.each do |service_slug, settings| helpers do def slash_command_service(project, service_slug, params) project.services.active.where(template: false).find do |service| diff --git a/lib/api/v3/entities.rb b/lib/api/v3/entities.rb index 2ccbb9da1c5..68b4d7c3982 100644 --- a/lib/api/v3/entities.rb +++ b/lib/api/v3/entities.rb @@ -252,8 +252,9 @@ module API class ProjectService < Grape::Entity expose :id, :title, :created_at, :updated_at, :active - expose :push_events, :issues_events, :merge_requests_events - expose :tag_push_events, :note_events, :pipeline_events + expose :push_events, :issues_events, :confidential_issues_events + expose :merge_requests_events, :tag_push_events, :note_events + expose :pipeline_events expose :job_events, as: :build_events # Expose serialized properties expose :properties do |service, options| @@ -262,8 +263,9 @@ module API end class ProjectHook < ::API::Entities::Hook - expose :project_id, :issues_events, :merge_requests_events - expose :note_events, :pipeline_events, :wiki_page_events + expose :project_id, :issues_events, :confidential_issues_events + expose :merge_requests_events, :note_events, :pipeline_events + expose :wiki_page_events expose :job_events, as: :build_events end diff --git a/lib/api/v3/members.rb b/lib/api/v3/members.rb index d7bde8ceb89..88dd598f1e9 100644 --- a/lib/api/v3/members.rb +++ b/lib/api/v3/members.rb @@ -124,7 +124,7 @@ module API status(200 ) { message: "Access revoked", id: params[:user_id].to_i } else - ::Members::DestroyService.new(source, current_user, declared_params).execute + ::Members::DestroyService.new(current_user).execute(member) present member, with: ::API::Entities::Member end diff --git a/lib/api/v3/project_hooks.rb b/lib/api/v3/project_hooks.rb index 51014591a93..631944150c7 100644 --- a/lib/api/v3/project_hooks.rb +++ b/lib/api/v3/project_hooks.rb @@ -11,6 +11,7 @@ module API requires :url, type: String, desc: "The URL to send the request to" optional :push_events, type: Boolean, desc: "Trigger hook on push events" optional :issues_events, type: Boolean, desc: "Trigger hook on issues events" + optional :confidential_issues_events, type: Boolean, desc: "Trigger hook on confidential issues events" optional :merge_requests_events, type: Boolean, desc: "Trigger hook on merge request events" optional :tag_push_events, type: Boolean, desc: "Trigger hook on tag push events" optional :note_events, type: Boolean, desc: "Trigger hook on note(comment) events" diff --git a/lib/banzai/filter/abstract_reference_filter.rb b/lib/banzai/filter/abstract_reference_filter.rb index e7e6a90b5fd..c9e3f8ce42b 100644 --- a/lib/banzai/filter/abstract_reference_filter.rb +++ b/lib/banzai/filter/abstract_reference_filter.rb @@ -174,7 +174,9 @@ module Banzai title = object_link_title(object) klass = reference_class(object_sym) - data = data_attributes_for(link_content || match, parent, object, link: !!link_content) + data = data_attributes_for(link_content || match, parent, object, + link_content: !!link_content, + link_reference: link_reference) url = if matches.names.include?("url") && matches[:url] @@ -194,12 +196,13 @@ module Banzai end end - def data_attributes_for(text, project, object, link: false) + def data_attributes_for(text, project, object, link_content: false, link_reference: false) data_attribute( - original: text, - link: link, - project: project.id, - object_sym => object.id + original: text, + link: link_content, + link_reference: link_reference, + project: project.id, + object_sym => object.id ) end diff --git a/lib/banzai/redactor.rb b/lib/banzai/redactor.rb index 827df7c08ae..fd457bebf03 100644 --- a/lib/banzai/redactor.rb +++ b/lib/banzai/redactor.rb @@ -42,16 +42,33 @@ module Banzai next if visible.include?(node) doc_data[:visible_reference_count] -= 1 - # The reference should be replaced by the original link's content, - # which is not always the same as the rendered one. - content = node.attr('data-original') || node.inner_html - node.replace(content) + redacted_content = redacted_node_content(node) + node.replace(redacted_content) end end metadata end + # Return redacted content of given node as either the original link (<a> tag), + # the original content (text), or the inner HTML of the node. + # + def redacted_node_content(node) + original_content = node.attr('data-original') + link_reference = node.attr('data-link-reference') + + # Build the raw <a> tag just with a link as href and content if + # it's originally a link pattern. We shouldn't return a plain text href. + original_link = + if link_reference == 'true' && href = original_content + %(<a href="#{href}">#{href}</a>) + end + + # The reference should be replaced by the original link's content, + # which is not always the same as the rendered one. + original_link || original_content || node.inner_html + end + def redact_cross_project_references(documents) extractor = Banzai::IssuableExtractor.new(project, user) issuables = extractor.extract(documents) diff --git a/lib/gitlab/background_migration/migrate_build_stage.rb b/lib/gitlab/background_migration/migrate_build_stage.rb new file mode 100644 index 00000000000..8fe4f1a2289 --- /dev/null +++ b/lib/gitlab/background_migration/migrate_build_stage.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true +# rubocop:disable Metrics/AbcSize +# rubocop:disable Style/Documentation + +module Gitlab + module BackgroundMigration + class MigrateBuildStage + module Migratable + class Stage < ActiveRecord::Base + self.table_name = 'ci_stages' + end + + class Build < ActiveRecord::Base + self.table_name = 'ci_builds' + + def ensure_stage!(attempts: 2) + find_stage || create_stage! + rescue ActiveRecord::RecordNotUnique + retry if (attempts -= 1) > 0 + raise + end + + def find_stage + Stage.find_by(name: self.stage || 'test', + pipeline_id: self.commit_id, + project_id: self.project_id) + end + + def create_stage! + Stage.create!(name: self.stage || 'test', + pipeline_id: self.commit_id, + project_id: self.project_id) + end + end + end + + def perform(start_id, stop_id) + stages = Migratable::Build.where('stage_id IS NULL') + .where('id BETWEEN ? AND ?', start_id, stop_id) + .map { |build| build.ensure_stage! } + .compact.map(&:id) + + MigrateBuildStageIdReference.new.perform(start_id, stop_id) + MigrateStageStatus.new.perform(stages.min, stages.max) + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/base.rb b/lib/gitlab/ci/pipeline/expression/lexeme/base.rb new file mode 100644 index 00000000000..047ab66e9b3 --- /dev/null +++ b/lib/gitlab/ci/pipeline/expression/lexeme/base.rb @@ -0,0 +1,25 @@ +module Gitlab + module Ci + module Pipeline + module Expression + module Lexeme + class Base + def evaluate(**variables) + raise NotImplementedError + end + + def self.build(token) + raise NotImplementedError + end + + def self.scan(scanner) + if scanner.scan(self::PATTERN) + Expression::Token.new(scanner.matched, self) + end + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/equals.rb b/lib/gitlab/ci/pipeline/expression/lexeme/equals.rb new file mode 100644 index 00000000000..3a2f0c6924e --- /dev/null +++ b/lib/gitlab/ci/pipeline/expression/lexeme/equals.rb @@ -0,0 +1,26 @@ +module Gitlab + module Ci + module Pipeline + module Expression + module Lexeme + class Equals < Lexeme::Operator + PATTERN = /==/.freeze + + def initialize(left, right) + @left = left + @right = right + end + + def evaluate(variables = {}) + @left.evaluate(variables) == @right.evaluate(variables) + end + + def self.build(_value, behind, ahead) + new(behind, ahead) + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/null.rb b/lib/gitlab/ci/pipeline/expression/lexeme/null.rb new file mode 100644 index 00000000000..a2778716924 --- /dev/null +++ b/lib/gitlab/ci/pipeline/expression/lexeme/null.rb @@ -0,0 +1,25 @@ +module Gitlab + module Ci + module Pipeline + module Expression + module Lexeme + class Null < Lexeme::Value + PATTERN = /null/.freeze + + def initialize(value = nil) + @value = nil + end + + def evaluate(variables = {}) + nil + end + + def self.build(_value) + self.new + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/operator.rb b/lib/gitlab/ci/pipeline/expression/lexeme/operator.rb new file mode 100644 index 00000000000..f640d0b5855 --- /dev/null +++ b/lib/gitlab/ci/pipeline/expression/lexeme/operator.rb @@ -0,0 +1,15 @@ +module Gitlab + module Ci + module Pipeline + module Expression + module Lexeme + class Operator < Lexeme::Base + def self.type + :operator + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/string.rb b/lib/gitlab/ci/pipeline/expression/lexeme/string.rb new file mode 100644 index 00000000000..48bde213d44 --- /dev/null +++ b/lib/gitlab/ci/pipeline/expression/lexeme/string.rb @@ -0,0 +1,25 @@ +module Gitlab + module Ci + module Pipeline + module Expression + module Lexeme + class String < Lexeme::Value + PATTERN = /("(?<string>.+?)")|('(?<string>.+?)')/.freeze + + def initialize(value) + @value = value + end + + def evaluate(variables = {}) + @value.to_s + end + + def self.build(string) + new(string.match(PATTERN)[:string]) + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/value.rb b/lib/gitlab/ci/pipeline/expression/lexeme/value.rb new file mode 100644 index 00000000000..f2611d65faf --- /dev/null +++ b/lib/gitlab/ci/pipeline/expression/lexeme/value.rb @@ -0,0 +1,15 @@ +module Gitlab + module Ci + module Pipeline + module Expression + module Lexeme + class Value < Lexeme::Base + def self.type + :value + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/variable.rb b/lib/gitlab/ci/pipeline/expression/lexeme/variable.rb new file mode 100644 index 00000000000..b781c15fd67 --- /dev/null +++ b/lib/gitlab/ci/pipeline/expression/lexeme/variable.rb @@ -0,0 +1,25 @@ +module Gitlab + module Ci + module Pipeline + module Expression + module Lexeme + class Variable < Lexeme::Value + PATTERN = /\$(?<name>\w+)/.freeze + + def initialize(name) + @name = name + end + + def evaluate(variables = {}) + HashWithIndifferentAccess.new(variables).fetch(@name, nil) + end + + def self.build(string) + new(string.match(PATTERN)[:name]) + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/expression/lexer.rb b/lib/gitlab/ci/pipeline/expression/lexer.rb new file mode 100644 index 00000000000..e1c68b7c3c2 --- /dev/null +++ b/lib/gitlab/ci/pipeline/expression/lexer.rb @@ -0,0 +1,59 @@ +module Gitlab + module Ci + module Pipeline + module Expression + class Lexer + include ::Gitlab::Utils::StrongMemoize + + LEXEMES = [ + Expression::Lexeme::Variable, + Expression::Lexeme::String, + Expression::Lexeme::Null, + Expression::Lexeme::Equals + ].freeze + + SyntaxError = Class.new(Statement::StatementError) + + MAX_TOKENS = 100 + + def initialize(statement, max_tokens: MAX_TOKENS) + @scanner = StringScanner.new(statement) + @max_tokens = max_tokens + end + + def tokens + strong_memoize(:tokens) { tokenize } + end + + def lexemes + tokens.map(&:to_lexeme) + end + + private + + def tokenize + tokens = [] + + @max_tokens.times do + @scanner.skip(/\s+/) # ignore whitespace + + return tokens if @scanner.eos? + + lexeme = LEXEMES.find do |type| + type.scan(@scanner).tap do |token| + tokens.push(token) if token.present? + end + end + + unless lexeme.present? + raise Lexer::SyntaxError, 'Unknown lexeme found!' + end + end + + raise Lexer::SyntaxError, 'Too many tokens!' + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/expression/parser.rb b/lib/gitlab/ci/pipeline/expression/parser.rb new file mode 100644 index 00000000000..90f94d0b763 --- /dev/null +++ b/lib/gitlab/ci/pipeline/expression/parser.rb @@ -0,0 +1,40 @@ +module Gitlab + module Ci + module Pipeline + module Expression + class Parser + def initialize(tokens) + @tokens = tokens.to_enum + @nodes = [] + end + + ## + # This produces a reverse descent parse tree. + # + # It currently does not support precedence of operators. + # + def tree + while token = @tokens.next + case token.type + when :operator + token.build(@nodes.pop, tree).tap do |node| + @nodes.push(node) + end + when :value + token.build.tap do |leaf| + @nodes.push(leaf) + end + end + end + rescue StopIteration + @nodes.last || Lexeme::Null.new + end + + def self.seed(statement) + new(Expression::Lexer.new(statement).tokens) + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/expression/statement.rb b/lib/gitlab/ci/pipeline/expression/statement.rb new file mode 100644 index 00000000000..4f0e101b730 --- /dev/null +++ b/lib/gitlab/ci/pipeline/expression/statement.rb @@ -0,0 +1,42 @@ +module Gitlab + module Ci + module Pipeline + module Expression + class Statement + StatementError = Class.new(StandardError) + + GRAMMAR = [ + %w[variable equals string], + %w[variable equals variable], + %w[variable equals null], + %w[string equals variable], + %w[null equals variable], + %w[variable] + ].freeze + + def initialize(statement, pipeline) + @lexer = Expression::Lexer.new(statement) + + @variables = pipeline.variables.map do |variable| + [variable.key, variable.value] + end + end + + def parse_tree + raise StatementError if @lexer.lexemes.empty? + + unless GRAMMAR.find { |syntax| syntax == @lexer.lexemes } + raise StatementError, 'Unknown pipeline expression!' + end + + Expression::Parser.new(@lexer.tokens).tree + end + + def evaluate + parse_tree.evaluate(@variables.to_h) + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/expression/token.rb b/lib/gitlab/ci/pipeline/expression/token.rb new file mode 100644 index 00000000000..58211800b88 --- /dev/null +++ b/lib/gitlab/ci/pipeline/expression/token.rb @@ -0,0 +1,28 @@ +module Gitlab + module Ci + module Pipeline + module Expression + class Token + attr_reader :value, :lexeme + + def initialize(value, lexeme) + @value = value + @lexeme = lexeme + end + + def build(*args) + @lexeme.build(@value, *args) + end + + def type + @lexeme.type + end + + def to_lexeme + @lexeme.name.demodulize.downcase + end + end + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/base_query.rb b/lib/gitlab/cycle_analytics/base_query.rb index 8b3bc3e440d..86d708be0d6 100644 --- a/lib/gitlab/cycle_analytics/base_query.rb +++ b/lib/gitlab/cycle_analytics/base_query.rb @@ -8,13 +8,14 @@ module Gitlab private def base_query - @base_query ||= stage_query + @base_query ||= stage_query(@project.id) # rubocop:disable Gitlab/ModuleWithInstanceVariables end - def stage_query + def stage_query(project_ids) query = mr_closing_issues_table.join(issue_table).on(issue_table[:id].eq(mr_closing_issues_table[:issue_id])) .join(issue_metrics_table).on(issue_table[:id].eq(issue_metrics_table[:issue_id])) - .where(issue_table[:project_id].eq(@project.id)) # rubocop:disable Gitlab/ModuleWithInstanceVariables + .project(issue_table[:project_id].as("project_id")) + .where(issue_table[:project_id].in(project_ids)) .where(issue_table[:created_at].gteq(@options[:from])) # rubocop:disable Gitlab/ModuleWithInstanceVariables # Load merge_requests diff --git a/lib/gitlab/cycle_analytics/base_stage.rb b/lib/gitlab/cycle_analytics/base_stage.rb index cac31ea8cff..038d5a19bc4 100644 --- a/lib/gitlab/cycle_analytics/base_stage.rb +++ b/lib/gitlab/cycle_analytics/base_stage.rb @@ -21,17 +21,28 @@ module Gitlab end def median - cte_table = Arel::Table.new("cte_table_for_#{name}") + BatchLoader.for(@project.id).batch(key: name) do |project_ids, loader| + cte_table = Arel::Table.new("cte_table_for_#{name}") - # Build a `SELECT` query. We find the first of the `end_time_attrs` that isn't `NULL` (call this end_time). - # Next, we find the first of the start_time_attrs that isn't `NULL` (call this start_time). - # We compute the (end_time - start_time) interval, and give it an alias based on the current - # cycle analytics stage. - interval_query = Arel::Nodes::As.new( - cte_table, - subtract_datetimes(base_query.dup, start_time_attrs, end_time_attrs, name.to_s)) + # Build a `SELECT` query. We find the first of the `end_time_attrs` that isn't `NULL` (call this end_time). + # Next, we find the first of the start_time_attrs that isn't `NULL` (call this start_time). + # We compute the (end_time - start_time) interval, and give it an alias based on the current + # cycle analytics stage. + interval_query = Arel::Nodes::As.new(cte_table, + subtract_datetimes(stage_query(project_ids), start_time_attrs, end_time_attrs, name.to_s)) - median_datetime(cte_table, interval_query, name) + if project_ids.one? + loader.call(@project.id, median_datetime(cte_table, interval_query, name)) + else + begin + median_datetimes(cte_table, interval_query, name, :project_id)&.each do |project_id, median| + loader.call(project_id, median) + end + rescue NotSupportedError + {} + end + end + end end def name diff --git a/lib/gitlab/cycle_analytics/production_helper.rb b/lib/gitlab/cycle_analytics/production_helper.rb index 7a889b3877f..d0ca62e46e4 100644 --- a/lib/gitlab/cycle_analytics/production_helper.rb +++ b/lib/gitlab/cycle_analytics/production_helper.rb @@ -1,8 +1,8 @@ module Gitlab module CycleAnalytics module ProductionHelper - def stage_query - super + def stage_query(project_ids) + super(project_ids) .where(mr_metrics_table[:first_deployed_to_production_at] .gteq(@options[:from])) # rubocop:disable Gitlab/ModuleWithInstanceVariables end diff --git a/lib/gitlab/cycle_analytics/test_stage.rb b/lib/gitlab/cycle_analytics/test_stage.rb index 2b5f72bef89..0e9d235ca79 100644 --- a/lib/gitlab/cycle_analytics/test_stage.rb +++ b/lib/gitlab/cycle_analytics/test_stage.rb @@ -25,11 +25,11 @@ module Gitlab _("Total test time for all commits/merges") end - def stage_query + def stage_query(project_ids) if @options[:branch] - super.where(build_table[:ref].eq(@options[:branch])) + super(project_ids).where(build_table[:ref].eq(@options[:branch])) else - super + super(project_ids) end end end diff --git a/lib/gitlab/cycle_analytics/usage_data.rb b/lib/gitlab/cycle_analytics/usage_data.rb new file mode 100644 index 00000000000..5122e3417ca --- /dev/null +++ b/lib/gitlab/cycle_analytics/usage_data.rb @@ -0,0 +1,72 @@ +module Gitlab + module CycleAnalytics + class UsageData + PROJECTS_LIMIT = 10 + + attr_reader :projects, :options + + def initialize + @projects = Project.sorted_by_activity.limit(PROJECTS_LIMIT) + @options = { from: 7.days.ago } + end + + def to_json + total = 0 + + values = + medians_per_stage.each_with_object({}) do |(stage_name, medians), hsh| + calculations = stage_values(medians) + + total += calculations.values.compact.sum + hsh[stage_name] = calculations + end + + values[:total] = total + + { avg_cycle_analytics: values } + end + + private + + def medians_per_stage + projects.each_with_object({}) do |project, hsh| + ::CycleAnalytics.new(project, options).all_medians_per_stage.each do |stage_name, median| + hsh[stage_name] ||= [] + hsh[stage_name] << median + end + end + end + + def stage_values(medians) + medians = medians.map(&:presence).compact + average = calc_average(medians) + + { + average: average, + sd: standard_deviation(medians, average), + missing: projects.length - medians.length + } + end + + def calc_average(values) + return if values.empty? + + (values.sum / values.length).to_i + end + + def standard_deviation(values, average) + Math.sqrt(sample_variance(values, average)).to_i + end + + def sample_variance(values, average) + return 0 if values.length <= 1 + + sum = values.inject(0) do |acc, val| + acc + (val - average)**2 + end + + sum / (values.length - 1) + end + end + end +end diff --git a/lib/gitlab/database/median.rb b/lib/gitlab/database/median.rb index 059054ac9ff..74fed447289 100644 --- a/lib/gitlab/database/median.rb +++ b/lib/gitlab/database/median.rb @@ -2,18 +2,14 @@ module Gitlab module Database module Median + NotSupportedError = Class.new(StandardError) + def median_datetime(arel_table, query_so_far, column_sym) - median_queries = - if Gitlab::Database.postgresql? - pg_median_datetime_sql(arel_table, query_so_far, column_sym) - elsif Gitlab::Database.mysql? - mysql_median_datetime_sql(arel_table, query_so_far, column_sym) - end - - results = Array.wrap(median_queries).map do |query| - ActiveRecord::Base.connection.execute(query) - end - extract_median(results).presence + extract_median(execute_queries(arel_table, query_so_far, column_sym)).presence + end + + def median_datetimes(arel_table, query_so_far, column_sym, partition_column) + extract_medians(execute_queries(arel_table, query_so_far, column_sym, partition_column)).presence end def extract_median(results) @@ -21,13 +17,21 @@ module Gitlab if Gitlab::Database.postgresql? result = result.first.presence - median = result['median'] if result - median.to_f if median + + result['median']&.to_f if result elsif Gitlab::Database.mysql? result.to_a.flatten.first end end + def extract_medians(results) + median_values = results.compact.first.values + + median_values.each_with_object({}) do |(id, median), hash| + hash[id.to_i] = median&.to_f + end + end + def mysql_median_datetime_sql(arel_table, query_so_far, column_sym) query = arel_table .from(arel_table.project(Arel.sql('*')).order(arel_table[column_sym]).as(arel_table.table_name)) @@ -53,7 +57,7 @@ module Gitlab ] end - def pg_median_datetime_sql(arel_table, query_so_far, column_sym) + def pg_median_datetime_sql(arel_table, query_so_far, column_sym, partition_column = nil) # Create a CTE with the column we're operating on, row number (after sorting by the column # we're operating on), and count of the table we're operating on (duplicated across) all rows # of the CTE. For example, if we're looking to find the median of the `projects.star_count` @@ -64,41 +68,107 @@ module Gitlab # 5 | 1 | 3 # 9 | 2 | 3 # 15 | 3 | 3 + # + # If a partition column is used we will do the same operation but for separate partitions, + # when that happens the CTE might look like this: + # + # project_id | star_count | row_id | ct + # ------------+------------+--------+---- + # 1 | 5 | 1 | 2 + # 1 | 9 | 2 | 2 + # 2 | 10 | 1 | 3 + # 2 | 15 | 2 | 3 + # 2 | 20 | 3 | 3 cte_table = Arel::Table.new("ordered_records") + cte = Arel::Nodes::As.new( cte_table, - arel_table - .project( - arel_table[column_sym].as(column_sym.to_s), - Arel::Nodes::Over.new(Arel::Nodes::NamedFunction.new("row_number", []), - Arel::Nodes::Window.new.order(arel_table[column_sym])).as('row_id'), - arel_table.project("COUNT(1)").as('ct')). + arel_table.project(*rank_rows(arel_table, column_sym, partition_column)). # Disallow negative values where(arel_table[column_sym].gteq(zero_interval))) # From the CTE, select either the middle row or the middle two rows (this is accomplished # by 'where cte.row_id between cte.ct / 2.0 AND cte.ct / 2.0 + 1'). Find the average of the # selected rows, and this is the median value. - cte_table.project(average([extract_epoch(cte_table[column_sym])], "median")) - .where( - Arel::Nodes::Between.new( - cte_table[:row_id], - Arel::Nodes::And.new( - [(cte_table[:ct] / Arel.sql('2.0')), - (cte_table[:ct] / Arel.sql('2.0') + 1)] + result = + cte_table + .project(*median_projections(cte_table, column_sym, partition_column)) + .where( + Arel::Nodes::Between.new( + cte_table[:row_id], + Arel::Nodes::And.new( + [(cte_table[:ct] / Arel.sql('2.0')), + (cte_table[:ct] / Arel.sql('2.0') + 1)] + ) ) ) - ) - .with(query_so_far, cte) - .to_sql + .with(query_so_far, cte) + + result.group(cte_table[partition_column]).order(cte_table[partition_column]) if partition_column + + result.to_sql end private + def median_queries(arel_table, query_so_far, column_sym, partition_column = nil) + if Gitlab::Database.postgresql? + pg_median_datetime_sql(arel_table, query_so_far, column_sym, partition_column) + elsif Gitlab::Database.mysql? + raise NotSupportedError, "partition_column is not supported for MySQL" if partition_column + + mysql_median_datetime_sql(arel_table, query_so_far, column_sym) + end + end + + def execute_queries(arel_table, query_so_far, column_sym, partition_column = nil) + queries = median_queries(arel_table, query_so_far, column_sym, partition_column) + + Array.wrap(queries).map { |query| ActiveRecord::Base.connection.execute(query) } + end + def average(args, as) Arel::Nodes::NamedFunction.new("AVG", args, as) end + def rank_rows(arel_table, column_sym, partition_column) + column_row = arel_table[column_sym].as(column_sym.to_s) + + if partition_column + partition_row = arel_table[partition_column] + row_id = + Arel::Nodes::Over.new( + Arel::Nodes::NamedFunction.new('rank', []), + Arel::Nodes::Window.new.partition(arel_table[partition_column]) + .order(arel_table[column_sym]) + ).as('row_id') + + count = arel_table.from(arel_table.alias) + .project('COUNT(*)') + .where(arel_table[partition_column].eq(arel_table.alias[partition_column])) + .as('ct') + + [partition_row, column_row, row_id, count] + else + row_id = + Arel::Nodes::Over.new( + Arel::Nodes::NamedFunction.new('row_number', []), + Arel::Nodes::Window.new.order(arel_table[column_sym]) + ).as('row_id') + + count = arel_table.project("COUNT(1)").as('ct') + + [column_row, row_id, count] + end + end + + def median_projections(table, column_sym, partition_column) + projections = [] + projections << table[partition_column] if partition_column + projections << average([extract_epoch(table[column_sym])], "median") + projections + end + def extract_epoch(arel_attribute) Arel.sql(%Q{EXTRACT(EPOCH FROM "#{arel_attribute.relation.name}"."#{arel_attribute.name}")}) end diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index e3cbf017e55..d7c373ccd6f 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -467,7 +467,8 @@ module Gitlab follow: false, skip_merges: false, after: nil, - before: nil + before: nil, + all: false } options = default_options.merge(options) @@ -478,8 +479,9 @@ module Gitlab raise ArgumentError.new("invalid Repository#log limit: #{limit.inspect}") end + # TODO support options[:all] in Gitaly https://gitlab.com/gitlab-org/gitaly/issues/1049 gitaly_migrate(:find_commits) do |is_enabled| - if is_enabled + if is_enabled && !options[:all] gitaly_commit_client.find_commits(options) else raw_log(options).map { |c| Commit.decorate(self, c) } @@ -489,13 +491,16 @@ module Gitlab # Used in gitaly-ruby def raw_log(options) - actual_ref = options[:ref] || root_ref - begin - sha = sha_from_ref(actual_ref) - rescue Rugged::OdbError, Rugged::InvalidError, Rugged::ReferenceError - # Return an empty array if the ref wasn't found - return [] - end + sha = + unless options[:all] + actual_ref = options[:ref] || root_ref + begin + sha_from_ref(actual_ref) + rescue Rugged::OdbError, Rugged::InvalidError, Rugged::ReferenceError + # Return an empty array if the ref wasn't found + return [] + end + end log_by_shell(sha, options) end @@ -503,8 +508,9 @@ module Gitlab def count_commits(options) count_commits_options = process_count_commits_options(options) + # TODO add support for options[:all] in Gitaly https://gitlab.com/gitlab-org/gitaly/issues/1050 gitaly_migrate(:count_commits) do |is_enabled| - if is_enabled + if is_enabled && !options[:all] count_commits_by_gitaly(count_commits_options) else count_commits_by_shelling_out(count_commits_options) @@ -1032,6 +1038,21 @@ module Gitlab end end + def license_short_name + gitaly_migrate(:license_short_name) do |is_enabled| + if is_enabled + gitaly_repository_client.license_short_name + else + begin + # The licensee gem creates a Rugged object from the path: + # https://github.com/benbalter/licensee/blob/v8.7.0/lib/licensee/projects/git_project.rb + Licensee.license(path).try(:key) + rescue Rugged::Error + end + end + end + end + def with_repo_branch_commit(start_repository, start_branch_name) Gitlab::Git.check_namespace!(start_repository) start_repository = RemoteRepository.new(start_repository) unless start_repository.is_a?(RemoteRepository) @@ -1701,7 +1722,12 @@ module Gitlab cmd << '--no-merges' if options[:skip_merges] cmd << "--after=#{options[:after].iso8601}" if options[:after] cmd << "--before=#{options[:before].iso8601}" if options[:before] - cmd << sha + + if options[:all] + cmd += %w[--all --reverse] + else + cmd << sha + end # :path can be a string or an array of strings if options[:path].present? @@ -1918,7 +1944,16 @@ module Gitlab cmd << "--before=#{options[:before].iso8601}" if options[:before] cmd << "--max-count=#{options[:max_count]}" if options[:max_count] cmd << "--left-right" if options[:left_right] - cmd += %W[--count #{options[:ref]}] + cmd << '--count' + + cmd << if options[:all] + '--all' + elsif options[:ref] + options[:ref] + else + raise ArgumentError, "Please specify a valid ref or set the 'all' attribute to true" + end + cmd += %W[-- #{options[:path]}] if options[:path].present? cmd end @@ -2206,7 +2241,7 @@ module Gitlab with_worktree(squash_path, branch, sparse_checkout_files: diff_files, env: env) do # Apply diff of the `diff_range` to the worktree diff = run_git!(%W(diff --binary #{diff_range})) - run_git!(%w(apply --index), chdir: squash_path, env: env) do |stdin| + run_git!(%w(apply --index --whitespace=nowarn), chdir: squash_path, env: env) do |stdin| stdin.binmode stdin.write(diff) end diff --git a/lib/gitlab/git/tree.rb b/lib/gitlab/git/tree.rb index ba6058fd3c9..b6ceb542dd1 100644 --- a/lib/gitlab/git/tree.rb +++ b/lib/gitlab/git/tree.rb @@ -14,14 +14,14 @@ module Gitlab # Uses rugged for raw objects # # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/320 - def where(repository, sha, path = nil) + def where(repository, sha, path = nil, recursive = false) path = nil if path == '' || path == '/' Gitlab::GitalyClient.migrate(:tree_entries) do |is_enabled| if is_enabled - repository.gitaly_commit_client.tree_entries(repository, sha, path) + repository.gitaly_commit_client.tree_entries(repository, sha, path, recursive) else - tree_entries_from_rugged(repository, sha, path) + tree_entries_from_rugged(repository, sha, path, recursive) end end end @@ -57,7 +57,22 @@ module Gitlab end end - def tree_entries_from_rugged(repository, sha, path) + def tree_entries_from_rugged(repository, sha, path, recursive) + current_path_entries = get_tree_entries_from_rugged(repository, sha, path) + ordered_entries = [] + + current_path_entries.each do |entry| + ordered_entries << entry + + if recursive && entry.dir? + ordered_entries.concat(tree_entries_from_rugged(repository, sha, entry.path, true)) + end + end + + ordered_entries + end + + def get_tree_entries_from_rugged(repository, sha, path) commit = repository.lookup(sha) root_tree = commit.tree diff --git a/lib/gitlab/git/wiki.rb b/lib/gitlab/git/wiki.rb index ac12271a87e..52b44b9b3c5 100644 --- a/lib/gitlab/git/wiki.rb +++ b/lib/gitlab/git/wiki.rb @@ -59,7 +59,7 @@ module Gitlab end def pages(limit: nil) - @repository.gitaly_migrate(:wiki_get_all_pages, status: Gitlab::GitalyClient::MigrationStatus::DISABLED) do |is_enabled| + @repository.gitaly_migrate(:wiki_get_all_pages) do |is_enabled| if is_enabled gitaly_get_all_pages else @@ -68,9 +68,8 @@ module Gitlab end end - # Disable because of https://gitlab.com/gitlab-org/gitlab-ce/issues/42039 def page(title:, version: nil, dir: nil) - @repository.gitaly_migrate(:wiki_find_page, status: Gitlab::GitalyClient::MigrationStatus::DISABLED) do |is_enabled| + @repository.gitaly_migrate(:wiki_find_page) do |is_enabled| if is_enabled gitaly_find_page(title: title, version: version, dir: dir) else diff --git a/lib/gitlab/gitaly_client/commit_service.rb b/lib/gitlab/gitaly_client/commit_service.rb index 269a048cf5d..d60f57717b5 100644 --- a/lib/gitlab/gitaly_client/commit_service.rb +++ b/lib/gitlab/gitaly_client/commit_service.rb @@ -105,11 +105,12 @@ module Gitlab entry unless entry.oid.blank? end - def tree_entries(repository, revision, path) + def tree_entries(repository, revision, path, recursive) request = Gitaly::GetTreeEntriesRequest.new( repository: @gitaly_repo, revision: encode_binary(revision), - path: path.present? ? encode_binary(path) : '.' + path: path.present? ? encode_binary(path) : '.', + recursive: recursive ) response = GitalyClient.call(@repository.storage, :commit_service, :get_tree_entries, request, timeout: GitalyClient.medium_timeout) diff --git a/lib/gitlab/gitaly_client/repository_service.rb b/lib/gitlab/gitaly_client/repository_service.rb index 603457d0664..fdb3247cf4d 100644 --- a/lib/gitlab/gitaly_client/repository_service.rb +++ b/lib/gitlab/gitaly_client/repository_service.rb @@ -41,7 +41,7 @@ module Gitlab end def apply_gitattributes(revision) - request = Gitaly::ApplyGitattributesRequest.new(repository: @gitaly_repo, revision: revision) + request = Gitaly::ApplyGitattributesRequest.new(repository: @gitaly_repo, revision: encode_binary(revision)) GitalyClient.call(@storage, :repository_service, :apply_gitattributes, request) end @@ -249,6 +249,14 @@ module Gitlab raise Gitlab::Git::OSError.new(response.error) unless response.error.empty? end + + def license_short_name + request = Gitaly::FindLicenseRequest.new(repository: @gitaly_repo) + + response = GitalyClient.call(@storage, :repository_service, :find_license, request, timeout: GitalyClient.fast_timeout) + + response.license_short_name.presence + end end end end diff --git a/lib/gitlab/plugin.rb b/lib/gitlab/plugin.rb new file mode 100644 index 00000000000..0d1cb16b378 --- /dev/null +++ b/lib/gitlab/plugin.rb @@ -0,0 +1,26 @@ +module Gitlab + module Plugin + def self.files + Dir.glob(Rails.root.join('plugins/*')).select do |entry| + File.file?(entry) + end + end + + def self.execute_all_async(data) + args = files.map { |file| [file, data] } + + PluginWorker.bulk_perform_async(args) + end + + def self.execute(file, data) + result = Gitlab::Popen.popen_with_detail([file]) do |stdin| + stdin.write(data.to_json) + end + + exit_status = result.status&.exitstatus + [exit_status.zero?, result.stderr] + rescue => e + [false, e.message] + end + end +end diff --git a/lib/gitlab/plugin_logger.rb b/lib/gitlab/plugin_logger.rb new file mode 100644 index 00000000000..c4f6ec3e21d --- /dev/null +++ b/lib/gitlab/plugin_logger.rb @@ -0,0 +1,7 @@ +module Gitlab + class PluginLogger < Gitlab::Logger + def self.file_name_noext + 'plugin' + end + end +end diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index 5a5ae7f19d4..781783f4d97 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -1,6 +1,8 @@ module Gitlab class SearchResults class FoundBlob + include EncodingHelper + attr_reader :id, :filename, :basename, :ref, :startline, :data, :project_id def initialize(opts = {}) @@ -9,7 +11,7 @@ module Gitlab @basename = opts.fetch(:basename, nil) @ref = opts.fetch(:ref, nil) @startline = opts.fetch(:startline, nil) - @data = opts.fetch(:data, nil) + @data = encode_utf8(opts.fetch(:data, nil)) @per_page = opts.fetch(:per_page, 20) @project_id = opts.fetch(:project_id, nil) end diff --git a/lib/gitlab/slash_commands/base_command.rb b/lib/gitlab/slash_commands/base_command.rb index cc3c9a50555..466554e398c 100644 --- a/lib/gitlab/slash_commands/base_command.rb +++ b/lib/gitlab/slash_commands/base_command.rb @@ -31,10 +31,11 @@ module Gitlab raise NotImplementedError end - attr_accessor :project, :current_user, :params + attr_accessor :project, :current_user, :params, :chat_name - def initialize(project, user, params = {}) - @project, @current_user, @params = project, user, params.dup + def initialize(project, chat_name, params = {}) + @project, @current_user, @params = project, chat_name.user, params.dup + @chat_name = chat_name end private diff --git a/lib/gitlab/slash_commands/command.rb b/lib/gitlab/slash_commands/command.rb index a78408b0519..85aaa6b0eba 100644 --- a/lib/gitlab/slash_commands/command.rb +++ b/lib/gitlab/slash_commands/command.rb @@ -13,12 +13,13 @@ module Gitlab if command if command.allowed?(project, current_user) - command.new(project, current_user, params).execute(match) + command.new(project, chat_name, params).execute(match) else Gitlab::SlashCommands::Presenters::Access.new.access_denied end else - Gitlab::SlashCommands::Help.new(project, current_user, params).execute(available_commands, params[:text]) + Gitlab::SlashCommands::Help.new(project, chat_name, params) + .execute(available_commands, params[:text]) end end diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index 9d13d1d781f..37d3512990e 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -9,6 +9,7 @@ module Gitlab license_usage_data.merge(system_usage_data) .merge(features_usage_data) .merge(components_usage_data) + .merge(cycle_analytics_usage_data) end def to_json(force_refresh: false) @@ -71,6 +72,10 @@ module Gitlab } end + def cycle_analytics_usage_data + Gitlab::CycleAnalytics::UsageData.new.to_json + end + def features_usage_data features_usage_data_ce end diff --git a/lib/haml_lint/inline_javascript.rb b/lib/haml_lint/inline_javascript.rb index f5485eb89fa..4f776330e80 100644 --- a/lib/haml_lint/inline_javascript.rb +++ b/lib/haml_lint/inline_javascript.rb @@ -12,6 +12,12 @@ unless Rails.env.production? record_lint(node, 'Inline JavaScript is discouraged (https://docs.gitlab.com/ee/development/gotchas.html#do-not-use-inline-javascript-in-views)') end + + def visit_tag(node) + return unless node.tag_name == 'script' + + record_lint(node, 'Inline JavaScript is discouraged (https://docs.gitlab.com/ee/development/gotchas.html#do-not-use-inline-javascript-in-views)') + end end end end diff --git a/lib/tasks/plugins.rake b/lib/tasks/plugins.rake new file mode 100644 index 00000000000..e73dd7e68df --- /dev/null +++ b/lib/tasks/plugins.rake @@ -0,0 +1,16 @@ +namespace :plugins do + desc 'Validate existing plugins' + task validate: :environment do + puts 'Validating plugins from /plugins directory' + + Gitlab::Plugin.files.each do |file| + success, message = Gitlab::Plugin.execute(file, Gitlab::DataBuilder::Push::SAMPLE_DATA) + + if success + puts "* #{file} succeed (zero exit code)." + else + puts "* #{file} failure (non-zero exit code). #{message}" + end + end + end +end diff --git a/package.json b/package.json index 043af80a3be..cbad55b4c85 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "scripts": { - "dev-server": "nodemon -w 'config/webpack.config.js' -w 'app/assets/javascripts/dispatcher.js' -w 'app/assets/javascripts/pages/**/index.js' --exec 'webpack-dev-server --config config/webpack.config.js'", + "dev-server": "nodemon -w 'config/webpack.config.js' --exec 'webpack-dev-server --config config/webpack.config.js'", "eslint": "eslint --max-warnings 0 --ext .js,.vue .", "eslint-fix": "eslint --max-warnings 0 --ext .js,.vue --fix .", "eslint-report": "eslint --max-warnings 0 --ext .js,.vue --format html --output-file ./eslint-report.html .", @@ -116,6 +116,6 @@ "karma-webpack": "2.0.7", "nodemon": "^1.15.1", "prettier": "1.9.2", - "webpack-dev-server": "^2.11.1" + "webpack-dev-server": "^2.11.2" } } diff --git a/plugins/examples/save_to_file.clj b/plugins/examples/save_to_file.clj new file mode 100755 index 00000000000..a59d83749d3 --- /dev/null +++ b/plugins/examples/save_to_file.clj @@ -0,0 +1,3 @@ +#!/usr/bin/env clojure +(let [in (slurp *in*)] + (spit "/tmp/clj-data.txt" in)) diff --git a/plugins/examples/save_to_file.rb b/plugins/examples/save_to_file.rb new file mode 100755 index 00000000000..61b0df9bfd6 --- /dev/null +++ b/plugins/examples/save_to_file.rb @@ -0,0 +1,3 @@ +#!/usr/bin/env ruby +x = STDIN.read +File.write('/tmp/rb-data.txt', x) diff --git a/scripts/security-harness b/scripts/security-harness index d454f44dff7..c60b3410095 100755 --- a/scripts/security-harness +++ b/scripts/security-harness @@ -21,6 +21,8 @@ else File.open(hook_path, 'w') do |file| IO.copy_stream(DATA, file) end + + File.chmod(0755, hook_path) end # Toggle the harness on or off diff --git a/spec/controllers/oauth/authorizations_controller_spec.rb b/spec/controllers/oauth/authorizations_controller_spec.rb index 004b463e745..149b690ff70 100644 --- a/spec/controllers/oauth/authorizations_controller_spec.rb +++ b/spec/controllers/oauth/authorizations_controller_spec.rb @@ -34,6 +34,8 @@ describe Oauth::AuthorizationsController do end context 'with valid params' do + render_views + it 'returns 200 code and renders view' do get :new, params diff --git a/spec/controllers/projects/clusters_controller_spec.rb b/spec/controllers/projects/clusters_controller_spec.rb index 954fc79f57d..15ce418d0d6 100644 --- a/spec/controllers/projects/clusters_controller_spec.rb +++ b/spec/controllers/projects/clusters_controller_spec.rb @@ -91,6 +91,12 @@ describe Projects::ClustersController do expect(response).to have_gitlab_http_status(:ok) expect(response).to match_response_schema('cluster_status') end + + it 'invokes schedule_status_update on each application' do + expect_any_instance_of(Clusters::Applications::Ingress).to receive(:schedule_status_update) + + go + end end describe 'security' do diff --git a/spec/controllers/projects/cycle_analytics_controller_spec.rb b/spec/controllers/projects/cycle_analytics_controller_spec.rb index 7c708a418a7..5516c95d044 100644 --- a/spec/controllers/projects/cycle_analytics_controller_spec.rb +++ b/spec/controllers/projects/cycle_analytics_controller_spec.rb @@ -27,7 +27,7 @@ describe Projects::CycleAnalyticsController do milestone = create(:milestone, project: project, created_at: 5.days.ago) issue.update(milestone: milestone) - create_merge_request_closing_issue(issue) + create_merge_request_closing_issue(user, project, issue) end it 'is false' do diff --git a/spec/controllers/projects/discussions_controller_spec.rb b/spec/controllers/projects/discussions_controller_spec.rb index 00328d3ea51..fcb0c2f28c8 100644 --- a/spec/controllers/projects/discussions_controller_spec.rb +++ b/spec/controllers/projects/discussions_controller_spec.rb @@ -71,6 +71,19 @@ describe Projects::DiscussionsController do expect(response).to have_gitlab_http_status(200) end + + context "when vue_mr_discussions cookie is present" do + before do + allow(controller).to receive(:cookies).and_return(vue_mr_discussions: 'true') + end + + it "renders discussion with serializer" do + expect_any_instance_of(DiscussionSerializer).to receive(:represent) + .with(instance_of(Discussion), { context: instance_of(described_class) }) + + post :resolve, request_params + end + end end end end @@ -119,6 +132,19 @@ describe Projects::DiscussionsController do expect(response).to have_gitlab_http_status(200) end + + context "when vue_mr_discussions cookie is present" do + before do + allow(controller).to receive(:cookies).and_return({ vue_mr_discussions: 'true' }) + end + + it "renders discussion with serializer" do + expect_any_instance_of(DiscussionSerializer).to receive(:represent) + .with(instance_of(Discussion), { context: instance_of(described_class) }) + + delete :unresolve, request_params + end + end end end end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 9656e7f7e74..9918d52e402 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -974,7 +974,7 @@ describe Projects::IssuesController do it 'returns discussion json' do get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid - expect(json_response.first.keys).to match_array(%w[id reply_id expanded notes individual_note]) + expect(json_response.first.keys).to match_array(%w[id reply_id expanded notes diff_discussion individual_note resolvable resolve_with_issue_path resolved]) end context 'with cross-reference system note', :request_store do diff --git a/spec/controllers/projects/pages_domains_controller_spec.rb b/spec/controllers/projects/pages_domains_controller_spec.rb index 2192fd5cae2..83a3799e883 100644 --- a/spec/controllers/projects/pages_domains_controller_spec.rb +++ b/spec/controllers/projects/pages_domains_controller_spec.rb @@ -53,6 +53,66 @@ describe Projects::PagesDomainsController do end end + describe 'GET edit' do + it "displays the 'edit' page" do + get(:edit, request_params.merge(id: pages_domain.domain)) + + expect(response).to have_gitlab_http_status(200) + expect(response).to render_template('edit') + end + end + + describe 'PATCH update' do + before do + controller.instance_variable_set(:@domain, pages_domain) + end + + let(:pages_domain_params) do + attributes_for(:pages_domain, :with_certificate, :with_key).slice(:key, :certificate) + end + + let(:params) do + request_params.merge(id: pages_domain.domain, pages_domain: pages_domain_params) + end + + it 'updates the domain' do + expect(pages_domain) + .to receive(:update) + .with(pages_domain_params) + .and_return(true) + + patch(:update, params) + end + + it 'redirects to the project page' do + patch(:update, params) + + expect(flash[:notice]).to eq 'Domain was updated' + expect(response).to redirect_to(project_pages_path(project)) + end + + context 'the domain is invalid' do + it 'renders the edit action' do + allow(pages_domain).to receive(:update).and_return(false) + + patch(:update, params) + + expect(response).to render_template('edit') + end + end + + context 'the parameters include the domain' do + it 'renders 400 Bad Request' do + expect(pages_domain) + .to receive(:update) + .with(hash_not_including(:domain)) + .and_return(true) + + patch(:update, params.deep_merge(pages_domain: { domain: 'abc' })) + end + end + end + describe 'POST verify' do let(:params) { request_params.merge(id: pages_domain.domain) } diff --git a/spec/features/admin/services/admin_activates_prometheus_spec.rb b/spec/features/admin/services/admin_activates_prometheus_spec.rb new file mode 100644 index 00000000000..904fe5b406b --- /dev/null +++ b/spec/features/admin/services/admin_activates_prometheus_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +describe 'Admin activates Prometheus' do + let(:admin) { create(:user, :admin) } + + before do + sign_in(admin) + + visit(admin_application_settings_services_path) + + click_link('Prometheus') + end + + it 'activates service' do + check('Active') + fill_in('API URL', with: 'http://prometheus.example.com') + click_button('Save') + + expect(page).to have_content('Application settings saved successfully') + end +end diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb index 510677ecf56..ef493db3f11 100644 --- a/spec/features/cycle_analytics_spec.rb +++ b/spec/features/cycle_analytics_spec.rb @@ -6,7 +6,7 @@ feature 'Cycle Analytics', :js do let(:project) { create(:project, :repository) } let(:issue) { create(:issue, project: project, created_at: 2.days.ago) } let(:milestone) { create(:milestone, project: project) } - let(:mr) { create_merge_request_closing_issue(issue, commit_message: "References #{issue.to_reference}") } + let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") } let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) } context 'as an allowed user' do @@ -41,8 +41,8 @@ feature 'Cycle Analytics', :js do allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue]) project.add_master(user) - create_cycle - deploy_master + @build = create_cycle(user, project, issue, mr, milestone, pipeline) + deploy_master(user, project) sign_in(user) visit project_cycle_analytics_path(project) @@ -117,8 +117,8 @@ feature 'Cycle Analytics', :js do project.add_guest(guest) allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue]) - create_cycle - deploy_master + create_cycle(user, project, issue, mr, milestone, pipeline) + deploy_master(user, project) sign_in(guest) visit project_cycle_analytics_path(project) @@ -166,16 +166,6 @@ feature 'Cycle Analytics', :js do expect(find('.stage-events')).to have_content("!#{mr.iid}") end - def create_cycle - issue.update(milestone: milestone) - pipeline.run - - @build = create(:ci_build, pipeline: pipeline, status: :success, author: user) - - merge_merge_requests_closing_issue(issue) - ProcessCommitWorker.new.perform(project.id, user.id, mr.commits.last.to_hash) - end - def click_stage(stage_name) find('.stage-nav li', text: stage_name).click wait_for_requests diff --git a/spec/features/groups/empty_states_spec.rb b/spec/features/groups/empty_states_spec.rb index 243e8536168..04217fec06c 100644 --- a/spec/features/groups/empty_states_spec.rb +++ b/spec/features/groups/empty_states_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature 'Groups Merge Requests Empty States' do +feature 'Group empty states' do let(:group) { create(:group) } let(:user) { create(:group_member, :developer, user: create(:user), group: group ).user } @@ -8,62 +8,100 @@ feature 'Groups Merge Requests Empty States' do sign_in(user) end - context 'group has a project' do - let(:project) { create(:project, namespace: group) } + [:issue, :merge_request].each do |issuable| + issuable_name = issuable.to_s.humanize.downcase + project_relation = issuable == :issue ? :project : :source_project - before do - project.add_master(user) - end + context "for #{issuable_name}s" do + let(:path) { public_send(:"#{issuable}s_group_path", group) } - context 'the project has a merge request' do - before do - create(:merge_request, source_project: project) + context 'group has a project' do + let(:project) { create(:project, namespace: group) } - visit merge_requests_group_path(group) - end + before do + project.add_master(user) + end - it 'should not display an empty state' do - expect(page).not_to have_selector('.empty-state') - end - end + context "the project has #{issuable_name}s" do + before do + create(issuable, project_relation => project) - context 'the project has no merge requests', :js do - before do - visit merge_requests_group_path(group) - end + visit path + end - it 'should display an empty state' do - expect(page).to have_selector('.empty-state') - end + it 'does not display an empty state' do + expect(page).not_to have_selector('.empty-state') + end + end + + context "the project has no #{issuable_name}s", :js do + before do + visit path + end + + it 'displays an empty state' do + expect(page).to have_selector('.empty-state') + end + + it "shows a new #{issuable_name} button" do + within '.empty-state' do + expect(page).to have_content("create #{issuable_name}") + end + end + + it "the new #{issuable_name} button opens a project dropdown" do + within '.empty-state' do + find('.new-project-item-select-button').click + end - it 'should show a new merge request button' do - within '.empty-state' do - expect(page).to have_content('create merge request') + expect(page).to have_selector('.ajax-project-dropdown') + end end end - it 'the new merge request button opens a project dropdown' do - within '.empty-state' do - find('.new-project-item-select-button').click - end + context 'group without a project' do + context 'group has a subgroup', :nested_groups do + let(:subgroup) { create(:group, parent: group) } + let(:subgroup_project) { create(:project, namespace: subgroup) } - expect(page).to have_selector('.ajax-project-dropdown') - end - end - end + context "the project has #{issuable_name}s" do + before do + create(issuable, project_relation => subgroup_project) - context 'group without a project' do - before do - visit merge_requests_group_path(group) - end + visit path + end - it 'should display an empty state' do - expect(page).to have_selector('.empty-state') - end + it 'does not display an empty state' do + expect(page).not_to have_selector('.empty-state') + end + end - it 'should not show a new merge request button' do - within '.empty-state' do - expect(page).not_to have_link('create merge request') + context "the project has no #{issuable_name}s" do + before do + visit path + end + + it 'displays an empty state' do + expect(page).to have_selector('.empty-state') + end + end + end + + context 'group has no subgroups' do + before do + visit path + end + + it 'displays an empty state' do + expect(page).to have_selector('.empty-state') + end + + it "shows a new #{issuable_name} button" do + within '.empty-state' do + expect(page).not_to have_link("create #{issuable_name}") + end + end + end end end end diff --git a/spec/features/groups/members/manage_members.rb b/spec/features/groups/members/manage_members_spec.rb index 21f7b4999ad..21f7b4999ad 100644 --- a/spec/features/groups/members/manage_members.rb +++ b/spec/features/groups/members/manage_members_spec.rb diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb index faf14be4818..ef6b8edd0ad 100644 --- a/spec/features/issues/form_spec.rb +++ b/spec/features/issues/form_spec.rb @@ -189,6 +189,18 @@ describe 'New/edit issue', :js do expect(find('.js-label-select')).to have_content('Labels') end + it 'clears label search input field when a label is selected' do + click_button 'Labels' + + page.within '.dropdown-menu-labels' do + search_field = find('input[type="search"]') + + search_field.set(label2.title) + click_link label2.title + expect(search_field.value).to eq '' + end + end + it 'correctly updates the selected user when changing assignee' do click_button 'Unassigned' @@ -271,6 +283,18 @@ describe 'New/edit issue', :js do end end + context 'inline edit' do + before do + visit project_issue_path(project, issue) + end + + it 'opens inline edit form with shortcut' do + find('body').send_keys('e') + + expect(page).to have_selector('.detail-page-description form') + end + end + describe 'sub-group project' do let(:group) { create(:group) } let(:nested_group_1) { create(:group, parent: group) } diff --git a/spec/features/merge_request/user_posts_notes_spec.rb b/spec/features/merge_request/user_posts_notes_spec.rb index 50d06565fc0..b54addce993 100644 --- a/spec/features/merge_request/user_posts_notes_spec.rb +++ b/spec/features/merge_request/user_posts_notes_spec.rb @@ -144,7 +144,7 @@ describe 'Merge request > User posts notes', :js do end end - describe 'deleting an attachment' do + describe 'deleting attachment on legacy diff note' do before do find('.note').hover diff --git a/spec/features/profiles/password_spec.rb b/spec/features/profiles/password_spec.rb index 1d7700b6767..f9c6ff90ca1 100644 --- a/spec/features/profiles/password_spec.rb +++ b/spec/features/profiles/password_spec.rb @@ -134,5 +134,15 @@ describe 'Profile > Password' do expect(current_path).to eq new_user_session_path end + + context 'when global require_two_factor_authentication is enabled' do + it 'needs change user password' do + stub_application_setting(require_two_factor_authentication: true) + + visit profile_path + + expect(current_path).to eq new_profile_password_path + end + end end end diff --git a/spec/features/projects/clusters/applications_spec.rb b/spec/features/projects/clusters/applications_spec.rb index 8d1e10b7191..7b2c57aa652 100644 --- a/spec/features/projects/clusters/applications_spec.rb +++ b/spec/features/projects/clusters/applications_spec.rb @@ -22,7 +22,7 @@ feature 'Clusters Applications', :js do scenario 'user is unable to install applications' do page.within('.js-cluster-application-row-helm') do expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true') - expect(page.find(:css, '.js-cluster-application-install-button').text).to eq('Install') + expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install') end end end @@ -33,13 +33,13 @@ feature 'Clusters Applications', :js do scenario 'user can install applications' do page.within('.js-cluster-application-row-helm') do expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to be_nil - expect(page.find(:css, '.js-cluster-application-install-button')).to have_content('Install') + expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install') end end context 'when user installs Helm' do before do - allow(ClusterInstallAppWorker).to receive(:perform_async).and_return(nil) + allow(ClusterInstallAppWorker).to receive(:perform_async) page.within('.js-cluster-application-row-helm') do page.find(:css, '.js-cluster-application-install-button').click @@ -50,18 +50,18 @@ feature 'Clusters Applications', :js do page.within('.js-cluster-application-row-helm') do # FE sends request and gets the response, then the buttons is "Install" expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true') - expect(page.find(:css, '.js-cluster-application-install-button')).to have_content('Install') + expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install') Clusters::Cluster.last.application_helm.make_installing! # FE starts polling and update the buttons to "Installing" expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true') - expect(page.find(:css, '.js-cluster-application-install-button')).to have_content('Installing') + expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installing') Clusters::Cluster.last.application_helm.make_installed! expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true') - expect(page.find(:css, '.js-cluster-application-install-button')).to have_content('Installed') + expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installed') end expect(page).to have_content('Helm Tiller was successfully installed on your Kubernetes cluster') @@ -71,11 +71,14 @@ feature 'Clusters Applications', :js do context 'when user installs Ingress' do context 'when user installs application: Ingress' do before do - allow(ClusterInstallAppWorker).to receive(:perform_async).and_return(nil) + allow(ClusterInstallAppWorker).to receive(:perform_async) + allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_in) + allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_async) create(:clusters_applications_helm, :installed, cluster: cluster) page.within('.js-cluster-application-row-ingress') do + expect(page).to have_css('.js-cluster-application-install-button:not([disabled])') page.find(:css, '.js-cluster-application-install-button').click end end @@ -83,19 +86,28 @@ feature 'Clusters Applications', :js do it 'he sees status transition' do page.within('.js-cluster-application-row-ingress') do # FE sends request and gets the response, then the buttons is "Install" - expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true') - expect(page.find(:css, '.js-cluster-application-install-button')).to have_content('Install') + expect(page).to have_css('.js-cluster-application-install-button[disabled]') + expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install') Clusters::Cluster.last.application_ingress.make_installing! # FE starts polling and update the buttons to "Installing" - expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true') - expect(page.find(:css, '.js-cluster-application-install-button')).to have_content('Installing') + expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installing') + expect(page).to have_css('.js-cluster-application-install-button[disabled]') + # The application becomes installed but we keep waiting for external IP address Clusters::Cluster.last.application_ingress.make_installed! - expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true') - expect(page.find(:css, '.js-cluster-application-install-button')).to have_content('Installed') + expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installed') + expect(page).to have_css('.js-cluster-application-install-button[disabled]') + expect(page).to have_selector('.js-no-ip-message') + expect(page.find('.js-ip-address').value).to eq('?') + + # We receive the external IP address and display + Clusters::Cluster.last.application_ingress.update!(external_ip: '192.168.1.100') + + expect(page).not_to have_selector('.js-no-ip-message') + expect(page.find('.js-ip-address').value).to eq('192.168.1.100') end expect(page).to have_content('Ingress was successfully installed on your Kubernetes cluster') diff --git a/spec/features/projects/pages_spec.rb b/spec/features/projects/pages_spec.rb index a96f2c186a4..2a0d235ef04 100644 --- a/spec/features/projects/pages_spec.rb +++ b/spec/features/projects/pages_spec.rb @@ -160,6 +160,37 @@ feature 'Pages' do expect(page).to have_content('my.test.domain.com') end + + describe 'updating the certificate for an existing domain' do + let!(:domain) do + create(:pages_domain, :with_key, :with_certificate, project: project) + end + + it 'allows the certificate to be updated' do + visit project_pages_path(project) + + within('#content-body') { click_link 'Details' } + click_link 'Edit' + click_button 'Save Changes' + + expect(page).to have_content('Domain was updated') + end + + context 'when the certificate is invalid' do + it 'tells the user what the problem is' do + visit project_pages_path(project) + + within('#content-body') { click_link 'Details' } + click_link 'Edit' + fill_in 'Certificate (PEM)', with: 'invalid data' + click_button 'Save Changes' + + expect(page).to have_content('Certificate must be a valid PEM certificate') + expect(page).to have_content('Certificate misses intermediates') + expect(page).to have_content("Key doesn't match the certificate") + end + end + end end end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index 37a06b65481..3a8e7c05cc4 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -367,23 +367,6 @@ describe 'Pipelines', :js do expect(build.reload).to be_canceled end end - - context 'dropdown jobs list' do - it 'should keep the dropdown open when the user ctr/cmd + clicks in the job name' do - find('.js-builds-dropdown-button').click - dropdown_item = find('.mini-pipeline-graph-dropdown-item').native - - %i(alt control).each do |meta_key| - page.driver.browser.action - .key_down(meta_key) - .click(dropdown_item) - .key_up(meta_key) - .perform - end - - expect(page).to have_selector('.js-ci-action-icon') - end - end end context 'with pagination' do diff --git a/spec/features/projects/services/user_activates_prometheus_spec.rb b/spec/features/projects/services/user_activates_prometheus_spec.rb new file mode 100644 index 00000000000..33f884eb148 --- /dev/null +++ b/spec/features/projects/services/user_activates_prometheus_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +describe 'User activates Prometheus' 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('Prometheus') + end + + it 'activates service' do + check('Active') + fill_in('API URL', with: 'http://prometheus.example.com') + click_button('Save changes') + + expect(page).to have_content('Prometheus activated.') + end +end diff --git a/spec/finders/labels_finder_spec.rb b/spec/finders/labels_finder_spec.rb index 06031aee217..dc76efea35b 100644 --- a/spec/finders/labels_finder_spec.rb +++ b/spec/finders/labels_finder_spec.rb @@ -5,6 +5,8 @@ describe LabelsFinder do let(:group_1) { create(:group) } let(:group_2) { create(:group) } let(:group_3) { create(:group) } + let(:private_group_1) { create(:group, :private) } + let(:private_subgroup_1) { create(:group, :private, parent: private_group_1) } let(:project_1) { create(:project, namespace: group_1) } let(:project_2) { create(:project, namespace: group_2) } @@ -20,6 +22,8 @@ describe LabelsFinder do let!(:group_label_1) { create(:group_label, group: group_1, title: 'Label 1 (group)') } let!(:group_label_2) { create(:group_label, group: group_1, title: 'Group Label 2') } let!(:group_label_3) { create(:group_label, group: group_2, title: 'Group Label 3') } + let!(:private_group_label_1) { create(:group_label, group: private_group_1, title: 'Private Group Label 1') } + let!(:private_subgroup_label_1) { create(:group_label, group: private_subgroup_1, title: 'Private Sub Group Label 1') } let(:user) { create(:user) } @@ -66,6 +70,25 @@ describe LabelsFinder do expect(finder.execute).to eq [group_label_2, group_label_1] end end + + context 'when including labels from group ancestors', :nested_groups do + it 'returns labels from group and its ancestors' do + private_group_1.add_developer(user) + private_subgroup_1.add_developer(user) + + finder = described_class.new(user, group_id: private_subgroup_1.id, only_group_labels: true, include_ancestor_groups: true) + + expect(finder.execute).to eq [private_group_label_1, private_subgroup_label_1] + end + + it 'ignores labels from groups which user can not read' do + private_subgroup_1.add_developer(user) + + finder = described_class.new(user, group_id: private_subgroup_1.id, only_group_labels: true, include_ancestor_groups: true) + + expect(finder.execute).to eq [private_subgroup_label_1] + end + end end context 'filtering by project_id' do diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb index 9385c892c9e..7917a00fc50 100644 --- a/spec/finders/merge_requests_finder_spec.rb +++ b/spec/finders/merge_requests_finder_spec.rb @@ -18,7 +18,7 @@ describe MergeRequestsFinder do let(:project4) { create(:project, :public, group: subgroup) } let!(:merge_request1) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project1) } - let!(:merge_request2) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project1, state: 'closed') } + let!(:merge_request2) { create(:merge_request, :conflict, author: user, source_project: project2, target_project: project1, state: 'closed') } let!(:merge_request3) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project2) } let!(:merge_request4) { create(:merge_request, :simple, author: user, source_project: project3, target_project: project3) } let!(:merge_request5) { create(:merge_request, :simple, author: user, source_project: project4, target_project: project4) } @@ -74,6 +74,22 @@ describe MergeRequestsFinder do expect(merge_requests).to contain_exactly(merge_request1) end + it 'filters by source branch' do + params = { source_branch: merge_request2.source_branch } + + merge_requests = described_class.new(user, params).execute + + expect(merge_requests).to contain_exactly(merge_request2) + end + + it 'filters by target branch' do + params = { target_branch: merge_request2.target_branch } + + merge_requests = described_class.new(user, params).execute + + expect(merge_requests).to contain_exactly(merge_request2) + end + context 'filtering by group milestone' do let!(:group) { create(:group, :public) } let(:group_milestone) { create(:milestone, group: group) } diff --git a/spec/fixtures/api/schemas/cluster_status.json b/spec/fixtures/api/schemas/cluster_status.json index 489d563be2b..d27c12e43f2 100644 --- a/spec/fixtures/api/schemas/cluster_status.json +++ b/spec/fixtures/api/schemas/cluster_status.json @@ -30,7 +30,8 @@ ] } }, - "status_reason": { "type": ["string", "null"] } + "status_reason": { "type": ["string", "null"] }, + "external_ip": { "type": ["string", "null"] } }, "required" : [ "name", "status" ] } diff --git a/spec/fixtures/api/schemas/entities/merge_request_widget.json b/spec/fixtures/api/schemas/entities/merge_request_widget.json index 05461787f06..cfbeec58a45 100644 --- a/spec/fixtures/api/schemas/entities/merge_request_widget.json +++ b/spec/fixtures/api/schemas/entities/merge_request_widget.json @@ -75,7 +75,9 @@ "properties": { "can_remove_source_branch": { "type": "boolean" }, "can_revert_on_current_merge_request": { "type": ["boolean", "null"] }, - "can_cherry_pick_on_current_merge_request": { "type": ["boolean", "null"] } + "can_cherry_pick_on_current_merge_request": { "type": ["boolean", "null"] }, + "can_create_note": { "type": "boolean" }, + "can_update": { "type": "boolean" } }, "additionalProperties": false }, @@ -103,6 +105,7 @@ "merge_ongoing": { "type": "boolean" }, "ff_only_enabled": { "type": ["boolean", false] }, "should_be_rebased": { "type": "boolean" }, + "create_note_path": { "type": ["string", "null"] }, "rebase_commit_sha": { "type": ["string", "null"] }, "rebase_in_progress": { "type": "boolean" }, "can_push_to_source_branch": { "type": "boolean" }, diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index a030796c54e..1fa194fe1b8 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -86,7 +86,7 @@ describe BlobHelper do it 'verifies blob is text' do expect(helper).not_to receive(:blob_text_viewable?) - button = edit_blob_link(project, 'refs/heads/master', 'README.md') + button = edit_blob_button(project, 'refs/heads/master', 'README.md') expect(button).to start_with('<button') end @@ -96,17 +96,17 @@ describe BlobHelper do expect(project.repository).not_to receive(:blob_at) - edit_blob_link(project, 'refs/heads/master', 'README.md', blob: blob) + edit_blob_button(project, 'refs/heads/master', 'README.md', blob: blob) end it 'returns a link with the proper route' do - link = edit_blob_link(project, 'master', 'README.md') + link = edit_blob_button(project, 'master', 'README.md') expect(Capybara.string(link).find_link('Edit')[:href]).to eq("/#{project.full_path}/edit/master/README.md") end it 'returns a link with the passed link_opts on the expected route' do - link = edit_blob_link(project, 'master', 'README.md', link_opts: { mr_id: 10 }) + link = edit_blob_button(project, 'master', 'README.md', link_opts: { mr_id: 10 }) expect(Capybara.string(link).find_link('Edit')[:href]).to eq("/#{project.full_path}/edit/master/README.md?mr_id=10") end diff --git a/spec/javascripts/autosave_spec.js b/spec/javascripts/autosave_spec.js index 9f9acc392c2..b568d7fa8b0 100644 --- a/spec/javascripts/autosave_spec.js +++ b/spec/javascripts/autosave_spec.js @@ -3,28 +3,24 @@ import AccessorUtilities from '~/lib/utils/accessor'; describe('Autosave', () => { let autosave; + const field = $('<textarea></textarea>'); + const key = 'key'; describe('class constructor', () => { - const key = 'key'; - const field = jasmine.createSpyObj('field', ['data', 'on']); - beforeEach(() => { spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true); spyOn(Autosave.prototype, 'restore'); - - autosave = new Autosave(field, key); }); it('should set .isLocalStorageAvailable', () => { + autosave = new Autosave(field, key); + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); expect(autosave.isLocalStorageAvailable).toBe(true); }); }); describe('restore', () => { - const key = 'key'; - const field = jasmine.createSpyObj('field', ['trigger']); - beforeEach(() => { autosave = { field, @@ -49,24 +45,53 @@ describe('Autosave', () => { describe('if .isLocalStorageAvailable is `true`', () => { beforeEach(() => { autosave.isLocalStorageAvailable = true; - - Autosave.prototype.restore.call(autosave); }); it('should call .getItem', () => { + Autosave.prototype.restore.call(autosave); + expect(window.localStorage.getItem).toHaveBeenCalledWith(key); }); + + it('triggers jquery event', () => { + spyOn(autosave.field, 'trigger').and.callThrough(); + + Autosave.prototype.restore.call(autosave); + + expect( + field.trigger, + ).toHaveBeenCalled(); + }); + + it('triggers native event', (done) => { + autosave.field.get(0).addEventListener('change', () => { + done(); + }); + + Autosave.prototype.restore.call(autosave); + }); + }); + + describe('if field gets deleted from DOM', () => { + beforeEach(() => { + autosave.field = $('.not-a-real-element'); + }); + + it('does not trigger event', () => { + spyOn(field, 'trigger').and.callThrough(); + + expect( + field.trigger, + ).not.toHaveBeenCalled(); + }); }); }); describe('save', () => { - const field = jasmine.createSpyObj('field', ['val']); - beforeEach(() => { autosave = jasmine.createSpyObj('autosave', ['reset']); autosave.field = field; - - field.val.and.returnValue('value'); + field.val('value'); spyOn(window.localStorage, 'setItem'); }); @@ -97,8 +122,6 @@ describe('Autosave', () => { }); describe('reset', () => { - const key = 'key'; - beforeEach(() => { autosave = { key, diff --git a/spec/javascripts/boards/board_new_issue_spec.js b/spec/javascripts/boards/board_new_issue_spec.js index e204985f039..d5fbfdeaa91 100644 --- a/spec/javascripts/boards/board_new_issue_spec.js +++ b/spec/javascripts/boards/board_new_issue_spec.js @@ -4,7 +4,7 @@ import Vue from 'vue'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import boardNewIssue from '~/boards/components/board_new_issue'; +import boardNewIssue from '~/boards/components/board_new_issue.vue'; import '~/boards/models/list'; import { listObj, boardsMockInterceptor, mockBoardService } from './mock_data'; diff --git a/spec/javascripts/ci_variable_list/ci_variable_list_spec.js b/spec/javascripts/ci_variable_list/ci_variable_list_spec.js index cac785fd3c6..270f925e699 100644 --- a/spec/javascripts/ci_variable_list/ci_variable_list_spec.js +++ b/spec/javascripts/ci_variable_list/ci_variable_list_spec.js @@ -1,5 +1,5 @@ import VariableList from '~/ci_variable_list/ci_variable_list'; -import getSetTimeoutPromise from '../helpers/set_timeout_promise_helper'; +import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper'; const HIDE_CLASS = 'hide'; diff --git a/spec/javascripts/clusters/clusters_bundle_spec.js b/spec/javascripts/clusters/clusters_bundle_spec.js index a9e244e523d..a5cd247b689 100644 --- a/spec/javascripts/clusters/clusters_bundle_spec.js +++ b/spec/javascripts/clusters/clusters_bundle_spec.js @@ -7,7 +7,7 @@ import { REQUEST_SUCCESS, REQUEST_FAILURE, } from '~/clusters/constants'; -import getSetTimeoutPromise from '../helpers/set_timeout_promise_helper'; +import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper'; describe('Clusters', () => { let cluster; diff --git a/spec/javascripts/clusters/components/application_row_spec.js b/spec/javascripts/clusters/components/application_row_spec.js index e671c18e1a5..2c4707bb856 100644 --- a/spec/javascripts/clusters/components/application_row_spec.js +++ b/spec/javascripts/clusters/components/application_row_spec.js @@ -12,7 +12,7 @@ import { REQUEST_FAILURE, } from '~/clusters/constants'; import applicationRow from '~/clusters/components/application_row.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { DEFAULT_APPLICATION_STATE } from '../services/mock_data'; describe('Application Row', () => { diff --git a/spec/javascripts/clusters/components/applications_spec.js b/spec/javascripts/clusters/components/applications_spec.js index 1a8affad4e3..dfb4cc1b9b1 100644 --- a/spec/javascripts/clusters/components/applications_spec.js +++ b/spec/javascripts/clusters/components/applications_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import applications from '~/clusters/components/applications.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Applications', () => { let vm; @@ -44,4 +44,71 @@ describe('Applications', () => { }); /* */ }); + + describe('Ingress application', () => { + describe('when installed', () => { + describe('with ip address', () => { + it('renders ip address with a clipboard button', () => { + vm = mountComponent(Applications, { + applications: { + ingress: { + title: 'Ingress', + status: 'installed', + externalIp: '0.0.0.0', + }, + helm: { title: 'Helm Tiller' }, + runner: { title: 'GitLab Runner' }, + prometheus: { title: 'Prometheus' }, + }, + }); + + expect( + vm.$el.querySelector('.js-ip-address').value, + ).toEqual('0.0.0.0'); + + expect( + vm.$el.querySelector('.js-clipboard-btn').getAttribute('data-clipboard-text'), + ).toEqual('0.0.0.0'); + }); + }); + + describe('without ip address', () => { + it('renders an input text with a question mark and an alert text', () => { + vm = mountComponent(Applications, { + applications: { + ingress: { + title: 'Ingress', + status: 'installed', + }, + helm: { title: 'Helm Tiller' }, + runner: { title: 'GitLab Runner' }, + prometheus: { title: 'Prometheus' }, + }, + }); + + expect( + vm.$el.querySelector('.js-ip-address').value, + ).toEqual('?'); + + expect(vm.$el.querySelector('.js-no-ip-message')).not.toBe(null); + }); + }); + }); + + describe('before installing', () => { + it('does not render the IP address', () => { + vm = mountComponent(Applications, { + applications: { + helm: { title: 'Helm Tiller' }, + ingress: { title: 'Ingress' }, + runner: { title: 'GitLab Runner' }, + prometheus: { title: 'Prometheus' }, + }, + }); + + expect(vm.$el.textContent).not.toContain('Ingress IP Address'); + expect(vm.$el.querySelector('.js-ip-address')).toBe(null); + }); + }); + }); }); diff --git a/spec/javascripts/clusters/services/mock_data.js b/spec/javascripts/clusters/services/mock_data.js index 253b3c45243..6ae7a792329 100644 --- a/spec/javascripts/clusters/services/mock_data.js +++ b/spec/javascripts/clusters/services/mock_data.js @@ -18,6 +18,7 @@ const CLUSTERS_MOCK_DATA = { name: 'ingress', status: APPLICATION_ERROR, status_reason: 'Cannot connect', + external_ip: null, }, { name: 'runner', status: APPLICATION_INSTALLING, diff --git a/spec/javascripts/clusters/stores/clusters_store_spec.js b/spec/javascripts/clusters/stores/clusters_store_spec.js index 726a4ed30de..8028faf2f02 100644 --- a/spec/javascripts/clusters/stores/clusters_store_spec.js +++ b/spec/javascripts/clusters/stores/clusters_store_spec.js @@ -75,6 +75,7 @@ describe('Clusters Store', () => { statusReason: mockResponseData.applications[1].status_reason, requestStatus: null, requestReason: null, + externalIp: null, }, runner: { title: 'GitLab Runner', diff --git a/spec/javascripts/commit/commit_pipeline_status_component_spec.js b/spec/javascripts/commit/commit_pipeline_status_component_spec.js index 90f290e845e..421fe62a1e7 100644 --- a/spec/javascripts/commit/commit_pipeline_status_component_spec.js +++ b/spec/javascripts/commit/commit_pipeline_status_component_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; -import mountComponent from '../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Commit pipeline status component', () => { let vm; diff --git a/spec/javascripts/cycle_analytics/banner_spec.js b/spec/javascripts/cycle_analytics/banner_spec.js index 64a76a6ee5f..2815bdba0c2 100644 --- a/spec/javascripts/cycle_analytics/banner_spec.js +++ b/spec/javascripts/cycle_analytics/banner_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import banner from '~/cycle_analytics/components/banner.vue'; -import mountComponent from '../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Cycle analytics banner', () => { let vm; diff --git a/spec/javascripts/cycle_analytics/total_time_component_spec.js b/spec/javascripts/cycle_analytics/total_time_component_spec.js index ad0fc38a856..691e03cb8a6 100644 --- a/spec/javascripts/cycle_analytics/total_time_component_spec.js +++ b/spec/javascripts/cycle_analytics/total_time_component_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import component from '~/cycle_analytics/components/total_time_component.vue'; -import mountComponent from '../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Total time component', () => { let vm; diff --git a/spec/javascripts/environments/emtpy_state_spec.js b/spec/javascripts/environments/emtpy_state_spec.js index 82de35933f5..10a19af4175 100644 --- a/spec/javascripts/environments/emtpy_state_spec.js +++ b/spec/javascripts/environments/emtpy_state_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import emptyState from '~/environments/components/empty_state.vue'; -import mountComponent from '../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('environments empty state', () => { let vm; diff --git a/spec/javascripts/environments/environment_table_spec.js b/spec/javascripts/environments/environment_table_spec.js index 9bd42863759..0e5e50a59a5 100644 --- a/spec/javascripts/environments/environment_table_spec.js +++ b/spec/javascripts/environments/environment_table_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import environmentTableComp from '~/environments/components/environments_table.vue'; -import mountComponent from '../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Environment table', () => { let Component; diff --git a/spec/javascripts/environments/environments_app_spec.js b/spec/javascripts/environments/environments_app_spec.js index a41a4e5a3f7..5bb37304372 100644 --- a/spec/javascripts/environments/environments_app_spec.js +++ b/spec/javascripts/environments/environments_app_spec.js @@ -1,9 +1,9 @@ import _ from 'underscore'; import Vue from 'vue'; import environmentsComponent from '~/environments/components/environments_app.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { headersInterceptor } from 'spec/helpers/vue_resource_helper'; import { environment, folder } from './mock_data'; -import { headersInterceptor } from '../helpers/vue_resource_helper'; -import mountComponent from '../helpers/vue_mount_component_helper'; describe('Environment', () => { const mockData = { diff --git a/spec/javascripts/environments/folder/environments_folder_view_spec.js b/spec/javascripts/environments/folder/environments_folder_view_spec.js index a085074d312..906a1116974 100644 --- a/spec/javascripts/environments/folder/environments_folder_view_spec.js +++ b/spec/javascripts/environments/folder/environments_folder_view_spec.js @@ -1,9 +1,9 @@ import _ from 'underscore'; import Vue from 'vue'; import environmentsFolderViewComponent from '~/environments/folder/environments_folder_view.vue'; +import { headersInterceptor } from 'spec/helpers/vue_resource_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { environmentsList } from '../mock_data'; -import { headersInterceptor } from '../../helpers/vue_resource_helper'; -import mountComponent from '../../helpers/vue_mount_component_helper'; describe('Environments Folder View', () => { let Component; diff --git a/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js b/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js index 34ffc7b1016..1b1f28f3ddb 100644 --- a/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js +++ b/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js @@ -8,7 +8,7 @@ import { mouseenter, inserted, } from '~/feature_highlight/feature_highlight_helper'; -import getSetTimeoutPromise from '../helpers/set_timeout_promise_helper'; +import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper'; describe('feature highlight helper', () => { describe('getSelector', () => { diff --git a/spec/javascripts/fixtures/merge_requests.rb b/spec/javascripts/fixtures/merge_requests.rb index 3fd16d76f51..ee60489eb7c 100644 --- a/spec/javascripts/fixtures/merge_requests.rb +++ b/spec/javascripts/fixtures/merge_requests.rb @@ -70,8 +70,50 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont render_merge_request(example.description, merge_request) end + it 'merge_requests/discussions.json' do |example| + create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request) + render_discussions_json(merge_request, example.description) + end + + it 'merge_requests/diff_discussion.json' do |example| + create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request) + render_discussions_json(merge_request, example.description) + end + + context 'with image diff' do + let(:merge_request2) { create(:merge_request_with_diffs, :with_image_diffs, source_project: project, title: "Added images") } + let(:image_path) { "files/images/ee_repo_logo.png" } + let(:image_position) do + Gitlab::Diff::Position.new( + old_path: image_path, + new_path: image_path, + width: 100, + height: 100, + x: 1, + y: 1, + position_type: "image", + diff_refs: merge_request2.diff_refs + ) + end + + it 'merge_requests/image_diff_discussion.json' do |example| + create(:diff_note_on_merge_request, project: project, noteable: merge_request2, position: image_position) + render_discussions_json(merge_request2, example.description) + end + end + private + def render_discussions_json(merge_request, fixture_file_name) + get :discussions, + namespace_id: project.namespace.to_param, + project_id: project, + id: merge_request.to_param, + format: :json + + store_frontend_fixture(response, fixture_file_name) + end + def render_merge_request(fixture_file_name, merge_request) get :show, namespace_id: project.namespace.to_param, diff --git a/spec/javascripts/groups/components/group_item_spec.js b/spec/javascripts/groups/components/group_item_spec.js index 618d0022e4f..e3c942597a3 100644 --- a/spec/javascripts/groups/components/group_item_spec.js +++ b/spec/javascripts/groups/components/group_item_spec.js @@ -3,10 +3,9 @@ import * as urlUtils from '~/lib/utils/url_utility'; import groupItemComponent from '~/groups/components/group_item.vue'; import groupFolderComponent from '~/groups/components/group_folder.vue'; import eventHub from '~/groups/event_hub'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { mockParentGroupItem, mockChildren } from '../mock_data'; -import mountComponent from '../../helpers/vue_mount_component_helper'; - const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => { const Component = Vue.extend(groupItemComponent); diff --git a/spec/javascripts/groups/components/groups_spec.js b/spec/javascripts/groups/components/groups_spec.js index 90e818c1545..793c4909d89 100644 --- a/spec/javascripts/groups/components/groups_spec.js +++ b/spec/javascripts/groups/components/groups_spec.js @@ -4,10 +4,9 @@ import groupsComponent from '~/groups/components/groups.vue'; import groupFolderComponent from '~/groups/components/group_folder.vue'; import groupItemComponent from '~/groups/components/group_item.vue'; import eventHub from '~/groups/event_hub'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { mockGroups, mockPageInfo } from '../mock_data'; -import mountComponent from '../../helpers/vue_mount_component_helper'; - const createComponent = (searchEmpty = false) => { const Component = Vue.extend(groupsComponent); diff --git a/spec/javascripts/groups/components/item_actions_spec.js b/spec/javascripts/groups/components/item_actions_spec.js index acccbe639c4..15fd37ebcd2 100644 --- a/spec/javascripts/groups/components/item_actions_spec.js +++ b/spec/javascripts/groups/components/item_actions_spec.js @@ -2,10 +2,9 @@ import Vue from 'vue'; import itemActionsComponent from '~/groups/components/item_actions.vue'; import eventHub from '~/groups/event_hub'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { mockParentGroupItem, mockChildren } from '../mock_data'; -import mountComponent from '../../helpers/vue_mount_component_helper'; - const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => { const Component = Vue.extend(itemActionsComponent); diff --git a/spec/javascripts/groups/components/item_caret_spec.js b/spec/javascripts/groups/components/item_caret_spec.js index 8faad455825..36f838a104f 100644 --- a/spec/javascripts/groups/components/item_caret_spec.js +++ b/spec/javascripts/groups/components/item_caret_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import itemCaretComponent from '~/groups/components/item_caret.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const createComponent = (isGroupOpen = false) => { const Component = Vue.extend(itemCaretComponent); diff --git a/spec/javascripts/groups/components/item_stats_spec.js b/spec/javascripts/groups/components/item_stats_spec.js index 55a7a713ca6..ee7ee18259e 100644 --- a/spec/javascripts/groups/components/item_stats_spec.js +++ b/spec/javascripts/groups/components/item_stats_spec.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import itemStatsComponent from '~/groups/components/item_stats.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { mockParentGroupItem, ITEM_TYPE, @@ -9,8 +10,6 @@ import { PROJECT_VISIBILITY_TYPE, } from '../mock_data'; -import mountComponent from '../../helpers/vue_mount_component_helper'; - const createComponent = (item = mockParentGroupItem) => { const Component = Vue.extend(itemStatsComponent); diff --git a/spec/javascripts/groups/components/item_stats_value_spec.js b/spec/javascripts/groups/components/item_stats_value_spec.js index e990870aaa6..5e35ae4d36c 100644 --- a/spec/javascripts/groups/components/item_stats_value_spec.js +++ b/spec/javascripts/groups/components/item_stats_value_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import itemStatsValueComponent from '~/groups/components/item_stats_value.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const createComponent = ({ title, cssClass, iconName, tooltipPlacement, value }) => { const Component = Vue.extend(itemStatsValueComponent); diff --git a/spec/javascripts/groups/components/item_type_icon_spec.js b/spec/javascripts/groups/components/item_type_icon_spec.js index 495cc97b475..24380689b29 100644 --- a/spec/javascripts/groups/components/item_type_icon_spec.js +++ b/spec/javascripts/groups/components/item_type_icon_spec.js @@ -1,10 +1,9 @@ import Vue from 'vue'; import itemTypeIconComponent from '~/groups/components/item_type_icon.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { ITEM_TYPE } from '../mock_data'; -import mountComponent from '../../helpers/vue_mount_component_helper'; - const createComponent = (itemType = ITEM_TYPE.GROUP, isGroupOpen = false) => { const Component = Vue.extend(itemTypeIconComponent); diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js index 1c9f48028f2..584db6c6632 100644 --- a/spec/javascripts/issue_show/components/app_spec.js +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -6,8 +6,8 @@ import '~/render_gfm'; import * as urlUtils from '~/lib/utils/url_utility'; import issuableApp from '~/issue_show/components/app.vue'; import eventHub from '~/issue_show/event_hub'; +import setTimeoutPromise from 'spec/helpers/set_timeout_promise_helper'; import issueShowData from '../mock_data'; -import setTimeoutPromise from '../../helpers/set_timeout_promise_helper'; function formatText(text) { return text.trim().replace(/\s\s+/g, ' '); diff --git a/spec/javascripts/issue_show/components/description_spec.js b/spec/javascripts/issue_show/components/description_spec.js index 0da25bdca9c..ff7f99eec14 100644 --- a/spec/javascripts/issue_show/components/description_spec.js +++ b/spec/javascripts/issue_show/components/description_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import descriptionComponent from '~/issue_show/components/description.vue'; import * as taskList from '~/task_list'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Description component', () => { let vm; diff --git a/spec/javascripts/jobs/header_spec.js b/spec/javascripts/jobs/header_spec.js index a9df0418d5d..0961605ce5c 100644 --- a/spec/javascripts/jobs/header_spec.js +++ b/spec/javascripts/jobs/header_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import headerComponent from '~/jobs/components/header.vue'; -import mountComponent from '../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Job details header', () => { let HeaderComponent; diff --git a/spec/javascripts/labels_select_spec.js b/spec/javascripts/labels_select_spec.js new file mode 100644 index 00000000000..b8f7b1dc855 --- /dev/null +++ b/spec/javascripts/labels_select_spec.js @@ -0,0 +1,43 @@ +import LabelsSelect from '~/labels_select'; + +const mockUrl = '/foo/bar/url'; + +const mockLabels = [ + { + id: 26, + title: 'Foo Label', + description: 'Foobar', + color: '#BADA55', + text_color: '#FFFFFF', + }, +]; + +describe('LabelsSelect', () => { + describe('getLabelTemplate', () => { + const label = mockLabels[0]; + let $labelEl; + + beforeEach(() => { + $labelEl = $(LabelsSelect.getLabelTemplate({ + labels: mockLabels, + issueUpdateURL: mockUrl, + })); + }); + + it('generated label item template has correct label URL', () => { + expect($labelEl.attr('href')).toBe('/foo/bar?label_name[]=Foo%20Label'); + }); + + it('generated label item template has correct label title', () => { + expect($labelEl.find('span.label').text()).toBe(label.title); + }); + + it('generated label item template has label description as title attribute', () => { + expect($labelEl.find('span.label').attr('title')).toBe(label.description); + }); + + it('generated label item template has correct label styles', () => { + expect($labelEl.find('span.label').attr('style')).toBe(`background-color: ${label.color}; color: ${label.text_color};`); + }); + }); +}); diff --git a/spec/javascripts/notes/components/comment_form_spec.js b/spec/javascripts/notes/components/comment_form_spec.js index 104d03377b6..6a7131528a3 100644 --- a/spec/javascripts/notes/components/comment_form_spec.js +++ b/spec/javascripts/notes/components/comment_form_spec.js @@ -1,17 +1,20 @@ import Vue from 'vue'; import Autosize from 'autosize'; import store from '~/notes/stores'; -import issueCommentForm from '~/notes/components/comment_form.vue'; +import CommentForm from '~/notes/components/comment_form.vue'; import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data'; import { keyboardDownEvent } from '../../issue_show/helpers'; describe('issue_comment_form component', () => { let vm; - const Component = Vue.extend(issueCommentForm); + const Component = Vue.extend(CommentForm); let mountComponent; beforeEach(() => { - mountComponent = () => new Component({ + mountComponent = (noteableType = 'issue') => new Component({ + propsData: { + noteableType, + }, store, }).$mount(); }); @@ -136,6 +139,11 @@ describe('issue_comment_form component', () => { expect(vm.editCurrentUserLastNote).toHaveBeenCalled(); }); + + it('inits autosave', () => { + expect(vm.autosave).toBeDefined(); + expect(vm.autosave.key).toEqual(`autosave/Note/Issue/${noteableDataMock.id}`); + }); }); describe('event enter', () => { @@ -182,6 +190,15 @@ describe('issue_comment_form component', () => { done(); }); }); + + it('updates button text with noteable type', (done) => { + vm.noteableType = 'merge_request'; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.btn-comment-and-close').textContent.trim()).toEqual('Close merge request'); + done(); + }); + }); }); describe('issue is confidential', () => { diff --git a/spec/javascripts/notes/components/diff_file_header_spec.js b/spec/javascripts/notes/components/diff_file_header_spec.js new file mode 100644 index 00000000000..aed30a087a6 --- /dev/null +++ b/spec/javascripts/notes/components/diff_file_header_spec.js @@ -0,0 +1,93 @@ +import Vue from 'vue'; +import DiffFileHeader from '~/notes/components/diff_file_header.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const discussionFixture = 'merge_requests/diff_discussion.json'; + +describe('diff_file_header', () => { + let vm; + const diffDiscussionMock = getJSONFixture(discussionFixture)[0]; + const diffFile = convertObjectPropsToCamelCase(diffDiscussionMock.diff_file); + const props = { + diffFile, + }; + const Component = Vue.extend(DiffFileHeader); + const selectors = { + get copyButton() { + return vm.$el.querySelector('button[data-original-title="Copy file path to clipboard"]'); + }, + get fileName() { + return vm.$el.querySelector('.file-title-name'); + }, + get titleWrapper() { + return vm.$refs.titleWrapper; + }, + }; + + describe('submodule', () => { + beforeEach(() => { + props.diffFile.submodule = true; + props.diffFile.submoduleLink = '<a href="/bha">Submodule</a>'; + + vm = mountComponent(Component, props); + }); + + it('shows submoduleLink', () => { + expect(selectors.fileName.innerHTML).toBe(props.diffFile.submoduleLink); + }); + + it('has button to copy blob path', () => { + expect(selectors.copyButton).toExist(); + expect(selectors.copyButton.getAttribute('data-clipboard-text')).toBe(props.diffFile.submoduleLink); + }); + }); + + describe('changed file', () => { + beforeEach(() => { + props.diffFile.submodule = false; + props.diffFile.discussionPath = 'some/discussion/id'; + + vm = mountComponent(Component, props); + }); + + it('shows file type icon', () => { + expect(vm.$el.innerHTML).toContain('fa-file-text-o'); + }); + + it('links to discussion path', () => { + expect(selectors.titleWrapper).toExist(); + expect(selectors.titleWrapper.tagName).toBe('A'); + expect(selectors.titleWrapper.getAttribute('href')).toBe(props.diffFile.discussionPath); + }); + + it('shows plain title if no link given', () => { + props.diffFile.discussionPath = undefined; + vm = mountComponent(Component, props); + + expect(selectors.titleWrapper.tagName).not.toBe('A'); + expect(selectors.titleWrapper.href).toBeFalsy(); + }); + + it('has button to copy file path', () => { + expect(selectors.copyButton).toExist(); + expect(selectors.copyButton.getAttribute('data-clipboard-text')).toBe(props.diffFile.filePath); + }); + + it('shows file mode change', (done) => { + vm.diffFile = { + ...props.diffFile, + modeChanged: true, + aMode: '100755', + bMode: '100644', + }; + + Vue.nextTick(() => { + expect( + vm.$refs.fileMode.textContent.trim(), + ).toBe('100755 → 100644'); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/notes/components/diff_with_note_spec.js b/spec/javascripts/notes/components/diff_with_note_spec.js new file mode 100644 index 00000000000..7f1f4bf0bcd --- /dev/null +++ b/spec/javascripts/notes/components/diff_with_note_spec.js @@ -0,0 +1,64 @@ +import Vue from 'vue'; +import DiffWithNote from '~/notes/components/diff_with_note.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const discussionFixture = 'merge_requests/diff_discussion.json'; +const imageDiscussionFixture = 'merge_requests/image_diff_discussion.json'; + +describe('diff_with_note', () => { + let vm; + const diffDiscussionMock = getJSONFixture(discussionFixture)[0]; + const diffDiscussion = convertObjectPropsToCamelCase(diffDiscussionMock); + const Component = Vue.extend(DiffWithNote); + const props = { + discussion: diffDiscussion, + }; + const selectors = { + get container() { + return vm.$refs.fileHolder; + }, + get diffTable() { + return this.container.querySelector('.diff-content table'); + }, + get diffRows() { + return this.container.querySelectorAll('.diff-content .line_holder'); + }, + get noteRow() { + return this.container.querySelector('.diff-content .notes_holder'); + }, + }; + + describe('text diff', () => { + beforeEach(() => { + vm = mountComponent(Component, props); + }); + + it('shows text diff', () => { + expect(selectors.container).toHaveClass('text-file'); + expect(selectors.diffTable).toExist(); + }); + + it('shows diff lines', () => { + expect(selectors.diffRows.length).toBe(12); + }); + + it('shows notes row', () => { + expect(selectors.noteRow).toExist(); + }); + }); + + describe('image diff', () => { + beforeEach(() => { + const imageDiffDiscussionMock = getJSONFixture(imageDiscussionFixture)[0]; + props.discussion = convertObjectPropsToCamelCase(imageDiffDiscussionMock); + }); + + it('shows image diff', () => { + vm = mountComponent(Component, props); + + expect(selectors.container).toHaveClass('js-image-file'); + expect(selectors.diffTable).not.toExist(); + }); + }); +}); diff --git a/spec/javascripts/notes/components/note_app_spec.js b/spec/javascripts/notes/components/note_app_spec.js index 12d180137a0..e1c612f5100 100644 --- a/spec/javascripts/notes/components/note_app_spec.js +++ b/spec/javascripts/notes/components/note_app_spec.js @@ -24,6 +24,7 @@ describe('note_app', () => { beforeEach(() => { jasmine.addMatchers(vueMatchers); + $('body').attr('data-page', 'projects:merge_requests:show'); const IssueNotesApp = Vue.extend(notesApp); @@ -119,8 +120,8 @@ describe('note_app', () => { vm = mountComponent(); }); - it('should render loading icon', () => { - expect(vm).toIncludeElement('.js-loading'); + it('renders skeleton notes', () => { + expect(vm).toIncludeElement('.animation-container'); }); it('should render form', () => { diff --git a/spec/javascripts/notes/components/note_body_spec.js b/spec/javascripts/notes/components/note_body_spec.js index b42e7943b98..0ff804f0e55 100644 --- a/spec/javascripts/notes/components/note_body_spec.js +++ b/spec/javascripts/notes/components/note_body_spec.js @@ -30,17 +30,26 @@ describe('issue_note_body component', () => { expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html); }); - it('should be render form if user is editing', (done) => { - vm.isEditing = true; + it('should render awards list', () => { + expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).not.toBeNull(); + expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).not.toBeNull(); + }); - Vue.nextTick(() => { - expect(vm.$el.querySelector('textarea.js-task-list-field')).toBeDefined(); - done(); + describe('isEditing', () => { + beforeEach((done) => { + vm.isEditing = true; + Vue.nextTick(done); }); - }); - it('should render awards list', () => { - expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).toBeDefined(); - expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).toBeDefined(); + it('renders edit form', () => { + expect(vm.$el.querySelector('textarea.js-task-list-field')).not.toBeNull(); + }); + + it('adds autosave', () => { + const autosaveKey = `autosave/Note/${note.noteable_type}/${note.id}`; + + expect(vm.autosave).toExist(); + expect(vm.autosave.key).toEqual(autosaveKey); + }); }); }); diff --git a/spec/javascripts/notes/components/note_header_spec.js b/spec/javascripts/notes/components/note_header_spec.js index 16a76b11321..5636f8d1a9f 100644 --- a/spec/javascripts/notes/components/note_header_spec.js +++ b/spec/javascripts/notes/components/note_header_spec.js @@ -32,6 +32,7 @@ describe('note_header component', () => { createdAt: '2017-08-02T10:51:58.559Z', includeToggle: false, noteId: 1394, + expanded: true, }, }).$mount(); }); @@ -68,6 +69,7 @@ describe('note_header component', () => { createdAt: '2017-08-02T10:51:58.559Z', includeToggle: true, noteId: 1395, + expanded: true, }, }).$mount(); }); @@ -76,17 +78,35 @@ describe('note_header component', () => { expect(vm.$el.querySelector('.js-vue-toggle-button')).toBeDefined(); }); - it('should toggle the disucssion icon', (done) => { - expect( - vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-up'), - ).toEqual(true); + it('emits toggle event on click', (done) => { + spyOn(vm, '$emit'); vm.$el.querySelector('.js-vue-toggle-button').click(); Vue.nextTick(() => { + expect(vm.$emit).toHaveBeenCalledWith('toggleHandler'); + done(); + }); + }); + + it('renders up arrow when open', (done) => { + vm.expanded = true; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.js-vue-toggle-button i').classList, + ).toContain('fa-chevron-up'); + done(); + }); + }); + + it('renders down arrow when closed', (done) => { + vm.expanded = false; + + Vue.nextTick(() => { expect( - vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-down'), - ).toEqual(true); + vm.$el.querySelector('.js-vue-toggle-button i').classList, + ).toContain('fa-chevron-down'); done(); }); }); diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js index ccf4bd070c2..bf60cb12f52 100644 --- a/spec/javascripts/notes/mock_data.js +++ b/spec/javascripts/notes/mock_data.js @@ -7,8 +7,9 @@ export const notesDataMock = { notesPath: '/gitlab-org/gitlab-ce/noteable/issue/98/notes', quickActionsDocsPath: '/help/user/project/quick_actions', registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane', - closeIssuePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=close', - reopenIssuePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=reopen', + totalNotes: 1, + closePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=close', + reopenPath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=reopen', }; export const userDataMock = { diff --git a/spec/javascripts/notes/stores/getters_spec.js b/spec/javascripts/notes/stores/getters_spec.js index 919ffbfdef0..8b2a8d2cd7a 100644 --- a/spec/javascripts/notes/stores/getters_spec.js +++ b/spec/javascripts/notes/stores/getters_spec.js @@ -56,9 +56,9 @@ describe('Getters Notes Store', () => { }); }); - describe('issueState', () => { + describe('openState', () => { it('should return the issue state', () => { - expect(getters.issueState(state)).toEqual(noteableDataMock.state); + expect(getters.openState(state)).toEqual(noteableDataMock.state); }); }); }); diff --git a/spec/javascripts/notes/stores/mutation_spec.js b/spec/javascripts/notes/stores/mutation_spec.js index 22d99998a7d..e4baefc5bfc 100644 --- a/spec/javascripts/notes/stores/mutation_spec.js +++ b/spec/javascripts/notes/stores/mutation_spec.js @@ -1,7 +1,7 @@ import mutations from '~/notes/stores/mutations'; import { note, discussionMock, notesDataMock, userDataMock, noteableDataMock, individualNote } from '../mock_data'; -describe('Mutation Notes Store', () => { +describe('Notes Store mutations', () => { describe('ADD_NEW_NOTE', () => { let state; let noteData; @@ -103,7 +103,8 @@ describe('Mutation Notes Store', () => { }; mutations.SET_INITIAL_NOTES(state, [note]); - expect(state.notes).toEqual([note]); + expect(state.notes[0].id).toEqual(note.id); + expect(state.notes.length).toEqual(1); }); }); diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index 274d7591c71..d4a148e6ab1 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -34,6 +34,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; describe('Notes', function() { const FLASH_TYPE_ALERT = 'alert'; + const NOTES_POST_PATH = /(.*)\/notes\?html=true$/; var commentsTemplate = 'merge_requests/merge_request_with_comment.html.raw'; preloadFixtures(commentsTemplate); @@ -154,7 +155,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; $form.find('textarea.js-note-text').val(sampleComment); mock = new MockAdapter(axios); - mock.onPost(/(.*)\/notes$/).reply(200, noteEntity); + mock.onPost(NOTES_POST_PATH).reply(200, noteEntity); }); afterEach(() => { @@ -506,11 +507,11 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; let mock; function mockNotesPost() { - mock.onPost(/(.*)\/notes$/).reply(200, note); + mock.onPost(NOTES_POST_PATH).reply(200, note); } function mockNotesPostError() { - mock.onPost(/(.*)\/notes$/).networkError(); + mock.onPost(NOTES_POST_PATH).networkError(); } beforeEach(() => { @@ -631,7 +632,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; beforeEach(() => { mock = new MockAdapter(axios); - mock.onPost(/(.*)\/notes$/).reply(200, note); + mock.onPost(NOTES_POST_PATH).reply(200, note); this.notes = new Notes('', []); window.gon.current_username = 'root'; @@ -684,7 +685,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; beforeEach(() => { mock = new MockAdapter(axios); - mock.onPost(/(.*)\/notes$/).reply(200, note); + mock.onPost(NOTES_POST_PATH).reply(200, note); this.notes = new Notes('', []); window.gon.current_username = 'root'; diff --git a/spec/javascripts/pages/admin/jobs/index/components/stop_jobs_modal_spec.js b/spec/javascripts/pages/admin/jobs/index/components/stop_jobs_modal_spec.js index 440a6585d57..a6fe9fb65e9 100644 --- a/spec/javascripts/pages/admin/jobs/index/components/stop_jobs_modal_spec.js +++ b/spec/javascripts/pages/admin/jobs/index/components/stop_jobs_modal_spec.js @@ -4,7 +4,7 @@ import axios from '~/lib/utils/axios_utils'; import stopJobsModal from '~/pages/admin/jobs/index/components/stop_jobs_modal.vue'; import * as urlUtility from '~/lib/utils/url_utility'; -import mountComponent from '../../../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('stop_jobs_modal.vue', () => { const props = { diff --git a/spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js b/spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js index 3cd33a3e900..6074e06fcec 100644 --- a/spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js +++ b/spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js @@ -5,7 +5,7 @@ import deleteMilestoneModal from '~/pages/milestones/shared/components/delete_mi import eventHub from '~/pages/milestones/shared/event_hub'; import * as urlUtility from '~/lib/utils/url_utility'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('delete_milestone_modal.vue', () => { const Component = Vue.extend(deleteMilestoneModal); diff --git a/spec/javascripts/pipelines/graph/job_component_spec.js b/spec/javascripts/pipelines/graph/job_component_spec.js index c3dc7b53d0f..ce181a1e515 100644 --- a/spec/javascripts/pipelines/graph/job_component_spec.js +++ b/spec/javascripts/pipelines/graph/job_component_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import jobComponent from '~/pipelines/components/graph/job_component.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('pipeline graph job component', () => { let JobComponent; diff --git a/spec/javascripts/pipelines/pipelines_spec.js b/spec/javascripts/pipelines/pipelines_spec.js index a99ebc4e51a..54d5bfd51e6 100644 --- a/spec/javascripts/pipelines/pipelines_spec.js +++ b/spec/javascripts/pipelines/pipelines_spec.js @@ -2,7 +2,7 @@ import _ from 'underscore'; import Vue from 'vue'; import pipelinesComp from '~/pipelines/components/pipelines.vue'; import Store from '~/pipelines/stores/pipelines_store'; -import mountComponent from '../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Pipelines', () => { const jsonFixtureName = 'pipelines/pipelines.json'; diff --git a/spec/javascripts/profile/account/components/delete_account_modal_spec.js b/spec/javascripts/profile/account/components/delete_account_modal_spec.js index 588b61196a5..a0939ff5c20 100644 --- a/spec/javascripts/profile/account/components/delete_account_modal_spec.js +++ b/spec/javascripts/profile/account/components/delete_account_modal_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import deleteAccountModal from '~/profile/account/components/delete_account_modal.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('DeleteAccountModal component', () => { const actionUrl = `${gl.TEST_HOST}/delete/user`; diff --git a/spec/javascripts/projects_dropdown/components/app_spec.js b/spec/javascripts/projects_dropdown/components/app_spec.js index 42f0f6fc1af..2054fef790b 100644 --- a/spec/javascripts/projects_dropdown/components/app_spec.js +++ b/spec/javascripts/projects_dropdown/components/app_spec.js @@ -6,7 +6,7 @@ import eventHub from '~/projects_dropdown/event_hub'; import ProjectsStore from '~/projects_dropdown/store/projects_store'; import ProjectsService from '~/projects_dropdown/service/projects_service'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { currentSession, mockProject, mockRawProject } from '../mock_data'; const createComponent = () => { diff --git a/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js index fcd0f6a3630..2bafb4e81ca 100644 --- a/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js +++ b/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import projectsListFrequentComponent from '~/projects_dropdown/components/projects_list_frequent.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { mockFrequents } from '../mock_data'; const createComponent = () => { diff --git a/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js index edef150dd1e..c193258474e 100644 --- a/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js +++ b/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import projectsListItemComponent from '~/projects_dropdown/components/projects_list_item.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { mockProject } from '../mock_data'; const createComponent = () => { diff --git a/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js index 67f8a8946c2..c4b86d77034 100644 --- a/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js +++ b/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import projectsListSearchComponent from '~/projects_dropdown/components/projects_list_search.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { mockProject } from '../mock_data'; const createComponent = () => { diff --git a/spec/javascripts/projects_dropdown/components/search_spec.js b/spec/javascripts/projects_dropdown/components/search_spec.js index 24d8a00b254..601264258c2 100644 --- a/spec/javascripts/projects_dropdown/components/search_spec.js +++ b/spec/javascripts/projects_dropdown/components/search_spec.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import searchComponent from '~/projects_dropdown/components/search.vue'; import eventHub from '~/projects_dropdown/event_hub'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const createComponent = () => { const Component = Vue.extend(searchComponent); diff --git a/spec/javascripts/registry/components/app_spec.js b/spec/javascripts/registry/components/app_spec.js index 6a8a85e3dfb..cf1d0625397 100644 --- a/spec/javascripts/registry/components/app_spec.js +++ b/spec/javascripts/registry/components/app_spec.js @@ -1,7 +1,7 @@ import _ from 'underscore'; import Vue from 'vue'; import registry from '~/registry/components/app.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { reposServerResponse } from '../mock_data'; describe('Registry List', () => { diff --git a/spec/javascripts/repo/components/commit_sidebar/list_collapsed_spec.js b/spec/javascripts/repo/components/commit_sidebar/list_collapsed_spec.js index debde1bb357..b509cedbe80 100644 --- a/spec/javascripts/repo/components/commit_sidebar/list_collapsed_spec.js +++ b/spec/javascripts/repo/components/commit_sidebar/list_collapsed_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import store from '~/ide/stores'; import listCollapsed from '~/ide/components/commit_sidebar/list_collapsed.vue'; -import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { file } from '../../helpers'; describe('Multi-file editor commit sidebar list collapsed', () => { diff --git a/spec/javascripts/repo/components/commit_sidebar/list_item_spec.js b/spec/javascripts/repo/components/commit_sidebar/list_item_spec.js index 4b20fdf70d6..6f1a1d874d3 100644 --- a/spec/javascripts/repo/components/commit_sidebar/list_item_spec.js +++ b/spec/javascripts/repo/components/commit_sidebar/list_item_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import listItem from '~/ide/components/commit_sidebar/list_item.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { file } from '../../helpers'; describe('Multi-file editor commit sidebar list item', () => { diff --git a/spec/javascripts/repo/components/commit_sidebar/list_spec.js b/spec/javascripts/repo/components/commit_sidebar/list_spec.js index cb5240ad118..aeb9de9ace4 100644 --- a/spec/javascripts/repo/components/commit_sidebar/list_spec.js +++ b/spec/javascripts/repo/components/commit_sidebar/list_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import store from '~/ide/stores'; import commitSidebarList from '~/ide/components/commit_sidebar/list.vue'; -import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { file } from '../../helpers'; describe('Multi-file editor commit sidebar list', () => { diff --git a/spec/javascripts/repo/components/ide_context_bar_spec.js b/spec/javascripts/repo/components/ide_context_bar_spec.js index 3f8f37d2343..935da259a99 100644 --- a/spec/javascripts/repo/components/ide_context_bar_spec.js +++ b/spec/javascripts/repo/components/ide_context_bar_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import store from '~/ide/stores'; import ideContextBar from '~/ide/components/ide_context_bar.vue'; -import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; describe('Multi-file editor right context bar', () => { let vm; diff --git a/spec/javascripts/repo/components/ide_side_bar_spec.js b/spec/javascripts/repo/components/ide_side_bar_spec.js index 30e45169205..79c3c8128e8 100644 --- a/spec/javascripts/repo/components/ide_side_bar_spec.js +++ b/spec/javascripts/repo/components/ide_side_bar_spec.js @@ -1,8 +1,8 @@ import Vue from 'vue'; import store from '~/ide/stores'; import ideSidebar from '~/ide/components/ide_side_bar.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { resetStore } from '../helpers'; -import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; describe('IdeSidebar', () => { let vm; diff --git a/spec/javascripts/repo/components/ide_spec.js b/spec/javascripts/repo/components/ide_spec.js index acfd63eb8de..18135177b5e 100644 --- a/spec/javascripts/repo/components/ide_spec.js +++ b/spec/javascripts/repo/components/ide_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import store from '~/ide/stores'; import ide from '~/ide/components/ide.vue'; -import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { file, resetStore } from '../helpers'; describe('ide component', () => { diff --git a/spec/javascripts/repo/components/new_branch_form_spec.js b/spec/javascripts/repo/components/new_branch_form_spec.js index cd1d073ec18..82597fc75e8 100644 --- a/spec/javascripts/repo/components/new_branch_form_spec.js +++ b/spec/javascripts/repo/components/new_branch_form_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import store from '~/ide/stores'; import newBranchForm from '~/ide/components/new_branch_form.vue'; -import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { resetStore } from '../helpers'; describe('Multi-file editor new branch form', () => { diff --git a/spec/javascripts/repo/components/new_dropdown/index_spec.js b/spec/javascripts/repo/components/new_dropdown/index_spec.js index 6efbbf6d75e..4a8e4445e2f 100644 --- a/spec/javascripts/repo/components/new_dropdown/index_spec.js +++ b/spec/javascripts/repo/components/new_dropdown/index_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import store from '~/ide/stores'; import newDropdown from '~/ide/components/new_dropdown/index.vue'; -import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { resetStore } from '../../helpers'; describe('new dropdown component', () => { diff --git a/spec/javascripts/repo/components/new_dropdown/modal_spec.js b/spec/javascripts/repo/components/new_dropdown/modal_spec.js index 8bbc3100357..d6a1fdd115c 100644 --- a/spec/javascripts/repo/components/new_dropdown/modal_spec.js +++ b/spec/javascripts/repo/components/new_dropdown/modal_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import store from '~/ide/stores'; import service from '~/ide/services'; import modal from '~/ide/components/new_dropdown/modal.vue'; -import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { file, resetStore } from '../../helpers'; describe('new file modal component', () => { diff --git a/spec/javascripts/repo/components/new_dropdown/upload_spec.js b/spec/javascripts/repo/components/new_dropdown/upload_spec.js index 667112ab21a..ee8aab3a252 100644 --- a/spec/javascripts/repo/components/new_dropdown/upload_spec.js +++ b/spec/javascripts/repo/components/new_dropdown/upload_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import upload from '~/ide/components/new_dropdown/upload.vue'; import store from '~/ide/stores'; import service from '~/ide/services'; -import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { resetStore } from '../../helpers'; describe('new dropdown upload', () => { diff --git a/spec/javascripts/repo/components/repo_commit_section_spec.js b/spec/javascripts/repo/components/repo_commit_section_spec.js index 93e94b4f24c..934ada9dec2 100644 --- a/spec/javascripts/repo/components/repo_commit_section_spec.js +++ b/spec/javascripts/repo/components/repo_commit_section_spec.js @@ -3,7 +3,7 @@ import * as urlUtils from '~/lib/utils/url_utility'; import store from '~/ide/stores'; import service from '~/ide/services'; import repoCommitSection from '~/ide/components/repo_commit_section.vue'; -import getSetTimeoutPromise from '../../helpers/set_timeout_promise_helper'; +import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper'; import { file, resetStore } from '../helpers'; describe('RepoCommitSection', () => { diff --git a/spec/javascripts/sidebar/assignees_spec.js b/spec/javascripts/sidebar/assignees_spec.js index c9453a21189..4e4343812bd 100644 --- a/spec/javascripts/sidebar/assignees_spec.js +++ b/spec/javascripts/sidebar/assignees_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import Assignee from '~/sidebar/components/assignees/assignees'; +import Assignee from '~/sidebar/components/assignees/assignees.vue'; import UsersMock from './mock_data'; import UsersMockHelper from '../helpers/user_mock_data_helper'; diff --git a/spec/javascripts/sidebar/lock/edit_form_buttons_spec.js b/spec/javascripts/sidebar/lock/edit_form_buttons_spec.js index b0ea8ae0206..deeea669de8 100644 --- a/spec/javascripts/sidebar/lock/edit_form_buttons_spec.js +++ b/spec/javascripts/sidebar/lock/edit_form_buttons_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import editFormButtons from '~/sidebar/components/lock/edit_form_buttons.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('EditFormButtons', () => { let vm1; diff --git a/spec/javascripts/sidebar/participants_spec.js b/spec/javascripts/sidebar/participants_spec.js index 30cc549c7c0..2a3b60c399c 100644 --- a/spec/javascripts/sidebar/participants_spec.js +++ b/spec/javascripts/sidebar/participants_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import participants from '~/sidebar/components/participants/participants.vue'; -import mountComponent from '../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const PARTICIPANT = { id: 1, diff --git a/spec/javascripts/sidebar/sidebar_assignees_spec.js b/spec/javascripts/sidebar/sidebar_assignees_spec.js index 6bb6d639f24..2fbb7268e0b 100644 --- a/spec/javascripts/sidebar/sidebar_assignees_spec.js +++ b/spec/javascripts/sidebar/sidebar_assignees_spec.js @@ -4,8 +4,8 @@ import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarStore from '~/sidebar/stores/sidebar_store'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; import Mock from './mock_data'; -import mountComponent from '../helpers/vue_mount_component_helper'; describe('sidebar assignees', () => { let vm; diff --git a/spec/javascripts/sidebar/sidebar_subscriptions_spec.js b/spec/javascripts/sidebar/sidebar_subscriptions_spec.js index a6113cb0bae..56a2543660b 100644 --- a/spec/javascripts/sidebar/sidebar_subscriptions_spec.js +++ b/spec/javascripts/sidebar/sidebar_subscriptions_spec.js @@ -4,7 +4,7 @@ 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 mountComponent from 'spec/helpers/vue_mount_component_helper'; import Mock from './mock_data'; describe('Sidebar Subscriptions', function () { diff --git a/spec/javascripts/sidebar/subscriptions_spec.js b/spec/javascripts/sidebar/subscriptions_spec.js index 79db05f04ed..aee8f0acbb9 100644 --- a/spec/javascripts/sidebar/subscriptions_spec.js +++ b/spec/javascripts/sidebar/subscriptions_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue'; -import mountComponent from '../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Subscriptions', function () { let vm; diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js index 94fcc6c7f2b..fb4946aeeea 100644 --- a/spec/javascripts/test_bundle.js +++ b/spec/javascripts/test_bundle.js @@ -126,7 +126,6 @@ if (process.env.BABEL_ENV === 'coverage') { './diff_notes/components/resolve_count.js', './dispatcher.js', './environments/environments_bundle.js', - './filtered_search/filtered_search_bundle.js', './graphs/graphs_bundle.js', './issuable/time_tracking/time_tracking_bundle.js', './main.js', diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js index f14d5f6f76c..db27aa144d6 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import authorComponent from '~/vue_merge_request_widget/components/mr_widget_author.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MRWidgetAuthor', () => { let vm; diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js index 8c55622b15e..6784b498c29 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import authorTimeComponent from '~/vue_merge_request_widget/components/mr_widget_author_time.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MRWidgetAuthorTime', () => { let vm; diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js index 13e5595bbfc..235c33fac0d 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import headerComponent from '~/vue_merge_request_widget/components/mr_widget_header.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MRWidgetHeader', () => { let vm; diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js index cc43639f576..367c499daaf 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import mergeHelpComponent from '~/vue_merge_request_widget/components/mr_widget_merge_help.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MRWidgetMergeHelp', () => { let vm; diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js index d7af956c9c1..431cb7f3913 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import pipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mockData from '../mock_data'; describe('MRWidgetPipeline', () => { diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_rebase_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_rebase_spec.js index 66ecaa316c8..b453d180a40 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_rebase_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_rebase_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import eventHub from '~/vue_merge_request_widget/event_hub'; import component from '~/vue_merge_request_widget/components/states/mr_widget_rebase.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Merge request widget rebase component', () => { let Component; diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_related_links_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_related_links_spec.js index 637bf483deb..5de6ac4079d 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_related_links_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_related_links_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import relatedLinksComponent from '~/vue_merge_request_widget/components/mr_widget_related_links.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MRWidgetRelatedLinks', () => { let vm; diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_status_icon_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_status_icon_spec.js index c39fcda0071..0b25500caf4 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_status_icon_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_status_icon_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import mrStatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MR widget status icon component', () => { let vm; diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_archived_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_archived_spec.js index f98ebdb38e6..e818f87b4c8 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_archived_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_archived_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import archivedComponent from '~/vue_merge_request_widget/components/states/mr_widget_archived.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MRWidgetArchived', () => { let vm; diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js index 95c94e95e3a..d069dc3fcc6 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import autoMergeFailedComponent from '~/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue'; import eventHub from '~/vue_merge_request_widget/event_hub'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MRWidgetAutoMergeFailed', () => { let vm; diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js index 658cadddb81..658612aad3c 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import checkingComponent from '~/vue_merge_request_widget/components/states/mr_widget_checking.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MRWidgetChecking', () => { let Component; diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_closed_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_closed_spec.js index 51a34739ee9..0e3c134d3ac 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_closed_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_closed_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import closedComponent from '~/vue_merge_request_widget/components/states/mr_widget_closed.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MRWidgetClosed', () => { let vm; diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js index a7d69fdcdb9..5323523abc0 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import conflictsComponent from '~/vue_merge_request_widget/components/states/mr_widget_conflicts.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MRWidgetConflicts', () => { let Component; diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js index a57b9811e08..dd1d62cd4ed 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import failedToMergeComponent from '~/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue'; import eventHub from '~/vue_merge_request_widget/event_hub'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MRWidgetFailedToMerge', () => { let Component; diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js index df56c4e2c5c..dd907ad9015 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import mwpsComponent from '~/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue'; import eventHub from '~/vue_merge_request_widget/event_hub'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MRWidgetMergeWhenPipelineSucceeds', () => { let vm; diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js index 43a989393ba..c2c92d8ac56 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import mergedComponent from '~/vue_merge_request_widget/components/states/mr_widget_merged.vue'; import eventHub from '~/vue_merge_request_widget/event_hub'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MRWidgetMerged', () => { let vm; diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merging_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merging_spec.js index 0b2ed2d4086..d2d219e4bdb 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merging_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merging_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import mergingComponent from '~/vue_merge_request_widget/components/states/mr_widget_merging.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MRWidgetMerging', () => { let vm; diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js index 3d7f4abd420..34f76b39b28 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import missingBranchComponent from '~/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MRWidgetMissingBranch', () => { let vm; diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js index c89e863d904..9f8b96c118b 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import notAllowedComponent from '~/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MRWidgetNotAllowed', () => { let vm; diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js index edab26286bc..baacbc03fb1 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import pipelineBlockedComponent from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MRWidgetPipelineBlocked', () => { let vm; diff --git a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js index 45035effe81..18ba34b55a5 100644 --- a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js @@ -3,8 +3,8 @@ import mrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options'; import eventHub from '~/vue_merge_request_widget/event_hub'; import notify from '~/lib/utils/notify'; import { stateKey } from '~/vue_merge_request_widget/stores/state_maps'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mockData from './mock_data'; -import mountComponent from '../helpers/vue_mount_component_helper'; const returnPromise = data => new Promise((resolve) => { resolve({ diff --git a/spec/javascripts/vue_shared/components/ci_badge_link_spec.js b/spec/javascripts/vue_shared/components/ci_badge_link_spec.js index 8762ce9903b..668742ebaee 100644 --- a/spec/javascripts/vue_shared/components/ci_badge_link_spec.js +++ b/spec/javascripts/vue_shared/components/ci_badge_link_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import ciBadge from '~/vue_shared/components/ci_badge_link.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('CI Badge Link Component', () => { let CIBadge; diff --git a/spec/javascripts/vue_shared/components/clipboard_button_spec.js b/spec/javascripts/vue_shared/components/clipboard_button_spec.js index 08e4e1f8337..d0fc10d69ea 100644 --- a/spec/javascripts/vue_shared/components/clipboard_button_spec.js +++ b/spec/javascripts/vue_shared/components/clipboard_button_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('clipboard button', () => { let vm; diff --git a/spec/javascripts/vue_shared/components/expand_button_spec.js b/spec/javascripts/vue_shared/components/expand_button_spec.js index a33ab689dd1..f19589d3b75 100644 --- a/spec/javascripts/vue_shared/components/expand_button_spec.js +++ b/spec/javascripts/vue_shared/components/expand_button_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import expandButton from '~/vue_shared/components/expand_button.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('expand button', () => { let vm; diff --git a/spec/javascripts/vue_shared/components/file_icon_spec.js b/spec/javascripts/vue_shared/components/file_icon_spec.js index d99b17bdc79..f7581251bf0 100644 --- a/spec/javascripts/vue_shared/components/file_icon_spec.js +++ b/spec/javascripts/vue_shared/components/file_icon_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import fileIcon from '~/vue_shared/components/file_icon.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('File Icon component', () => { let vm; diff --git a/spec/javascripts/vue_shared/components/gl_modal_spec.js b/spec/javascripts/vue_shared/components/gl_modal_spec.js index d6148cb785b..2805d9a7003 100644 --- a/spec/javascripts/vue_shared/components/gl_modal_spec.js +++ b/spec/javascripts/vue_shared/components/gl_modal_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import GlModal from '~/vue_shared/components/gl_modal.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const modalComponent = Vue.extend(GlModal); diff --git a/spec/javascripts/vue_shared/components/header_ci_component_spec.js b/spec/javascripts/vue_shared/components/header_ci_component_spec.js index b378a0bd896..65499a2d730 100644 --- a/spec/javascripts/vue_shared/components/header_ci_component_spec.js +++ b/spec/javascripts/vue_shared/components/header_ci_component_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import headerCi from '~/vue_shared/components/header_ci_component.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Header CI Component', () => { let HeaderCi; diff --git a/spec/javascripts/vue_shared/components/icon_spec.js b/spec/javascripts/vue_shared/components/icon_spec.js index a22b6bd3a67..68d57ebc8f0 100644 --- a/spec/javascripts/vue_shared/components/icon_spec.js +++ b/spec/javascripts/vue_shared/components/icon_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import Icon from '~/vue_shared/components/icon.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Sprite Icon Component', function () { describe('Initialization', function () { diff --git a/spec/javascripts/vue_shared/components/issue/issue_warning_spec.js b/spec/javascripts/vue_shared/components/issue/issue_warning_spec.js index 24484796bf1..e6ed77dbb52 100644 --- a/spec/javascripts/vue_shared/components/issue/issue_warning_spec.js +++ b/spec/javascripts/vue_shared/components/issue/issue_warning_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import issueWarning from '~/vue_shared/components/issue/issue_warning.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const IssueWarning = Vue.extend(issueWarning); diff --git a/spec/javascripts/vue_shared/components/loading_button_spec.js b/spec/javascripts/vue_shared/components/loading_button_spec.js index 49bf8ee6f7c..51c19cd4080 100644 --- a/spec/javascripts/vue_shared/components/loading_button_spec.js +++ b/spec/javascripts/vue_shared/components/loading_button_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import loadingButton from '~/vue_shared/components/loading_button.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const LABEL = 'Hello'; diff --git a/spec/javascripts/vue_shared/components/modal_spec.js b/spec/javascripts/vue_shared/components/modal_spec.js index a5f9c75be4e..8412df74f98 100644 --- a/spec/javascripts/vue_shared/components/modal_spec.js +++ b/spec/javascripts/vue_shared/components/modal_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import modal from '~/vue_shared/components/modal.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const modalComponent = Vue.extend(modal); diff --git a/spec/javascripts/vue_shared/components/navigation_tabs_spec.js b/spec/javascripts/vue_shared/components/navigation_tabs_spec.js index 78e7d747b92..09fda95d7d3 100644 --- a/spec/javascripts/vue_shared/components/navigation_tabs_spec.js +++ b/spec/javascripts/vue_shared/components/navigation_tabs_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import navigationTabs from '~/vue_shared/components/navigation_tabs.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('navigation tabs component', () => { let vm; diff --git a/spec/javascripts/vue_shared/components/notes/placeholder_system_note_spec.js b/spec/javascripts/vue_shared/components/notes/placeholder_system_note_spec.js index 7b8e6c330c2..262571efcb8 100644 --- a/spec/javascripts/vue_shared/components/notes/placeholder_system_note_spec.js +++ b/spec/javascripts/vue_shared/components/notes/placeholder_system_note_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import placeholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('placeholder system note component', () => { let PlaceholderSystemNote; diff --git a/spec/javascripts/vue_shared/components/panel_resizer_spec.js b/spec/javascripts/vue_shared/components/panel_resizer_spec.js index 70ce3dffaba..8efcb54659d 100644 --- a/spec/javascripts/vue_shared/components/panel_resizer_spec.js +++ b/spec/javascripts/vue_shared/components/panel_resizer_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import panelResizer from '~/vue_shared/components/panel_resizer.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Panel Resizer component', () => { let vm; diff --git a/spec/javascripts/vue_shared/components/pikaday_spec.js b/spec/javascripts/vue_shared/components/pikaday_spec.js index 47af9534737..b349e2a2a81 100644 --- a/spec/javascripts/vue_shared/components/pikaday_spec.js +++ b/spec/javascripts/vue_shared/components/pikaday_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import datePicker from '~/vue_shared/components/pikaday.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('datePicker', () => { let vm; diff --git a/spec/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js b/spec/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js index cce53193870..8c296af6652 100644 --- a/spec/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import collapsedCalendarIcon from '~/vue_shared/components/sidebar/collapsed_calendar_icon.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('collapsedCalendarIcon', () => { let vm; diff --git a/spec/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js b/spec/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js index 2de108da2ac..9d60f9c758f 100644 --- a/spec/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import collapsedGroupedDatePicker from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('collapsedGroupedDatePicker', () => { let vm; diff --git a/spec/javascripts/vue_shared/components/sidebar/date_picker_spec.js b/spec/javascripts/vue_shared/components/sidebar/date_picker_spec.js index 926e11b4d30..8840a5a9dbf 100644 --- a/spec/javascripts/vue_shared/components/sidebar/date_picker_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/date_picker_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import sidebarDatePicker from '~/vue_shared/components/sidebar/date_picker.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('sidebarDatePicker', () => { let vm; diff --git a/spec/javascripts/vue_shared/components/sidebar/toggle_sidebar_spec.js b/spec/javascripts/vue_shared/components/sidebar/toggle_sidebar_spec.js index 752a9e89d50..c911a129173 100644 --- a/spec/javascripts/vue_shared/components/sidebar/toggle_sidebar_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/toggle_sidebar_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import toggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('toggleSidebar', () => { let vm; diff --git a/spec/javascripts/vue_shared/components/skeleton_loading_container_spec.js b/spec/javascripts/vue_shared/components/skeleton_loading_container_spec.js index a5db0b2c59e..bbd50863069 100644 --- a/spec/javascripts/vue_shared/components/skeleton_loading_container_spec.js +++ b/spec/javascripts/vue_shared/components/skeleton_loading_container_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Skeleton loading container', () => { let vm; diff --git a/spec/javascripts/vue_shared/components/stacked_progress_bar_spec.js b/spec/javascripts/vue_shared/components/stacked_progress_bar_spec.js index 6940b04573e..de3bf667fb3 100644 --- a/spec/javascripts/vue_shared/components/stacked_progress_bar_spec.js +++ b/spec/javascripts/vue_shared/components/stacked_progress_bar_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import stackedProgressBarComponent from '~/vue_shared/components/stacked_progress_bar.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const createComponent = (config) => { const Component = Vue.extend(stackedProgressBarComponent); diff --git a/spec/javascripts/vue_shared/components/toggle_button_spec.js b/spec/javascripts/vue_shared/components/toggle_button_spec.js index 859995d33fa..71952cc39e0 100644 --- a/spec/javascripts/vue_shared/components/toggle_button_spec.js +++ b/spec/javascripts/vue_shared/components/toggle_button_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import toggleButton from '~/vue_shared/components/toggle_button.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Toggle Button', () => { let vm; diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js index aa93134f2dd..446f025c127 100644 --- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js +++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import { placeholderImage } from '~/lazy_loader'; import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const DEFAULT_PROPS = { size: 99, diff --git a/spec/lib/banzai/redactor_spec.rb b/spec/lib/banzai/redactor_spec.rb index 1fa89137972..441f3725985 100644 --- a/spec/lib/banzai/redactor_spec.rb +++ b/spec/lib/banzai/redactor_spec.rb @@ -40,6 +40,16 @@ describe Banzai::Redactor do expect(doc.to_html).to eq(original_content) end end + + it 'returns <a> tag with original href if it is originally a link reference' do + href = 'http://localhost:3000' + doc = Nokogiri::HTML + .fragment("<a class='gfm' data-reference-type='issue' data-original=#{href} data-link-reference='true'>#{href}</a>") + + redactor.redact([doc]) + + expect(doc.to_html).to eq('<a href="http://localhost:3000">http://localhost:3000</a>') + end end context 'when project is in pending delete' do diff --git a/spec/lib/gitlab/background_migration/migrate_build_stage_spec.rb b/spec/lib/gitlab/background_migration/migrate_build_stage_spec.rb new file mode 100644 index 00000000000..e112e9e9e3d --- /dev/null +++ b/spec/lib/gitlab/background_migration/migrate_build_stage_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +describe Gitlab::BackgroundMigration::MigrateBuildStage, :migration, schema: 20180212101928 do + let(:projects) { table(:projects) } + let(:pipelines) { table(:ci_pipelines) } + let(:stages) { table(:ci_stages) } + let(:jobs) { table(:ci_builds) } + + STATUSES = { created: 0, pending: 1, running: 2, success: 3, + failed: 4, canceled: 5, skipped: 6, manual: 7 }.freeze + + before do + projects.create!(id: 123, name: 'gitlab', path: 'gitlab-ce') + pipelines.create!(id: 1, project_id: 123, ref: 'master', sha: 'adf43c3a') + + jobs.create!(id: 1, commit_id: 1, project_id: 123, + stage_idx: 2, stage: 'build', status: :success) + jobs.create!(id: 2, commit_id: 1, project_id: 123, + stage_idx: 2, stage: 'build', status: :success) + jobs.create!(id: 3, commit_id: 1, project_id: 123, + stage_idx: 1, stage: 'test', status: :failed) + jobs.create!(id: 4, commit_id: 1, project_id: 123, + stage_idx: 1, stage: 'test', status: :success) + jobs.create!(id: 5, commit_id: 1, project_id: 123, + stage_idx: 3, stage: 'deploy', status: :pending) + jobs.create!(id: 6, commit_id: 1, project_id: 123, + stage_idx: 3, stage: nil, status: :pending) + end + + it 'correctly migrates builds stages' do + expect(stages.count).to be_zero + + described_class.new.perform(1, 6) + + expect(stages.count).to eq 3 + expect(stages.all.pluck(:name)).to match_array %w[test build deploy] + expect(jobs.where(stage_id: nil)).to be_one + expect(jobs.find_by(stage_id: nil).id).to eq 6 + expect(stages.all.pluck(:status)).to match_array [STATUSES[:success], + STATUSES[:failed], + STATUSES[:pending]] + end + + it 'recovers from unique constraint violation only twice' do + allow(described_class::Migratable::Stage) + .to receive(:find_by).and_return(nil) + + expect(described_class::Migratable::Stage) + .to receive(:find_by).exactly(3).times + + expect { described_class.new.perform(1, 6) } + .to raise_error ActiveRecord::RecordNotUnique + end +end diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexeme/equals_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexeme/equals_spec.rb new file mode 100644 index 00000000000..019a2ed184d --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/expression/lexeme/equals_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +describe Gitlab::Ci::Pipeline::Expression::Lexeme::Equals do + let(:left) { double('left') } + let(:right) { double('right') } + + describe '.build' do + it 'creates a new instance of the token' do + expect(described_class.build('==', left, right)) + .to be_a(described_class) + end + end + + describe '.type' do + it 'is an operator' do + expect(described_class.type).to eq :operator + end + end + + describe '#evaluate' do + it 'returns false when left and right are not equal' do + allow(left).to receive(:evaluate).and_return(1) + allow(right).to receive(:evaluate).and_return(2) + + operator = described_class.new(left, right) + + expect(operator.evaluate(VARIABLE: 3)).to eq false + end + + it 'returns true when left and right are equal' do + allow(left).to receive(:evaluate).and_return(1) + allow(right).to receive(:evaluate).and_return(1) + + operator = described_class.new(left, right) + + expect(operator.evaluate(VARIABLE: 3)).to eq true + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexeme/null_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexeme/null_spec.rb new file mode 100644 index 00000000000..b5a59929e11 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/expression/lexeme/null_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe Gitlab::Ci::Pipeline::Expression::Lexeme::Null do + describe '.build' do + it 'creates a new instance of the token' do + expect(described_class.build('null')) + .to be_a(described_class) + end + end + + describe '.type' do + it 'is a value lexeme' do + expect(described_class.type).to eq :value + end + end + + describe '#evaluate' do + it 'always evaluates to `nil`' do + expect(described_class.new('null').evaluate).to be_nil + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexeme/string_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexeme/string_spec.rb new file mode 100644 index 00000000000..86234dfb9e5 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/expression/lexeme/string_spec.rb @@ -0,0 +1,92 @@ +require 'spec_helper' + +describe Gitlab::Ci::Pipeline::Expression::Lexeme::String do + describe '.build' do + it 'creates a new instance of the token' do + expect(described_class.build('"my string"')) + .to be_a(described_class) + end + end + + describe '.type' do + it 'is a value lexeme' do + expect(described_class.type).to eq :value + end + end + + describe '.scan' do + context 'when using double quotes' do + it 'correctly identifies string token' do + scanner = StringScanner.new('"some string"') + + token = described_class.scan(scanner) + + expect(token).not_to be_nil + expect(token.build.evaluate).to eq 'some string' + end + end + + context 'when using single quotes' do + it 'correctly identifies string token' do + scanner = StringScanner.new("'some string 2'") + + token = described_class.scan(scanner) + + expect(token).not_to be_nil + expect(token.build.evaluate).to eq 'some string 2' + end + end + + context 'when there are mixed quotes in the string' do + it 'is a greedy scanner for double quotes' do + scanner = StringScanner.new('"some string" "and another one"') + + token = described_class.scan(scanner) + + expect(token).not_to be_nil + expect(token.build.evaluate).to eq 'some string' + end + + it 'is a greedy scanner for single quotes' do + scanner = StringScanner.new("'some string' 'and another one'") + + token = described_class.scan(scanner) + + expect(token).not_to be_nil + expect(token.build.evaluate).to eq 'some string' + end + + it 'allows to use single quotes inside double quotes' do + scanner = StringScanner.new(%("some ' string")) + + token = described_class.scan(scanner) + + expect(token).not_to be_nil + expect(token.build.evaluate).to eq "some ' string" + end + + it 'allow to use double quotes inside single quotes' do + scanner = StringScanner.new(%('some " string')) + + token = described_class.scan(scanner) + + expect(token).not_to be_nil + expect(token.build.evaluate).to eq 'some " string' + end + end + end + + describe '#evaluate' do + it 'returns string value it is is present' do + string = described_class.new('my string') + + expect(string.evaluate).to eq 'my string' + end + + it 'returns an empty string if it is empty' do + string = described_class.new('') + + expect(string.evaluate).to eq '' + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexeme/variable_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexeme/variable_spec.rb new file mode 100644 index 00000000000..599a5411881 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/expression/lexeme/variable_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe Gitlab::Ci::Pipeline::Expression::Lexeme::Variable do + describe '.build' do + it 'creates a new instance of the token' do + expect(described_class.build('$VARIABLE')) + .to be_a(described_class) + end + end + + describe '.type' do + it 'is a value lexeme' do + expect(described_class.type).to eq :value + end + end + + describe '#evaluate' do + it 'returns variable value if it is defined' do + variable = described_class.new('VARIABLE') + + expect(variable.evaluate(VARIABLE: 'my variable')) + .to eq 'my variable' + end + + it 'allows to use a string as a variable key too' do + variable = described_class.new('VARIABLE') + + expect(variable.evaluate('VARIABLE' => 'my variable')) + .to eq 'my variable' + end + + it 'returns nil if it is not defined' do + variable = described_class.new('VARIABLE') + + expect(variable.evaluate(OTHER: 'variable')).to be_nil + end + + it 'returns an empty string if it is empty' do + variable = described_class.new('VARIABLE') + + expect(variable.evaluate(VARIABLE: '')).to eq '' + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexer_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexer_spec.rb new file mode 100644 index 00000000000..230ceeb07f8 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/expression/lexer_spec.rb @@ -0,0 +1,70 @@ +require 'spec_helper' + +describe Gitlab::Ci::Pipeline::Expression::Lexer do + let(:token_class) do + Gitlab::Ci::Pipeline::Expression::Token + end + + describe '#tokens' do + it 'tokenss single value' do + tokens = described_class.new('$VARIABLE').tokens + + expect(tokens).to be_one + expect(tokens).to all(be_an_instance_of(token_class)) + end + + it 'does ignore whitespace characters' do + tokens = described_class.new("\t$VARIABLE ").tokens + + expect(tokens).to be_one + expect(tokens).to all(be_an_instance_of(token_class)) + end + + it 'tokenss multiple values of the same token' do + tokens = described_class.new("$VARIABLE1 $VARIABLE2").tokens + + expect(tokens.size).to eq 2 + expect(tokens).to all(be_an_instance_of(token_class)) + end + + it 'tokenss multiple values with different tokens' do + tokens = described_class.new('$VARIABLE "text" "value"').tokens + + expect(tokens.size).to eq 3 + expect(tokens.first.value).to eq '$VARIABLE' + expect(tokens.second.value).to eq '"text"' + expect(tokens.third.value).to eq '"value"' + end + + it 'tokenss tokens and operators' do + tokens = described_class.new('$VARIABLE == "text"').tokens + + expect(tokens.size).to eq 3 + expect(tokens.first.value).to eq '$VARIABLE' + expect(tokens.second.value).to eq '==' + expect(tokens.third.value).to eq '"text"' + end + + it 'limits statement to specified amount of tokens' do + lexer = described_class.new("$V1 $V2 $V3 $V4", max_tokens: 3) + + expect { lexer.tokens } + .to raise_error described_class::SyntaxError + end + + it 'raises syntax error in case of finding unknown tokens' do + lexer = described_class.new('$V1 123 $V2') + + expect { lexer.tokens } + .to raise_error described_class::SyntaxError + end + end + + describe '#lexemes' do + it 'returns an array of syntax lexemes' do + lexer = described_class.new('$VAR "text"') + + expect(lexer.lexemes).to eq %w[variable string] + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/expression/parser_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/parser_spec.rb new file mode 100644 index 00000000000..e8e6f585310 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/expression/parser_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe Gitlab::Ci::Pipeline::Expression::Parser do + describe '#tree' do + context 'when using operators' do + it 'returns a reverse descent parse tree' do + expect(described_class.seed('$VAR1 == "123" == $VAR2').tree) + .to be_a Gitlab::Ci::Pipeline::Expression::Lexeme::Equals + end + end + + context 'when using a single token' do + it 'returns a single token instance' do + expect(described_class.seed('$VAR').tree) + .to be_a Gitlab::Ci::Pipeline::Expression::Lexeme::Variable + end + end + + context 'when expression is empty' do + it 'returns a null token' do + expect(described_class.seed('').tree) + .to be_a Gitlab::Ci::Pipeline::Expression::Lexeme::Null + end + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb new file mode 100644 index 00000000000..472a58599d8 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb @@ -0,0 +1,85 @@ +require 'spec_helper' + +describe Gitlab::Ci::Pipeline::Expression::Statement do + let(:pipeline) { build(:ci_pipeline) } + + subject do + described_class.new(text, pipeline) + end + + before do + pipeline.variables.build([key: 'VARIABLE', value: 'my variable']) + end + + describe '#parse_tree' do + context 'when expression is empty' do + let(:text) { '' } + + it 'raises an error' do + expect { subject.parse_tree } + .to raise_error described_class::StatementError + end + end + + context 'when expression grammar is incorrect' do + table = [ + '$VAR "text"', # missing operator + '== "123"', # invalid right side + "'single quotes'", # single quotes string + '$VAR ==', # invalid right side + '12345', # unknown syntax + '' # empty statement + ] + + table.each do |syntax| + it "raises an error when syntax is `#{syntax}`" do + expect { described_class.new(syntax, pipeline).parse_tree } + .to raise_error described_class::StatementError + end + end + end + + context 'when expression grammar is correct' do + context 'when using an operator' do + let(:text) { '$VAR == "value"' } + + it 'returns a reverse descent parse tree' do + expect(subject.parse_tree) + .to be_a Gitlab::Ci::Pipeline::Expression::Lexeme::Equals + end + end + + context 'when using a single token' do + let(:text) { '$VARIABLE' } + + it 'returns a single token instance' do + expect(subject.parse_tree) + .to be_a Gitlab::Ci::Pipeline::Expression::Lexeme::Variable + end + end + end + end + + describe '#evaluate' do + statements = [ + ['$VARIABLE == "my variable"', true], + ["$VARIABLE == 'my variable'", true], + ['"my variable" == $VARIABLE', true], + ['$VARIABLE == null', false], + ['$VAR == null', true], + ['null == $VAR', true], + ['$VARIABLE', 'my variable'], + ['$VAR', nil] + ] + + statements.each do |expression, value| + context "when using expression `#{expression}`" do + let(:text) { expression } + + it "evaluates to `#{value.inspect}`" do + expect(subject.evaluate).to eq value + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/expression/token_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/token_spec.rb new file mode 100644 index 00000000000..6d7453f0de5 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/expression/token_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +describe Gitlab::Ci::Pipeline::Expression::Token do + let(:value) { '$VARIABLE' } + let(:lexeme) { Gitlab::Ci::Pipeline::Expression::Lexeme::Variable } + + subject { described_class.new(value, lexeme) } + + describe '#value' do + it 'returns raw token value' do + expect(subject.value).to eq value + end + end + + describe '#lexeme' do + it 'returns raw token lexeme' do + expect(subject.lexeme).to eq lexeme + end + end + + describe '#build' do + it 'delegates to lexeme after adding a value' do + expect(lexeme).to receive(:build) + .with(value, 'some', 'args') + + subject.build('some', 'args') + end + + it 'allows passing only required arguments' do + expect(subject.build).to be_an_instance_of(lexeme) + end + end + + describe '#type' do + it 'delegates type query to the lexeme' do + expect(subject.type).to eq :value + end + end + + describe '#to_lexeme' do + it 'returns raw lexeme syntax component name' do + expect(subject.to_lexeme).to eq 'variable' + end + end +end diff --git a/spec/lib/gitlab/cycle_analytics/base_event_fetcher_spec.rb b/spec/lib/gitlab/cycle_analytics/base_event_fetcher_spec.rb index 3fe0493ed9b..8b07da11c5d 100644 --- a/spec/lib/gitlab/cycle_analytics/base_event_fetcher_spec.rb +++ b/spec/lib/gitlab/cycle_analytics/base_event_fetcher_spec.rb @@ -41,7 +41,7 @@ describe Gitlab::CycleAnalytics::BaseEventFetcher do milestone = create(:milestone, project: project) issue.update(milestone: milestone) - create_merge_request_closing_issue(issue) + create_merge_request_closing_issue(user, project, issue) end end end diff --git a/spec/lib/gitlab/cycle_analytics/events_spec.rb b/spec/lib/gitlab/cycle_analytics/events_spec.rb index 38a47a159e1..397dd4e5d2c 100644 --- a/spec/lib/gitlab/cycle_analytics/events_spec.rb +++ b/spec/lib/gitlab/cycle_analytics/events_spec.rb @@ -236,8 +236,8 @@ describe 'cycle analytics events' do pipeline.run! pipeline.succeed! - merge_merge_requests_closing_issue(context) - deploy_master + merge_merge_requests_closing_issue(user, project, context) + deploy_master(user, project) end it 'has the name' do @@ -294,8 +294,8 @@ describe 'cycle analytics events' do let!(:context) { create(:issue, project: project, created_at: 2.days.ago) } before do - merge_merge_requests_closing_issue(context) - deploy_master + merge_merge_requests_closing_issue(user, project, context) + deploy_master(user, project) end it 'has the total time' do @@ -334,7 +334,7 @@ describe 'cycle analytics events' do def setup(context) milestone = create(:milestone, project: project) context.update(milestone: milestone) - mr = create_merge_request_closing_issue(context, commit_message: "References #{context.to_reference}") + mr = create_merge_request_closing_issue(user, project, context, commit_message: "References #{context.to_reference}") ProcessCommitWorker.new.perform(project.id, user.id, mr.commits.last.to_hash) end diff --git a/spec/lib/gitlab/cycle_analytics/usage_data_spec.rb b/spec/lib/gitlab/cycle_analytics/usage_data_spec.rb new file mode 100644 index 00000000000..56a316318cb --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/usage_data_spec.rb @@ -0,0 +1,140 @@ +require 'spec_helper' + +describe Gitlab::CycleAnalytics::UsageData do + describe '#to_json' do + before do + Timecop.freeze do + user = create(:user, :admin) + projects = create_list(:project, 2, :repository) + + projects.each_with_index do |project, time| + issue = create(:issue, project: project, created_at: (time + 1).hour.ago) + + allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue]) + + milestone = create(:milestone, project: project) + mr = create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") + pipeline = create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) + + create_cycle(user, project, issue, mr, milestone, pipeline) + deploy_master(user, project, environment: 'staging') + deploy_master(user, project) + end + end + end + + shared_examples 'a valid usage data result' do + it 'returns the aggregated usage data of every selected project' do + result = subject.to_json + + expect(result).to have_key(:avg_cycle_analytics) + + CycleAnalytics::STAGES.each do |stage| + expect(result[:avg_cycle_analytics]).to have_key(stage) + + stage_values = result[:avg_cycle_analytics][stage] + expected_values = expect_values_per_stage[stage] + + expected_values.each_pair do |op, value| + expect(stage_values).to have_key(op) + + if op == :missing + expect(stage_values[op]).to eq(value) + else + # delta is used because of git timings that Timecop does not stub + expect(stage_values[op].to_i).to be_within(5).of(value.to_i) + end + end + end + end + end + + context 'when using postgresql', :postgresql do + let(:expect_values_per_stage) do + { + issue: { + average: 5400, + sd: 2545, + missing: 0 + }, + plan: { + average: 2, + sd: 2, + missing: 0 + }, + code: { + average: nil, + sd: 0, + missing: 2 + }, + test: { + average: nil, + sd: 0, + missing: 2 + }, + review: { + average: 0, + sd: 0, + missing: 0 + }, + staging: { + average: 0, + sd: 0, + missing: 0 + }, + production: { + average: 5400, + sd: 2545, + missing: 0 + } + } + end + + it_behaves_like 'a valid usage data result' + end + + context 'when using mysql', :mysql do + let(:expect_values_per_stage) do + { + issue: { + average: nil, + sd: 0, + missing: 2 + }, + plan: { + average: nil, + sd: 0, + missing: 2 + }, + code: { + average: nil, + sd: 0, + missing: 2 + }, + test: { + average: nil, + sd: 0, + missing: 2 + }, + review: { + average: nil, + sd: 0, + missing: 2 + }, + staging: { + average: nil, + sd: 0, + missing: 2 + }, + production: { + average: nil, + sd: 0, + missing: 2 + } + } + end + + it_behaves_like 'a valid usage data result' + end + end +end diff --git a/spec/lib/gitlab/database/median_spec.rb b/spec/lib/gitlab/database/median_spec.rb new file mode 100644 index 00000000000..1b5e30089ce --- /dev/null +++ b/spec/lib/gitlab/database/median_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +describe Gitlab::Database::Median do + let(:dummy_class) do + Class.new do + include Gitlab::Database::Median + end + end + + subject(:median) { dummy_class.new } + + describe '#median_datetimes' do + it 'raises NotSupportedError', :mysql do + expect { median.median_datetimes(nil, nil, nil, :project_id) }.to raise_error(dummy_class::NotSupportedError, "partition_column is not supported for MySQL") + end + end +end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index d601a383a98..25defb98b7c 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -895,7 +895,7 @@ describe Gitlab::Git::Repository, seed_helper: true do repository.log(options.merge(path: "encoding")) end - it "should not follow renames" do + it "does not follow renames" do expect(log_commits).to include(commit_with_new_name) expect(log_commits).to include(rename_commit) expect(log_commits).not_to include(commit_with_old_name) @@ -907,7 +907,7 @@ describe Gitlab::Git::Repository, seed_helper: true do repository.log(options.merge(path: "encoding/CHANGELOG")) end - it "should not follow renames" do + it "does not follow renames" do expect(log_commits).to include(commit_with_new_name) expect(log_commits).to include(rename_commit) expect(log_commits).not_to include(commit_with_old_name) @@ -919,7 +919,7 @@ describe Gitlab::Git::Repository, seed_helper: true do repository.log(options.merge(path: "CHANGELOG")) end - it "should not follow renames" do + it "does not follow renames" do expect(log_commits).to include(commit_with_old_name) expect(log_commits).to include(rename_commit) expect(log_commits).not_to include(commit_with_new_name) @@ -931,7 +931,7 @@ describe Gitlab::Git::Repository, seed_helper: true do repository.log(options.merge(ref: "refs/heads/fix-blob-path", path: "files/testdir/file.txt")) end - it "should return a list of commits" do + it "returns a list of commits" do expect(log_commits.size).to eq(1) end end @@ -991,6 +991,16 @@ describe Gitlab::Git::Repository, seed_helper: true do it { expect { repository.log(limit: limit) }.to raise_error(ArgumentError) } end end + + context 'with all' do + let(:options) { { all: true, limit: 50 } } + + it 'returns a list of commits' do + commits = repository.log(options) + + expect(commits.size).to eq(37) + end + end end describe "#rugged_commits_between" do @@ -1134,6 +1144,20 @@ describe Gitlab::Git::Repository, seed_helper: true do context 'when Gitaly count_commits feature is disabled', :skip_gitaly_mock do it_behaves_like 'extended commit counting' + + context "with all" do + it "returns the number of commits in the whole repository" do + options = { all: true } + + expect(repository.count_commits(options)).to eq(34) + end + end + + context 'without all or ref being specified' do + it "raises an ArgumentError" do + expect { repository.count_commits({}) }.to raise_error(ArgumentError, "Please specify a valid ref or set the 'all' attribute to true") + end + end end end @@ -1406,79 +1430,95 @@ describe Gitlab::Git::Repository, seed_helper: true do end describe "#copy_gitattributes" do - let(:attributes_path) { File.join(SEED_STORAGE_PATH, TEST_REPO_PATH, 'info/attributes') } + shared_examples 'applying git attributes' do + let(:attributes_path) { File.join(SEED_STORAGE_PATH, TEST_REPO_PATH, 'info/attributes') } - it "raises an error with invalid ref" do - expect { repository.copy_gitattributes("invalid") }.to raise_error(Gitlab::Git::Repository::InvalidRef) - end - - context "with no .gitattrbutes" do - before do - repository.copy_gitattributes("master") + after do + FileUtils.rm_rf(attributes_path) if Dir.exist?(attributes_path) end - it "does not have an info/attributes" do - expect(File.exist?(attributes_path)).to be_falsey + it "raises an error with invalid ref" do + expect { repository.copy_gitattributes("invalid") }.to raise_error(Gitlab::Git::Repository::InvalidRef) end - after do - FileUtils.rm_rf(attributes_path) - end - end + context 'when forcing encoding issues' do + let(:branch_name) { "ʕ•ᴥ•ʔ" } - context "with .gitattrbutes" do - before do - repository.copy_gitattributes("gitattributes") - end + before do + repository.create_branch(branch_name, "master") + end - it "has an info/attributes" do - expect(File.exist?(attributes_path)).to be_truthy - end + after do + repository.rm_branch(branch_name, user: build(:admin)) + end - it "has the same content in info/attributes as .gitattributes" do - contents = File.open(attributes_path, "rb") { |f| f.read } - expect(contents).to eq("*.md binary\n") - end + it "doesn't raise with a valid unicode ref" do + expect { repository.copy_gitattributes(branch_name) }.not_to raise_error - after do - FileUtils.rm_rf(attributes_path) + repository + end end - end - context "with updated .gitattrbutes" do - before do - repository.copy_gitattributes("gitattributes") - repository.copy_gitattributes("gitattributes-updated") - end + context "with no .gitattrbutes" do + before do + repository.copy_gitattributes("master") + end - it "has an info/attributes" do - expect(File.exist?(attributes_path)).to be_truthy + it "does not have an info/attributes" do + expect(File.exist?(attributes_path)).to be_falsey + end end - it "has the updated content in info/attributes" do - contents = File.read(attributes_path) - expect(contents).to eq("*.txt binary\n") - end + context "with .gitattrbutes" do + before do + repository.copy_gitattributes("gitattributes") + end - after do - FileUtils.rm_rf(attributes_path) - end - end + it "has an info/attributes" do + expect(File.exist?(attributes_path)).to be_truthy + end - context "with no .gitattrbutes in HEAD but with previous info/attributes" do - before do - repository.copy_gitattributes("gitattributes") - repository.copy_gitattributes("master") + it "has the same content in info/attributes as .gitattributes" do + contents = File.open(attributes_path, "rb") { |f| f.read } + expect(contents).to eq("*.md binary\n") + end end - it "does not have an info/attributes" do - expect(File.exist?(attributes_path)).to be_falsey + context "with updated .gitattrbutes" do + before do + repository.copy_gitattributes("gitattributes") + repository.copy_gitattributes("gitattributes-updated") + end + + it "has an info/attributes" do + expect(File.exist?(attributes_path)).to be_truthy + end + + it "has the updated content in info/attributes" do + contents = File.read(attributes_path) + expect(contents).to eq("*.txt binary\n") + end end - after do - FileUtils.rm_rf(attributes_path) + context "with no .gitattrbutes in HEAD but with previous info/attributes" do + before do + repository.copy_gitattributes("gitattributes") + repository.copy_gitattributes("master") + end + + it "does not have an info/attributes" do + expect(File.exist?(attributes_path)).to be_falsey + end end end + + context 'when gitaly is enabled' do + it_behaves_like 'applying git attributes' + end + + context 'when gitaly is disabled', :disable_gitaly do + it_behaves_like 'applying git attributes' + end end describe '#ref_exists?' do @@ -1649,6 +1689,35 @@ describe Gitlab::Git::Repository, seed_helper: true do end end + describe '#license_short_name' do + shared_examples 'acquiring the Licensee license key' do + subject { repository.license_short_name } + + context 'when no license file can be found' do + let(:project) { create(:project, :repository) } + let(:repository) { project.repository.raw_repository } + + before do + project.repository.delete_file(project.owner, 'LICENSE', message: 'remove license', branch_name: 'master') + end + + it { is_expected.to be_nil } + end + + context 'when an mit license is found' do + it { is_expected.to eq('mit') } + end + end + + context 'when gitaly is enabled' do + it_behaves_like 'acquiring the Licensee license key' + end + + context 'when gitaly is disabled', :disable_gitaly do + it_behaves_like 'acquiring the Licensee license key' + end + end + describe '#with_repo_branch_commit' do context 'when comparing with the same repository' do let(:start_repository) { repository } @@ -2283,6 +2352,20 @@ describe Gitlab::Git::Repository, seed_helper: true do expect(subject).to match(/\h{40}/) end end + + context 'with trailing whitespace in an invalid patch', :skip_gitaly_mock do + let(:diff) { "diff --git a/README.md b/README.md\nindex faaf198..43c5edf 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,4 +1,4 @@\n-testme\n+ \n ====== \n \n Sample repo for testing gitlab features\n" } + + it 'does not include whitespace warnings in the error' do + allow(repository).to receive(:run_git!).and_call_original + allow(repository).to receive(:run_git!).with(%W(diff --binary #{start_sha}...#{end_sha})).and_return(diff.force_encoding('ASCII-8BIT')) + + expect { subject }.to raise_error do |error| + expect(error).to be_a(described_class::GitError) + expect(error.message).not_to include('trailing whitespace') + end + end + end end end diff --git a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb index 001c4d3e10a..9be3fa633a7 100644 --- a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb @@ -113,7 +113,7 @@ describe Gitlab::GitalyClient::CommitService do .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash)) .and_return([]) - client.tree_entries(repository, revision, path) + client.tree_entries(repository, revision, path, false) end context 'with UTF-8 params strings' do @@ -126,7 +126,7 @@ describe Gitlab::GitalyClient::CommitService do .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash)) .and_return([]) - client.tree_entries(repository, revision, path) + client.tree_entries(repository, revision, path, false) end end end diff --git a/spec/lib/gitlab/import_export/relation_factory_spec.rb b/spec/lib/gitlab/import_export/relation_factory_spec.rb index f1df44cea75..5c61a5a2044 100644 --- a/spec/lib/gitlab/import_export/relation_factory_spec.rb +++ b/spec/lib/gitlab/import_export/relation_factory_spec.rb @@ -29,6 +29,7 @@ describe Gitlab::ImportExport::RelationFactory do 'service_id' => service_id, 'push_events' => true, 'issues_events' => false, + 'confidential_issues_events' => false, 'merge_requests_events' => true, 'tag_push_events' => false, 'note_events' => true, diff --git a/spec/lib/gitlab/plugin_spec.rb b/spec/lib/gitlab/plugin_spec.rb new file mode 100644 index 00000000000..33dd4f79130 --- /dev/null +++ b/spec/lib/gitlab/plugin_spec.rb @@ -0,0 +1,68 @@ +require 'spec_helper' + +describe Gitlab::Plugin do + describe '.execute' do + let(:data) { Gitlab::DataBuilder::Push::SAMPLE_DATA } + let(:plugin) { Rails.root.join('plugins', 'test.rb') } + let(:tmp_file) { Tempfile.new('plugin-dump') } + let(:result) { described_class.execute(plugin.to_s, data) } + let(:success) { result.first } + let(:message) { result.last } + + let(:plugin_source) do + <<~EOS + #!/usr/bin/env ruby + x = STDIN.read + File.write('#{tmp_file.path}', x) + EOS + end + + before do + File.write(plugin, plugin_source) + end + + after do + FileUtils.rm(plugin) + end + + context 'successful execution' do + before do + File.chmod(0o777, plugin) + end + + after do + tmp_file.close! + end + + it { expect(success).to be true } + it { expect(message).to be_empty } + + it 'ensures plugin received data via stdin' do + result + + expect(File.read(tmp_file.path)).to eq(data.to_json) + end + end + + context 'non-executable' do + it { expect(success).to be false } + it { expect(message).to include('Permission denied') } + end + + context 'non-zero exit' do + let(:plugin_source) do + <<~EOS + #!/usr/bin/env ruby + exit 1 + EOS + end + + before do + File.chmod(0o777, plugin) + end + + it { expect(success).to be false } + it { expect(message).to be_empty } + end + end +end diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index 1ebb0105cf5..d8250e4b4c6 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -1,3 +1,4 @@ +# coding: utf-8 require 'spec_helper' describe Gitlab::ProjectSearchResults do @@ -105,6 +106,32 @@ describe Gitlab::ProjectSearchResults do end end + context 'when the search returns non-ASCII data' do + context 'with UTF-8' do + let(:results) { project.repository.search_files_by_content("файл", 'master') } + + it 'returns results as UTF-8' do + expect(subject.filename).to eq('encoding/russian.rb') + expect(subject.basename).to eq('encoding/russian') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq("Хороший файл") + end + end + + context 'with ISO-8859-1' do + let(:search_result) { "master:encoding/iso8859.txt\x001\x00\xC4\xFC\nmaster:encoding/iso8859.txt\x002\x00\nmaster:encoding/iso8859.txt\x003\x00foo\n".force_encoding(Encoding::ASCII_8BIT) } + + it 'returns results as UTF-8' do + expect(subject.filename).to eq('encoding/iso8859.txt') + expect(subject.basename).to eq('encoding/iso8859') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq("Äü\n\nfoo") + end + end + end + context "when filename has extension" do let(:search_result) { "master:CONTRIBUTE.md\x005\x00- [Contribute to GitLab](#contribute-to-gitlab)\n" } diff --git a/spec/lib/gitlab/slash_commands/command_spec.rb b/spec/lib/gitlab/slash_commands/command_spec.rb index 0173a45d480..e3447d974aa 100644 --- a/spec/lib/gitlab/slash_commands/command_spec.rb +++ b/spec/lib/gitlab/slash_commands/command_spec.rb @@ -3,10 +3,11 @@ require 'spec_helper' describe Gitlab::SlashCommands::Command do let(:project) { create(:project) } let(:user) { create(:user) } + let(:chat_name) { double(:chat_name, user: user) } describe '#execute' do subject do - described_class.new(project, user, params).execute + described_class.new(project, chat_name, params).execute end context 'when no command is available' do @@ -88,7 +89,7 @@ describe Gitlab::SlashCommands::Command do end describe '#match_command' do - subject { described_class.new(project, user, params).match_command.first } + subject { described_class.new(project, chat_name, params).match_command.first } context 'IssueShow is triggered' do let(:params) { { text: 'issue show 123' } } diff --git a/spec/lib/gitlab/slash_commands/deploy_spec.rb b/spec/lib/gitlab/slash_commands/deploy_spec.rb index 74b5ef4bb26..0d57334aa4c 100644 --- a/spec/lib/gitlab/slash_commands/deploy_spec.rb +++ b/spec/lib/gitlab/slash_commands/deploy_spec.rb @@ -4,6 +4,7 @@ describe Gitlab::SlashCommands::Deploy do describe '#execute' do let(:project) { create(:project) } let(:user) { create(:user) } + let(:chat_name) { double(:chat_name, user: user) } let(:regex_match) { described_class.match('deploy staging to production') } before do @@ -16,7 +17,7 @@ describe Gitlab::SlashCommands::Deploy do end subject do - described_class.new(project, user).execute(regex_match) + described_class.new(project, chat_name).execute(regex_match) end context 'if no environment is defined' do diff --git a/spec/lib/gitlab/slash_commands/issue_new_spec.rb b/spec/lib/gitlab/slash_commands/issue_new_spec.rb index 3b077c58c50..8e7df946529 100644 --- a/spec/lib/gitlab/slash_commands/issue_new_spec.rb +++ b/spec/lib/gitlab/slash_commands/issue_new_spec.rb @@ -4,6 +4,7 @@ describe Gitlab::SlashCommands::IssueNew do describe '#execute' do let(:project) { create(:project) } let(:user) { create(:user) } + let(:chat_name) { double(:chat_name, user: user) } let(:regex_match) { described_class.match("issue create bird is the word") } before do @@ -11,7 +12,7 @@ describe Gitlab::SlashCommands::IssueNew do end subject do - described_class.new(project, user).execute(regex_match) + described_class.new(project, chat_name).execute(regex_match) end context 'without description' do diff --git a/spec/lib/gitlab/slash_commands/issue_search_spec.rb b/spec/lib/gitlab/slash_commands/issue_search_spec.rb index 35d01efc1bd..189e9592f1b 100644 --- a/spec/lib/gitlab/slash_commands/issue_search_spec.rb +++ b/spec/lib/gitlab/slash_commands/issue_search_spec.rb @@ -6,10 +6,11 @@ describe Gitlab::SlashCommands::IssueSearch do let!(:confidential) { create(:issue, :confidential, project: project, title: 'mepmep find') } let(:project) { create(:project) } let(:user) { create(:user) } + let(:chat_name) { double(:chat_name, user: user) } let(:regex_match) { described_class.match("issue search find") } subject do - described_class.new(project, user).execute(regex_match) + described_class.new(project, chat_name).execute(regex_match) end context 'when the user has no access' do diff --git a/spec/lib/gitlab/slash_commands/issue_show_spec.rb b/spec/lib/gitlab/slash_commands/issue_show_spec.rb index e5834d5a2ee..b1db1638237 100644 --- a/spec/lib/gitlab/slash_commands/issue_show_spec.rb +++ b/spec/lib/gitlab/slash_commands/issue_show_spec.rb @@ -5,6 +5,7 @@ describe Gitlab::SlashCommands::IssueShow do let(:issue) { create(:issue, project: project) } let(:project) { create(:project) } let(:user) { issue.author } + let(:chat_name) { double(:chat_name, user: user) } let(:regex_match) { described_class.match("issue show #{issue.iid}") } before do @@ -12,7 +13,7 @@ describe Gitlab::SlashCommands::IssueShow do end subject do - described_class.new(project, user).execute(regex_match) + described_class.new(project, chat_name).execute(regex_match) end context 'the issue exists' do diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index 0e9ecff25a6..138d21ede97 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -36,6 +36,7 @@ describe Gitlab::UsageData do gitlab_shared_runners git database + avg_cycle_analytics )) end diff --git a/spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb b/spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb index b47f3314926..033d0e7584d 100644 --- a/spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb +++ b/spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20170502101023_cleanup_namespaceless_pending_delete_projects.rb') -describe CleanupNamespacelessPendingDeleteProjects do +describe CleanupNamespacelessPendingDeleteProjects, :migration, schema: 20180222043024 do before do # Stub after_save callbacks that will fail when Project has no namespace allow_any_instance_of(Project).to receive(:ensure_storage_path_exists).and_return(nil) diff --git a/spec/migrations/migrate_issues_to_ghost_user_spec.rb b/spec/migrations/migrate_issues_to_ghost_user_spec.rb index ff0d44e1ed2..9220b49a736 100644 --- a/spec/migrations/migrate_issues_to_ghost_user_spec.rb +++ b/spec/migrations/migrate_issues_to_ghost_user_spec.rb @@ -8,7 +8,7 @@ describe MigrateIssuesToGhostUser, :migration do let(:users) { table(:users) } before do - project = projects.create!(name: 'gitlab') + project = projects.create!(name: 'gitlab', namespace_id: 1) user = users.create(email: 'test@example.com') issues.create(title: 'Issue 1', author_id: nil, project_id: project.id) issues.create(title: 'Issue 2', author_id: user.id, project_id: project.id) diff --git a/spec/migrations/migrate_stages_statuses_spec.rb b/spec/migrations/migrate_stages_statuses_spec.rb index 79d2708f9ad..ce35276cbf5 100644 --- a/spec/migrations/migrate_stages_statuses_spec.rb +++ b/spec/migrations/migrate_stages_statuses_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20170711145558_migrate_stages_statuses.rb') -describe MigrateStagesStatuses, :migration do +describe MigrateStagesStatuses, :sidekiq, :migration do let(:jobs) { table(:ci_builds) } let(:stages) { table(:ci_stages) } let(:pipelines) { table(:ci_pipelines) } diff --git a/spec/migrations/schedule_build_stage_migration_spec.rb b/spec/migrations/schedule_build_stage_migration_spec.rb new file mode 100644 index 00000000000..e2ca35447fb --- /dev/null +++ b/spec/migrations/schedule_build_stage_migration_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20180212101928_schedule_build_stage_migration') + +describe ScheduleBuildStageMigration, :sidekiq, :migration do + let(:projects) { table(:projects) } + let(:pipelines) { table(:ci_pipelines) } + let(:stages) { table(:ci_stages) } + let(:jobs) { table(:ci_builds) } + + before do + stub_const("#{described_class}::BATCH_SIZE", 1) + + projects.create!(id: 123, name: 'gitlab', path: 'gitlab-ce') + pipelines.create!(id: 1, project_id: 123, ref: 'master', sha: 'adf43c3a') + stages.create!(id: 1, project_id: 123, pipeline_id: 1, name: 'test') + + jobs.create!(id: 11, commit_id: 1, project_id: 123, stage_id: nil) + jobs.create!(id: 206, commit_id: 1, project_id: 123, stage_id: nil) + jobs.create!(id: 3413, commit_id: 1, project_id: 123, stage_id: nil) + jobs.create!(id: 4109, commit_id: 1, project_id: 123, stage_id: 1) + end + + it 'schedules delayed background migrations in batches in bulk' do + Sidekiq::Testing.fake! do + Timecop.freeze do + migrate! + + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(5.minutes, 11, 11) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(10.minutes, 206, 206) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(15.minutes, 3413, 3413) + expect(BackgroundMigrationWorker.jobs.size).to eq 3 + end + end + end +end diff --git a/spec/models/chat_name_spec.rb b/spec/models/chat_name_spec.rb index e89e534d914..504bc710b25 100644 --- a/spec/models/chat_name_spec.rb +++ b/spec/models/chat_name_spec.rb @@ -14,4 +14,24 @@ describe ChatName do it { is_expected.to validate_uniqueness_of(:user_id).scoped_to(:service_id) } it { is_expected.to validate_uniqueness_of(:chat_id).scoped_to(:service_id, :team_id) } + + describe '#update_last_used_at', :clean_gitlab_redis_shared_state do + it 'updates the last_used_at timestamp' do + expect(subject.last_used_at).to be_nil + + subject.update_last_used_at + + expect(subject.last_used_at).to be_present + end + + it 'does not update last_used_at if it was recently updated' do + subject.update_last_used_at + + time = subject.last_used_at + + subject.update_last_used_at + + expect(subject.last_used_at).to eq(time) + end + end end diff --git a/spec/models/ci/group_variable_spec.rb b/spec/models/ci/group_variable_spec.rb index 145189e7469..1b10501701c 100644 --- a/spec/models/ci/group_variable_spec.rb +++ b/spec/models/ci/group_variable_spec.rb @@ -5,7 +5,7 @@ describe Ci::GroupVariable do it { is_expected.to include_module(HasVariable) } it { is_expected.to include_module(Presentable) } - it { is_expected.to validate_uniqueness_of(:key).scoped_to(:group_id) } + it { is_expected.to validate_uniqueness_of(:key).scoped_to(:group_id).with_message(/\(\w+\) has already been taken/) } describe '.unprotected' do subject { described_class.unprotected } diff --git a/spec/models/ci/variable_spec.rb b/spec/models/ci/variable_spec.rb index e4ff551151e..875e8b2b682 100644 --- a/spec/models/ci/variable_spec.rb +++ b/spec/models/ci/variable_spec.rb @@ -6,7 +6,7 @@ describe Ci::Variable do describe 'validations' do it { is_expected.to include_module(HasVariable) } it { is_expected.to include_module(Presentable) } - it { is_expected.to validate_uniqueness_of(:key).scoped_to(:project_id, :environment_scope) } + it { is_expected.to validate_uniqueness_of(:key).scoped_to(:project_id, :environment_scope).with_message(/\(\w+\) has already been taken/) } end describe '.unprotected' do diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb index 619c088b0bf..a34f4ff2b48 100644 --- a/spec/models/clusters/applications/ingress_spec.rb +++ b/spec/models/clusters/applications/ingress_spec.rb @@ -4,5 +4,52 @@ describe Clusters::Applications::Ingress do it { is_expected.to belong_to(:cluster) } it { is_expected.to validate_presence_of(:cluster) } + before do + allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_in) + allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_async) + end + include_examples 'cluster application specs', described_class + + describe '#make_installed!' do + before do + application.make_installed! + end + + let(:application) { create(:clusters_applications_ingress, :installing) } + + it 'schedules a ClusterWaitForIngressIpAddressWorker' do + expect(ClusterWaitForIngressIpAddressWorker).to have_received(:perform_in) + .with(Clusters::Applications::Ingress::FETCH_IP_ADDRESS_DELAY, 'ingress', application.id) + end + end + + describe '#schedule_status_update' do + let(:application) { create(:clusters_applications_ingress, :installed) } + + before do + application.schedule_status_update + end + + it 'schedules a ClusterWaitForIngressIpAddressWorker' do + expect(ClusterWaitForIngressIpAddressWorker).to have_received(:perform_async) + .with('ingress', application.id) + end + + context 'when the application is not installed' do + let(:application) { create(:clusters_applications_ingress, :installing) } + + it 'does not schedule a ClusterWaitForIngressIpAddressWorker' do + expect(ClusterWaitForIngressIpAddressWorker).not_to have_received(:perform_async) + end + end + + context 'when there is already an external_ip' do + let(:application) { create(:clusters_applications_ingress, :installed, external_ip: '111.222.222.111') } + + it 'does not schedule a ClusterWaitForIngressIpAddressWorker' do + expect(ClusterWaitForIngressIpAddressWorker).not_to have_received(:perform_in) + end + end + end end diff --git a/spec/models/cycle_analytics/code_spec.rb b/spec/models/cycle_analytics/code_spec.rb index f2f1928926c..6a6b58fb52b 100644 --- a/spec/models/cycle_analytics/code_spec.rb +++ b/spec/models/cycle_analytics/code_spec.rb @@ -18,11 +18,11 @@ describe 'CycleAnalytics#code' do end]], end_time_conditions: [["merge request that closes issue is created", -> (context, data) do - context.create_merge_request_closing_issue(data[:issue]) + context.create_merge_request_closing_issue(context.user, context.project, data[:issue]) end]], post_fn: -> (context, data) do - context.merge_merge_requests_closing_issue(data[:issue]) - context.deploy_master + context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue]) + context.deploy_master(context.user, context.project) end) context "when a regular merge request (that doesn't close the issue) is created" do @@ -30,10 +30,10 @@ describe 'CycleAnalytics#code' do issue = create(:issue, project: project) create_commit_referencing_issue(issue) - create_merge_request_closing_issue(issue, message: "Closes nothing") + create_merge_request_closing_issue(user, project, issue, message: "Closes nothing") - merge_merge_requests_closing_issue(issue) - deploy_master + merge_merge_requests_closing_issue(user, project, issue) + deploy_master(user, project) expect(subject[:code].median).to be_nil end @@ -50,10 +50,10 @@ describe 'CycleAnalytics#code' do end]], end_time_conditions: [["merge request that closes issue is created", -> (context, data) do - context.create_merge_request_closing_issue(data[:issue]) + context.create_merge_request_closing_issue(context.user, context.project, data[:issue]) end]], post_fn: -> (context, data) do - context.merge_merge_requests_closing_issue(data[:issue]) + context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue]) end) context "when a regular merge request (that doesn't close the issue) is created" do @@ -61,9 +61,9 @@ describe 'CycleAnalytics#code' do issue = create(:issue, project: project) create_commit_referencing_issue(issue) - create_merge_request_closing_issue(issue, message: "Closes nothing") + create_merge_request_closing_issue(user, project, issue, message: "Closes nothing") - merge_merge_requests_closing_issue(issue) + merge_merge_requests_closing_issue(user, project, issue) expect(subject[:code].median).to be_nil end diff --git a/spec/models/cycle_analytics/issue_spec.rb b/spec/models/cycle_analytics/issue_spec.rb index 985e1bf80be..45f1b4fe8a3 100644 --- a/spec/models/cycle_analytics/issue_spec.rb +++ b/spec/models/cycle_analytics/issue_spec.rb @@ -26,8 +26,8 @@ describe 'CycleAnalytics#issue' do end]], post_fn: -> (context, data) do if data[:issue].persisted? - context.create_merge_request_closing_issue(data[:issue].reload) - context.merge_merge_requests_closing_issue(data[:issue]) + context.create_merge_request_closing_issue(context.user, context.project, data[:issue].reload) + context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue]) end end) @@ -37,8 +37,8 @@ describe 'CycleAnalytics#issue' do issue = create(:issue, project: project) issue.update(label_ids: [regular_label.id]) - create_merge_request_closing_issue(issue) - merge_merge_requests_closing_issue(issue) + create_merge_request_closing_issue(user, project, issue) + merge_merge_requests_closing_issue(user, project, issue) expect(subject[:issue].median).to be_nil end diff --git a/spec/models/cycle_analytics/plan_spec.rb b/spec/models/cycle_analytics/plan_spec.rb index 6fbb2a2d102..d366e2b723a 100644 --- a/spec/models/cycle_analytics/plan_spec.rb +++ b/spec/models/cycle_analytics/plan_spec.rb @@ -29,8 +29,8 @@ describe 'CycleAnalytics#plan' do context.create_commit_referencing_issue(data[:issue], branch_name: data[:branch_name]) end]], post_fn: -> (context, data) do - context.create_merge_request_closing_issue(data[:issue], source_branch: data[:branch_name]) - context.merge_merge_requests_closing_issue(data[:issue]) + context.create_merge_request_closing_issue(context.user, context.project, data[:issue], source_branch: data[:branch_name]) + context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue]) end) context "when a regular label (instead of a list label) is added to the issue" do @@ -41,8 +41,8 @@ describe 'CycleAnalytics#plan' do issue.update(label_ids: [label.id]) create_commit_referencing_issue(issue, branch_name: branch_name) - create_merge_request_closing_issue(issue, source_branch: branch_name) - merge_merge_requests_closing_issue(issue) + create_merge_request_closing_issue(user, project, issue, source_branch: branch_name) + merge_merge_requests_closing_issue(user, project, issue) expect(subject[:issue].median).to be_nil end diff --git a/spec/models/cycle_analytics/production_spec.rb b/spec/models/cycle_analytics/production_spec.rb index f8681c0a2f9..156eb96cfce 100644 --- a/spec/models/cycle_analytics/production_spec.rb +++ b/spec/models/cycle_analytics/production_spec.rb @@ -13,11 +13,11 @@ describe 'CycleAnalytics#production' do data_fn: -> (context) { { issue: context.build(:issue, project: context.project) } }, start_time_conditions: [["issue is created", -> (context, data) { data[:issue].save }]], before_end_fn: lambda do |context, data| - context.create_merge_request_closing_issue(data[:issue]) - context.merge_merge_requests_closing_issue(data[:issue]) + context.create_merge_request_closing_issue(context.user, context.project, data[:issue]) + context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue]) end, end_time_conditions: - [["merge request that closes issue is deployed to production", -> (context, data) { context.deploy_master }], + [["merge request that closes issue is deployed to production", -> (context, data) { context.deploy_master(context.user, context.project) }], ["production deploy happens after merge request is merged (along with other changes)", lambda do |context, data| # Make other changes on master @@ -29,14 +29,14 @@ describe 'CycleAnalytics#production' do branch_name: 'master') context.project.repository.commit(sha) - context.deploy_master + context.deploy_master(context.user, context.project) end]]) context "when a regular merge request (that doesn't close the issue) is merged and deployed" do it "returns nil" do merge_request = create(:merge_request) MergeRequests::MergeService.new(project, user).execute(merge_request) - deploy_master + deploy_master(user, project) expect(subject[:production].median).to be_nil end @@ -45,9 +45,9 @@ describe 'CycleAnalytics#production' do context "when the deployment happens to a non-production environment" do it "returns nil" do issue = create(:issue, project: project) - merge_request = create_merge_request_closing_issue(issue) + merge_request = create_merge_request_closing_issue(user, project, issue) MergeRequests::MergeService.new(project, user).execute(merge_request) - deploy_master(environment: 'staging') + deploy_master(user, project, environment: 'staging') expect(subject[:production].median).to be_nil end diff --git a/spec/models/cycle_analytics/review_spec.rb b/spec/models/cycle_analytics/review_spec.rb index 0ac58695b35..0aedfb49cb5 100644 --- a/spec/models/cycle_analytics/review_spec.rb +++ b/spec/models/cycle_analytics/review_spec.rb @@ -13,11 +13,11 @@ describe 'CycleAnalytics#review' do data_fn: -> (context) { { issue: context.create(:issue, project: context.project) } }, start_time_conditions: [["merge request that closes issue is created", -> (context, data) do - context.create_merge_request_closing_issue(data[:issue]) + context.create_merge_request_closing_issue(context.user, context.project, data[:issue]) end]], end_time_conditions: [["merge request that closes issue is merged", -> (context, data) do - context.merge_merge_requests_closing_issue(data[:issue]) + context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue]) end]], post_fn: nil) diff --git a/spec/models/cycle_analytics/staging_spec.rb b/spec/models/cycle_analytics/staging_spec.rb index b66d5623910..0cbda50c688 100644 --- a/spec/models/cycle_analytics/staging_spec.rb +++ b/spec/models/cycle_analytics/staging_spec.rb @@ -13,15 +13,15 @@ describe 'CycleAnalytics#staging' do phase: :staging, data_fn: lambda do |context| issue = context.create(:issue, project: context.project) - { issue: issue, merge_request: context.create_merge_request_closing_issue(issue) } + { issue: issue, merge_request: context.create_merge_request_closing_issue(context.user, context.project, issue) } end, start_time_conditions: [["merge request that closes issue is merged", -> (context, data) do - context.merge_merge_requests_closing_issue(data[:issue]) + context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue]) end]], end_time_conditions: [["merge request that closes issue is deployed to production", -> (context, data) do - context.deploy_master + context.deploy_master(context.user, context.project) end], ["production deploy happens after merge request is merged (along with other changes)", lambda do |context, data| @@ -34,14 +34,14 @@ describe 'CycleAnalytics#staging' do branch_name: 'master') context.project.repository.commit(sha) - context.deploy_master + context.deploy_master(context.user, context.project) end]]) context "when a regular merge request (that doesn't close the issue) is merged and deployed" do it "returns nil" do merge_request = create(:merge_request) MergeRequests::MergeService.new(project, user).execute(merge_request) - deploy_master + deploy_master(user, project) expect(subject[:staging].median).to be_nil end @@ -50,9 +50,9 @@ describe 'CycleAnalytics#staging' do context "when the deployment happens to a non-production environment" do it "returns nil" do issue = create(:issue, project: project) - merge_request = create_merge_request_closing_issue(issue) + merge_request = create_merge_request_closing_issue(user, project, issue) MergeRequests::MergeService.new(project, user).execute(merge_request) - deploy_master(environment: 'staging') + deploy_master(user, project, environment: 'staging') expect(subject[:staging].median).to be_nil end diff --git a/spec/models/cycle_analytics/test_spec.rb b/spec/models/cycle_analytics/test_spec.rb index 690c09bc2dc..e58b8fdff58 100644 --- a/spec/models/cycle_analytics/test_spec.rb +++ b/spec/models/cycle_analytics/test_spec.rb @@ -12,26 +12,26 @@ describe 'CycleAnalytics#test' do phase: :test, data_fn: lambda do |context| issue = context.create(:issue, project: context.project) - merge_request = context.create_merge_request_closing_issue(issue) + merge_request = context.create_merge_request_closing_issue(context.user, context.project, issue) pipeline = context.create(:ci_pipeline, ref: merge_request.source_branch, sha: merge_request.diff_head_sha, project: context.project, head_pipeline_of: merge_request) { pipeline: pipeline, issue: issue } end, start_time_conditions: [["pipeline is started", -> (context, data) { data[:pipeline].run! }]], end_time_conditions: [["pipeline is finished", -> (context, data) { data[:pipeline].succeed! }]], post_fn: -> (context, data) do - context.merge_merge_requests_closing_issue(data[:issue]) + context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue]) end) context "when the pipeline is for a regular merge request (that doesn't close an issue)" do it "returns nil" do issue = create(:issue, project: project) - merge_request = create_merge_request_closing_issue(issue) + merge_request = create_merge_request_closing_issue(user, project, issue) pipeline = create(:ci_pipeline, ref: "refs/heads/#{merge_request.source_branch}", sha: merge_request.diff_head_sha) pipeline.run! pipeline.succeed! - merge_merge_requests_closing_issue(issue) + merge_merge_requests_closing_issue(user, project, issue) expect(subject[:test].median).to be_nil end @@ -51,13 +51,13 @@ describe 'CycleAnalytics#test' do context "when the pipeline is dropped (failed)" do it "returns nil" do issue = create(:issue, project: project) - merge_request = create_merge_request_closing_issue(issue) + merge_request = create_merge_request_closing_issue(user, project, issue) pipeline = create(:ci_pipeline, ref: "refs/heads/#{merge_request.source_branch}", sha: merge_request.diff_head_sha) pipeline.run! pipeline.drop! - merge_merge_requests_closing_issue(issue) + merge_merge_requests_closing_issue(user, project, issue) expect(subject[:test].median).to be_nil end @@ -66,13 +66,13 @@ describe 'CycleAnalytics#test' do context "when the pipeline is cancelled" do it "returns nil" do issue = create(:issue, project: project) - merge_request = create_merge_request_closing_issue(issue) + merge_request = create_merge_request_closing_issue(user, project, issue) pipeline = create(:ci_pipeline, ref: "refs/heads/#{merge_request.source_branch}", sha: merge_request.diff_head_sha) pipeline.run! pipeline.cancel! - merge_merge_requests_closing_issue(issue) + merge_merge_requests_closing_issue(user, project, issue) expect(subject[:test].median).to be_nil end diff --git a/spec/models/cycle_analytics_spec.rb b/spec/models/cycle_analytics_spec.rb new file mode 100644 index 00000000000..0fe24870f02 --- /dev/null +++ b/spec/models/cycle_analytics_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +describe CycleAnalytics do + let(:project) { create(:project, :repository) } + let(:from_date) { 10.days.ago } + let(:user) { create(:user, :admin) } + let(:issue) { create(:issue, project: project, created_at: 2.days.ago) } + let(:milestone) { create(:milestone, project: project) } + let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") } + let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) } + + subject { described_class.new(project, from: from_date) } + + describe '#all_medians_per_stage' do + before do + allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue]) + + create_cycle(user, project, issue, mr, milestone, pipeline) + deploy_master(user, project) + end + + it 'returns every median for each stage for a specific project' do + values = described_class::STAGES.each_with_object({}) do |stage_name, hsh| + hsh[stage_name] = subject[stage_name].median.presence + end + + expect(subject.all_medians_per_stage).to eq(values) + end + end +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 56c2d7b953e..f4faec9e52a 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -2499,7 +2499,8 @@ describe Project do end it 'is a no-op when there is no namespace' do - project.update_column(:namespace_id, nil) + project.namespace.delete + project.reload expect_any_instance_of(Projects::UpdatePagesConfigurationService).not_to receive(:execute) expect_any_instance_of(Gitlab::PagesTransfer).not_to receive(:rename_project) @@ -2531,7 +2532,8 @@ describe Project do it 'is a no-op on legacy projects when there is no namespace' do export_path = legacy_project.export_path - legacy_project.update_column(:namespace_id, nil) + legacy_project.namespace.delete + legacy_project.reload expect(FileUtils).not_to receive(:rm_rf).with(export_path) @@ -2543,7 +2545,8 @@ describe Project do it 'runs on hashed storage projects when there is no namespace' do export_path = project.export_path - project.update_column(:namespace_id, nil) + project.namespace.delete + legacy_project.reload allow(FileUtils).to receive(:rm_rf).and_call_original expect(FileUtils).to receive(:rm_rf).with(export_path).and_call_original diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 0bc07dc7a85..38653e18306 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -242,23 +242,51 @@ describe Repository do end describe '#commits' do - it 'sets follow when path is a single path' do - expect(Gitlab::Git::Commit).to receive(:where).with(a_hash_including(follow: true)).and_call_original.twice - - repository.commits('master', limit: 1, path: 'README.md') - repository.commits('master', limit: 1, path: ['README.md']) + context 'when neither the all flag nor a ref are specified' do + it 'returns every commit from default branch' do + expect(repository.commits(limit: 60).size).to eq(37) + end end - it 'does not set follow when path is multiple paths' do - expect(Gitlab::Git::Commit).to receive(:where).with(a_hash_including(follow: false)).and_call_original + context 'when ref is passed' do + it 'returns every commit from the specified ref' do + expect(repository.commits('master', limit: 60).size).to eq(37) + end - repository.commits('master', limit: 1, path: ['README.md', 'CHANGELOG']) - end + context 'when all' do + it 'returns every commit from the repository' do + expect(repository.commits('master', limit: 60, all: true).size).to eq(60) + end + end + + context 'with path' do + it 'sets follow when it is a single path' do + expect(Gitlab::Git::Commit).to receive(:where).with(a_hash_including(follow: true)).and_call_original.twice + + repository.commits('master', limit: 1, path: 'README.md') + repository.commits('master', limit: 1, path: ['README.md']) + end - it 'does not set follow when there are no paths' do - expect(Gitlab::Git::Commit).to receive(:where).with(a_hash_including(follow: false)).and_call_original + it 'does not set follow when it is multiple paths' do + expect(Gitlab::Git::Commit).to receive(:where).with(a_hash_including(follow: false)).and_call_original - repository.commits('master', limit: 1) + repository.commits('master', limit: 1, path: ['README.md', 'CHANGELOG']) + end + end + + context 'without path' do + it 'does not set follow' do + expect(Gitlab::Git::Commit).to receive(:where).with(a_hash_including(follow: false)).and_call_original + + repository.commits('master', limit: 1) + end + end + end + + context "when 'all' flag is set" do + it 'returns every commit from the repository' do + expect(repository.commits(all: true, limit: 60).size).to eq(60) + end end end diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index ad3eec88952..852f67db958 100644 --- a/spec/requests/api/commits_spec.rb +++ b/spec/requests/api/commits_spec.rb @@ -149,6 +149,18 @@ describe API::Commits do end end + context 'all optional parameter' do + it 'returns all project commits' do + commit_count = project.repository.count_commits(all: true) + + get api("/projects/#{project_id}/repository/commits?all=true", user) + + expect(response).to include_pagination_headers + expect(response.headers['X-Total']).to eq(commit_count.to_s) + expect(response.headers['X-Page']).to eql('1') + end + end + context 'with pagination params' do let(:page) { 1 } let(:per_page) { 5 } diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index e6d7b9fde02..d1569e5d650 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -1417,7 +1417,7 @@ describe API::Issues do context 'when source project does not exist' do it 'returns 404 when trying to move an issue' do - post api("/projects/12345/issues/#{issue.iid}/move", user), + post api("/projects/0/issues/#{issue.iid}/move", user), to_project_id: target_project.id expect(response).to have_gitlab_http_status(404) @@ -1428,7 +1428,7 @@ describe API::Issues do context 'when target project does not exist' do it 'returns 404 when trying to move an issue' do post api("/projects/#{project.id}/issues/#{issue.iid}/move", user), - to_project_id: 12345 + to_project_id: 0 expect(response).to have_gitlab_http_status(404) end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 14dd9da119d..658cedd6b5f 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -151,6 +151,26 @@ describe API::MergeRequests do expect(json_response.first['id']).to eq(merge_request3.id) end + context 'source_branch param' do + it 'returns merge requests with the given source branch' do + get api('/merge_requests', user), source_branch: merge_request_closed.source_branch, state: 'all' + + expect(json_response.length).to eq(2) + expect(json_response.map { |mr| mr['id'] }) + .to contain_exactly(merge_request_closed.id, merge_request_merged.id) + end + end + + context 'target_branch param' do + it 'returns merge requests with the given target branch' do + get api('/merge_requests', user), target_branch: merge_request_closed.target_branch, state: 'all' + + expect(json_response.length).to eq(2) + expect(json_response.map { |mr| mr['id'] }) + .to contain_exactly(merge_request_closed.id, merge_request_merged.id) + end + end + context 'search params' do before do merge_request.update(title: 'Search title', description: 'Search description') @@ -426,6 +446,26 @@ describe API::MergeRequests do expect(response_dates).to eq(response_dates.sort) end end + + context 'source_branch param' do + it 'returns merge requests with the given source branch' do + get api('/merge_requests', user), source_branch: merge_request_closed.source_branch, state: 'all' + + expect(json_response.length).to eq(2) + expect(json_response.map { |mr| mr['id'] }) + .to contain_exactly(merge_request_closed.id, merge_request_merged.id) + end + end + + context 'target_branch param' do + it 'returns merge requests with the given target branch' do + get api('/merge_requests', user), target_branch: merge_request_closed.target_branch, state: 'all' + + expect(json_response.length).to eq(2) + expect(json_response.map { |mr| mr['id'] }) + .to contain_exactly(merge_request_closed.id, merge_request_merged.id) + end + end end end diff --git a/spec/requests/api/project_hooks_spec.rb b/spec/requests/api/project_hooks_spec.rb index 1fd082ecc38..392cad667be 100644 --- a/spec/requests/api/project_hooks_spec.rb +++ b/spec/requests/api/project_hooks_spec.rb @@ -28,6 +28,7 @@ describe API::ProjectHooks, 'ProjectHooks' do expect(json_response.count).to eq(1) expect(json_response.first['url']).to eq("http://example.com") expect(json_response.first['issues_events']).to eq(true) + expect(json_response.first['confidential_issues_events']).to eq(true) expect(json_response.first['push_events']).to eq(true) expect(json_response.first['merge_requests_events']).to eq(true) expect(json_response.first['tag_push_events']).to eq(true) @@ -56,6 +57,7 @@ describe API::ProjectHooks, 'ProjectHooks' do expect(response).to have_gitlab_http_status(200) expect(json_response['url']).to eq(hook.url) expect(json_response['issues_events']).to eq(hook.issues_events) + expect(json_response['confidential_issues_events']).to eq(hook.confidential_issues_events) expect(json_response['push_events']).to eq(hook.push_events) expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events) expect(json_response['tag_push_events']).to eq(hook.tag_push_events) @@ -90,13 +92,14 @@ describe API::ProjectHooks, 'ProjectHooks' do it "adds hook to project" do expect do post api("/projects/#{project.id}/hooks", user), - url: "http://example.com", issues_events: true, wiki_page_events: true, + url: "http://example.com", issues_events: true, confidential_issues_events: true, wiki_page_events: true, job_events: true end.to change {project.hooks.count}.by(1) expect(response).to have_gitlab_http_status(201) expect(json_response['url']).to eq('http://example.com') expect(json_response['issues_events']).to eq(true) + expect(json_response['confidential_issues_events']).to eq(true) expect(json_response['push_events']).to eq(true) expect(json_response['merge_requests_events']).to eq(false) expect(json_response['tag_push_events']).to eq(false) @@ -144,6 +147,7 @@ describe API::ProjectHooks, 'ProjectHooks' do expect(response).to have_gitlab_http_status(200) expect(json_response['url']).to eq('http://example.org') expect(json_response['issues_events']).to eq(hook.issues_events) + expect(json_response['confidential_issues_events']).to eq(hook.confidential_issues_events) expect(json_response['push_events']).to eq(false) expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events) expect(json_response['tag_push_events']).to eq(hook.tag_push_events) diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb index f10b6e43d09..72cafac3f90 100644 --- a/spec/requests/api/runner_spec.rb +++ b/spec/requests/api/runner_spec.rb @@ -122,6 +122,15 @@ describe API::Runner do end end end + + it "sets the runner's ip_address" do + post api('/runners'), + { token: registration_token }, + { 'REMOTE_ADDR' => '123.111.123.111' } + + expect(response).to have_gitlab_http_status 201 + expect(Ci::Runner.first.ip_address).to eq('123.111.123.111') + end end describe 'DELETE /api/v4/runners' do @@ -422,6 +431,15 @@ describe API::Runner do end end + it "sets the runner's ip_address" do + post api('/jobs/request'), + { token: runner.token }, + { 'User-Agent' => user_agent, 'REMOTE_ADDR' => '123.222.123.222' } + + expect(response).to have_gitlab_http_status 201 + expect(runner.reload.ip_address).to eq('123.222.123.222') + end + context 'when concurrently updating a job' do before do expect_any_instance_of(Ci::Build).to receive(:run!) @@ -682,7 +700,7 @@ describe API::Runner do context 'when tace is given' do it 'creates a trace artifact' do - allow_any_instance_of(BuildFinishedWorker).to receive(:perform).with(job.id) do + allow(BuildFinishedWorker).to receive(:perform_async).with(job.id) do CreateTraceArtifactWorker.new.perform(job.id) end diff --git a/spec/requests/api/v3/issues_spec.rb b/spec/requests/api/v3/issues_spec.rb index 0e745c82395..11b5469be7b 100644 --- a/spec/requests/api/v3/issues_spec.rb +++ b/spec/requests/api/v3/issues_spec.rb @@ -1218,7 +1218,7 @@ describe API::V3::Issues do context 'when source project does not exist' do it 'returns 404 when trying to move an issue' do - post v3_api("/projects/123/issues/#{issue.id}/move", user), + post v3_api("/projects/0/issues/#{issue.id}/move", user), to_project_id: target_project.id expect(response).to have_gitlab_http_status(404) @@ -1229,7 +1229,7 @@ describe API::V3::Issues do context 'when target project does not exist' do it 'returns 404 when trying to move an issue' do post v3_api("/projects/#{project.id}/issues/#{issue.id}/move", user), - to_project_id: 123 + to_project_id: 0 expect(response).to have_gitlab_http_status(404) end diff --git a/spec/requests/api/v3/project_hooks_spec.rb b/spec/requests/api/v3/project_hooks_spec.rb index 248ae97f875..8f6a2330d25 100644 --- a/spec/requests/api/v3/project_hooks_spec.rb +++ b/spec/requests/api/v3/project_hooks_spec.rb @@ -27,6 +27,7 @@ describe API::ProjectHooks, 'ProjectHooks' do expect(json_response.count).to eq(1) expect(json_response.first['url']).to eq("http://example.com") expect(json_response.first['issues_events']).to eq(true) + expect(json_response.first['confidential_issues_events']).to eq(true) expect(json_response.first['push_events']).to eq(true) expect(json_response.first['merge_requests_events']).to eq(true) expect(json_response.first['tag_push_events']).to eq(true) @@ -54,6 +55,7 @@ describe API::ProjectHooks, 'ProjectHooks' do expect(response).to have_gitlab_http_status(200) expect(json_response['url']).to eq(hook.url) expect(json_response['issues_events']).to eq(hook.issues_events) + expect(json_response['confidential_issues_events']).to eq(hook.confidential_issues_events) expect(json_response['push_events']).to eq(hook.push_events) expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events) expect(json_response['tag_push_events']).to eq(hook.tag_push_events) @@ -87,12 +89,13 @@ describe API::ProjectHooks, 'ProjectHooks' do it "adds hook to project" do expect do post v3_api("/projects/#{project.id}/hooks", user), - url: "http://example.com", issues_events: true, wiki_page_events: true, build_events: true + url: "http://example.com", issues_events: true, confidential_issues_events: true, wiki_page_events: true, build_events: true end.to change {project.hooks.count}.by(1) expect(response).to have_gitlab_http_status(201) expect(json_response['url']).to eq('http://example.com') expect(json_response['issues_events']).to eq(true) + expect(json_response['confidential_issues_events']).to eq(true) expect(json_response['push_events']).to eq(true) expect(json_response['merge_requests_events']).to eq(false) expect(json_response['tag_push_events']).to eq(false) @@ -139,6 +142,7 @@ describe API::ProjectHooks, 'ProjectHooks' do expect(response).to have_gitlab_http_status(200) expect(json_response['url']).to eq('http://example.org') expect(json_response['issues_events']).to eq(hook.issues_events) + expect(json_response['confidential_issues_events']).to eq(hook.confidential_issues_events) expect(json_response['push_events']).to eq(false) expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events) expect(json_response['tag_push_events']).to eq(hook.tag_push_events) diff --git a/spec/requests/projects/cycle_analytics_events_spec.rb b/spec/requests/projects/cycle_analytics_events_spec.rb index 98f70e2101b..eef860821e5 100644 --- a/spec/requests/projects/cycle_analytics_events_spec.rb +++ b/spec/requests/projects/cycle_analytics_events_spec.rb @@ -15,7 +15,7 @@ describe 'cycle analytics events' do end end - deploy_master + deploy_master(user, project) login_as(user) end @@ -119,7 +119,7 @@ describe 'cycle analytics events' do def create_cycle milestone = create(:milestone, project: project) issue.update(milestone: milestone) - mr = create_merge_request_closing_issue(issue, commit_message: "References #{issue.to_reference}") + mr = create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") pipeline = create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) pipeline.run @@ -127,7 +127,7 @@ describe 'cycle analytics events' do create(:ci_build, pipeline: pipeline, status: :success, author: user) create(:ci_build, pipeline: pipeline, status: :success, author: user) - merge_merge_requests_closing_issue(issue) + merge_merge_requests_closing_issue(user, project, issue) ProcessCommitWorker.new.perform(project.id, user.id, mr.commits.last.to_hash) end diff --git a/spec/serializers/cluster_application_entity_spec.rb b/spec/serializers/cluster_application_entity_spec.rb index b5a55b4ef6e..852b6af9f7f 100644 --- a/spec/serializers/cluster_application_entity_spec.rb +++ b/spec/serializers/cluster_application_entity_spec.rb @@ -26,5 +26,19 @@ describe ClusterApplicationEntity do expect(subject[:status_reason]).to eq(application.status_reason) end end + + context 'for ingress application' do + let(:application) do + build( + :clusters_applications_ingress, + :installed, + external_ip: '111.222.111.222' + ) + end + + it 'includes external_ip' do + expect(subject[:external_ip]).to eq('111.222.111.222') + end + end end end diff --git a/spec/serializers/diff_file_entity_spec.rb b/spec/serializers/diff_file_entity_spec.rb new file mode 100644 index 00000000000..45d7c703df3 --- /dev/null +++ b/spec/serializers/diff_file_entity_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe DiffFileEntity do + include RepoHelpers + + let(:project) { create(:project, :repository) } + let(:repository) { project.repository } + let(:commit) { project.commit(sample_commit.id) } + let(:diff_refs) { commit.diff_refs } + let(:diff) { commit.raw_diffs.first } + let(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) } + let(:entity) { described_class.new(diff_file) } + + subject { entity.as_json } + + it 'exposes correct attributes' do + expect(subject).to include( + :submodule, :submodule_link, :file_path, + :deleted_file, :old_path, :new_path, :mode_changed, + :a_mode, :b_mode, :text, :old_path_html, + :new_path_html + ) + end +end diff --git a/spec/serializers/discussion_entity_spec.rb b/spec/serializers/discussion_entity_spec.rb new file mode 100644 index 00000000000..7ee8e38af1c --- /dev/null +++ b/spec/serializers/discussion_entity_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe DiscussionEntity do + include RepoHelpers + + let(:user) { create(:user) } + let(:note) { create(:discussion_note_on_merge_request) } + let(:discussion) { note.discussion } + let(:request) { double('request') } + let(:controller) { double('controller') } + let(:entity) { described_class.new(discussion, request: request, context: controller) } + + subject { entity.as_json } + + before do + allow(controller).to receive(:render_to_string) + allow(request).to receive(:current_user).and_return(user) + allow(request).to receive(:noteable).and_return(note.noteable) + end + + it 'exposes correct attributes' do + expect(subject).to include( + :id, :expanded, :notes, :individual_note, + :resolvable, :resolved, :resolve_path, + :resolve_with_issue_path, :diff_discussion + ) + end + + context 'when diff file is present' do + let(:note) { create(:diff_note_on_merge_request) } + + it 'exposes diff file attributes' do + expect(subject).to include(:diff_file, :truncated_diff_lines, :image_diff_html) + end + end +end diff --git a/spec/serializers/note_entity_spec.rb b/spec/serializers/note_entity_spec.rb index 3459cc72063..51a8587ace9 100644 --- a/spec/serializers/note_entity_spec.rb +++ b/spec/serializers/note_entity_spec.rb @@ -48,4 +48,15 @@ describe NoteEntity do expect(subject).to include(:system_note_icon_name) end end + + context 'when note is part of resolvable discussion' do + before do + allow(note).to receive(:part_of_discussion?).and_return(true) + allow(note).to receive(:resolvable?).and_return(true) + end + + it 'exposes paths to resolve note' do + expect(subject).to include(:resolve_path, :resolve_with_issue_path) + end + end end diff --git a/spec/services/chat_names/find_user_service_spec.rb b/spec/services/chat_names/find_user_service_spec.rb index 79aaac3aeb6..5734b10109a 100644 --- a/spec/services/chat_names/find_user_service_spec.rb +++ b/spec/services/chat_names/find_user_service_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe ChatNames::FindUserService do +describe ChatNames::FindUserService, :clean_gitlab_redis_shared_state do describe '#execute' do let(:service) { create(:service) } @@ -13,21 +13,30 @@ describe ChatNames::FindUserService do context 'when existing user is requested' do let(:params) { { team_id: chat_name.team_id, user_id: chat_name.chat_id } } - it 'returns the existing user' do - is_expected.to eq(user) + it 'returns the existing chat_name' do + is_expected.to eq(chat_name) end - it 'updates when last time chat name was used' do + it 'updates the last used timestamp if one is not already set' do expect(chat_name.last_used_at).to be_nil subject - initial_last_used = chat_name.reload.last_used_at - expect(initial_last_used).to be_present + expect(chat_name.reload.last_used_at).to be_present + end + + it 'only updates an existing timestamp once within a certain time frame' do + service = described_class.new(service, params) + + expect(chat_name.last_used_at).to be_nil + + service.execute + + time = chat_name.reload.last_used_at - Timecop.travel(2.days.from_now) { described_class.new(service, params).execute } + service.execute - expect(chat_name.reload.last_used_at).to be > initial_last_used + expect(chat_name.reload.last_used_at).to eq(time) end end diff --git a/spec/services/clusters/applications/check_ingress_ip_address_service_spec.rb b/spec/services/clusters/applications/check_ingress_ip_address_service_spec.rb new file mode 100644 index 00000000000..bf038595a4d --- /dev/null +++ b/spec/services/clusters/applications/check_ingress_ip_address_service_spec.rb @@ -0,0 +1,73 @@ +require 'spec_helper' + +describe Clusters::Applications::CheckIngressIpAddressService do + let(:application) { create(:clusters_applications_ingress, :installed) } + let(:service) { described_class.new(application) } + let(:kubeclient) { double(::Kubeclient::Client, get_service: kube_service) } + let(:ingress) { [{ ip: '111.222.111.222' }] } + let(:exclusive_lease) { instance_double(Gitlab::ExclusiveLease, try_obtain: true) } + + let(:kube_service) do + ::Kubeclient::Resource.new( + { + status: { + loadBalancer: { + ingress: ingress + } + } + } + ) + end + + subject { service.execute } + + before do + allow(application.cluster).to receive(:kubeclient).and_return(kubeclient) + allow(Gitlab::ExclusiveLease) + .to receive(:new) + .with("check_ingress_ip_address_service:#{application.id}", timeout: 15.seconds.to_i) + .and_return(exclusive_lease) + end + + describe '#execute' do + context 'when the ingress ip address is available' do + it 'updates the external_ip for the app' do + subject + + expect(application.external_ip).to eq('111.222.111.222') + end + end + + context 'when the ingress ip address is not available' do + let(:ingress) { nil } + + it 'does not error' do + subject + end + end + + context 'when the exclusive lease cannot be obtained' do + before do + allow(exclusive_lease) + .to receive(:try_obtain) + .and_return(false) + end + + it 'does not call kubeclient' do + subject + + expect(kubeclient).not_to have_received(:get_service) + end + end + + context 'when there is already an external_ip' do + let(:application) { create(:clusters_applications_ingress, :installed, external_ip: '001.111.002.111') } + + it 'does not call kubeclient' do + subject + + expect(kubeclient).not_to have_received(:get_service) + end + end + end +end diff --git a/spec/services/labels/find_or_create_service_spec.rb b/spec/services/labels/find_or_create_service_spec.rb index 78aa5d442e7..68d5660445a 100644 --- a/spec/services/labels/find_or_create_service_spec.rb +++ b/spec/services/labels/find_or_create_service_spec.rb @@ -15,47 +15,79 @@ describe Labels::FindOrCreateService do context 'when acting on behalf of a specific user' do let(:user) { create(:user) } - subject(:service) { described_class.new(user, project, params) } - before do - project.add_developer(user) - end - context 'when label does not exist at group level' do - it 'creates a new label at project level' do - expect { service.execute }.to change(project.labels, :count).by(1) + context 'when finding labels on project level' do + subject(:service) { described_class.new(user, project, params) } + + before do + project.add_developer(user) end - end - context 'when label exists at group level' do - it 'returns the group label' do - group_label = create(:group_label, group: group, title: 'Security') + context 'when label does not exist at group level' do + it 'creates a new label at project level' do + expect { service.execute }.to change(project.labels, :count).by(1) + end + end - expect(service.execute).to eq group_label + context 'when label exists at group level' do + it 'returns the group label' do + group_label = create(:group_label, group: group, title: 'Security') + + expect(service.execute).to eq group_label + end + end + + context 'when label exists at project level' do + it 'returns the project label' do + project_label = create(:label, project: project, title: 'Security') + + expect(service.execute).to eq project_label + end end end - context 'when label does not exist at group level' do - it 'creates a new label at project leve' do - expect { service.execute }.to change(project.labels, :count).by(1) + context 'when finding labels on group level' do + subject(:service) { described_class.new(user, group, params) } + + before do + group.add_developer(user) + end + + context 'when label does not exist at group level' do + it 'creates a new label at group level' do + expect { service.execute }.to change(group.labels, :count).by(1) + end + end + + context 'when label exists at group level' do + it 'returns the group label' do + group_label = create(:group_label, group: group, title: 'Security') + + expect(service.execute).to eq group_label + end end end + end + + context 'when authorization is not required' do + context 'when finding labels on project level' do + subject(:service) { described_class.new(nil, project, params) } - context 'when label exists at project level' do it 'returns the project label' do project_label = create(:label, project: project, title: 'Security') - expect(service.execute).to eq project_label + expect(service.execute(skip_authorization: true)).to eq project_label end end - end - context 'when authorization is not required' do - subject(:service) { described_class.new(nil, project, params) } + context 'when finding labels on group level' do + subject(:service) { described_class.new(nil, group, params) } - it 'returns the project label' do - project_label = create(:label, project: project, title: 'Security') + it 'returns the group label' do + group_label = create(:group_label, group: group, title: 'Security') - expect(service.execute(skip_authorization: true)).to eq project_label + expect(service.execute(skip_authorization: true)).to eq group_label + end end end end diff --git a/spec/services/members/approve_access_request_service_spec.rb b/spec/services/members/approve_access_request_service_spec.rb index b3018169a1c..7076571b753 100644 --- a/spec/services/members/approve_access_request_service_spec.rb +++ b/spec/services/members/approve_access_request_service_spec.rb @@ -1,70 +1,56 @@ require 'spec_helper' describe Members::ApproveAccessRequestService do - let(:user) { create(:user) } - let(:access_requester) { create(:user) } let(:project) { create(:project, :public, :access_requestable) } let(:group) { create(:group, :public, :access_requestable) } + let(:current_user) { create(:user) } + let(:access_requester_user) { create(:user) } + let(:access_requester) { source.requesters.find_by!(user_id: access_requester_user.id) } let(:opts) { {} } shared_examples 'a service raising ActiveRecord::RecordNotFound' do it 'raises ActiveRecord::RecordNotFound' do - expect { described_class.new(source, user, params).execute(opts) }.to raise_error(ActiveRecord::RecordNotFound) + expect { described_class.new(current_user).execute(access_requester, opts) }.to raise_error(ActiveRecord::RecordNotFound) end end shared_examples 'a service raising Gitlab::Access::AccessDeniedError' do it 'raises Gitlab::Access::AccessDeniedError' do - expect { described_class.new(source, user, params).execute(opts) }.to raise_error(Gitlab::Access::AccessDeniedError) + expect { described_class.new(current_user).execute(access_requester, opts) }.to raise_error(Gitlab::Access::AccessDeniedError) end end shared_examples 'a service approving an access request' do it 'succeeds' do - expect { described_class.new(source, user, params).execute(opts) }.to change { source.requesters.count }.by(-1) + expect { described_class.new(current_user).execute(access_requester, opts) }.to change { source.requesters.count }.by(-1) end it 'returns a <Source>Member' do - member = described_class.new(source, user, params).execute(opts) + member = described_class.new(current_user).execute(access_requester, opts) expect(member).to be_a "#{source.class}Member".constantize expect(member.requested_at).to be_nil end context 'with a custom access level' do - let(:params2) { params.merge(user_id: access_requester.id, access_level: Gitlab::Access::MASTER) } - it 'returns a ProjectMember with the custom access level' do - member = described_class.new(source, user, params2).execute(opts) + member = described_class.new(current_user, access_level: Gitlab::Access::MASTER).execute(access_requester, opts) - expect(member.access_level).to eq Gitlab::Access::MASTER + expect(member.access_level).to eq(Gitlab::Access::MASTER) end end end - context 'when no access requester are found' do - let(:params) { { user_id: 42 } } - - it_behaves_like 'a service raising ActiveRecord::RecordNotFound' do - let(:source) { project } - end - - it_behaves_like 'a service raising ActiveRecord::RecordNotFound' do - let(:source) { group } - end - end - context 'when an access requester is found' do before do - project.request_access(access_requester) - group.request_access(access_requester) + project.request_access(access_requester_user) + group.request_access(access_requester_user) end - let(:params) { { user_id: access_requester.id } } context 'when current user is nil' do let(:user) { nil } - context 'and :force option is not given' do + context 'and :ldap option is not given' do it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do let(:source) { project } end @@ -74,8 +60,8 @@ describe Members::ApproveAccessRequestService do end end - context 'and :force option is false' do - let(:opts) { { force: false } } + context 'and :skip_authorization option is false' do + let(:opts) { { skip_authorization: false } } it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do let(:source) { project } @@ -86,8 +72,8 @@ describe Members::ApproveAccessRequestService do end end - context 'and :force option is true' do - let(:opts) { { force: true } } + context 'and :skip_authorization option is true' do + let(:opts) { { skip_authorization: true } } it_behaves_like 'a service approving an access request' do let(:source) { project } @@ -97,18 +83,6 @@ describe Members::ApproveAccessRequestService do let(:source) { group } end end - - context 'and :force param is true' do - let(:params) { { user_id: access_requester.id, force: true } } - - it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do - let(:source) { project } - end - - it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do - let(:source) { group } - end - end end context 'when current user cannot approve access request to the project' do @@ -123,8 +97,8 @@ describe Members::ApproveAccessRequestService do context 'when current user can approve access request to the project' do before do - project.add_master(user) - group.add_owner(user) + project.add_master(current_user) + group.add_owner(current_user) end it_behaves_like 'a service approving an access request' do @@ -134,14 +108,6 @@ describe Members::ApproveAccessRequestService do it_behaves_like 'a service approving an access request' do let(:source) { group } end - - context 'when given a :id' do - let(:params) { { id: project.requesters.find_by!(user_id: access_requester.id).id } } - - it_behaves_like 'a service approving an access request' do - let(:source) { project } - end - end end end end diff --git a/spec/services/members/authorized_destroy_service_spec.rb b/spec/services/members/authorized_destroy_service_spec.rb deleted file mode 100644 index 9cf6f64a078..00000000000 --- a/spec/services/members/authorized_destroy_service_spec.rb +++ /dev/null @@ -1,110 +0,0 @@ -require 'spec_helper' - -describe Members::AuthorizedDestroyService do - let(:member_user) { create(:user) } - let(:project) { create(:project, :public) } - let(:group) { create(:group, :public) } - let(:group_project) { create(:project, :public, group: group) } - - def number_of_assigned_issuables(user) - Issue.assigned_to(user).count + MergeRequest.assigned_to(user).count - end - - context 'Invited users' do - # Regression spec for issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/32504 - it 'destroys invited project member' do - project.add_developer(member_user) - - member = create :project_member, :invited, project: project - - expect { described_class.new(member, member_user).execute } - .to change { Member.count }.from(3).to(2) - end - - it "doesn't destroy invited project member notification_settings" do - project.add_developer(member_user) - - member = create :project_member, :invited, project: project - - expect { described_class.new(member, member_user).execute } - .not_to change { NotificationSetting.count } - end - - it 'destroys invited group member' do - group.add_developer(member_user) - - member = create :group_member, :invited, group: group - - expect { described_class.new(member, member_user).execute } - .to change { Member.count }.from(2).to(1) - end - - it "doesn't destroy invited group member notification_settings" do - group.add_developer(member_user) - - member = create :group_member, :invited, group: group - - expect { described_class.new(member, member_user).execute } - .not_to change { NotificationSetting.count } - end - end - - context 'Requested user' do - it "doesn't destroy member notification_settings" do - member = create(:project_member, user: member_user, requested_at: Time.now) - - expect { described_class.new(member, member_user).execute } - .not_to change { NotificationSetting.count } - end - end - - context 'Group member' do - let(:member) { group.members.find_by(user_id: member_user.id) } - - before do - group.add_developer(member_user) - end - - it "unassigns issues and merge requests" do - issue = create :issue, project: group_project, assignees: [member_user] - create :issue, assignees: [member_user] - merge_request = create :merge_request, target_project: group_project, source_project: group_project, assignee: member_user - create :merge_request, target_project: project, source_project: project, assignee: member_user - - expect { described_class.new(member, member_user).execute } - .to change { number_of_assigned_issuables(member_user) }.from(4).to(2) - - expect(issue.reload.assignee_ids).to be_empty - expect(merge_request.reload.assignee_id).to be_nil - end - - it 'destroys member notification_settings' do - group.add_developer(member_user) - member = group.members.find_by(user_id: member_user.id) - - expect { described_class.new(member, member_user).execute } - .to change { member_user.notification_settings.count }.by(-1) - end - end - - context 'Project member' do - let(:member) { project.members.find_by(user_id: member_user.id) } - - before do - project.add_developer(member_user) - end - - it "unassigns issues and merge requests" do - create :issue, project: project, assignees: [member_user] - create :merge_request, target_project: project, source_project: project, assignee: member_user - - expect { described_class.new(member, member_user).execute } - .to change { number_of_assigned_issuables(member_user) }.from(2).to(0) - end - - it 'destroys member notification_settings' do - expect { described_class.new(member, member_user).execute } - .to change { member_user.notification_settings.count }.by(-1) - end - end -end diff --git a/spec/services/members/create_service_spec.rb b/spec/services/members/create_service_spec.rb index 6bd4718e780..1831c62d788 100644 --- a/spec/services/members/create_service_spec.rb +++ b/spec/services/members/create_service_spec.rb @@ -11,7 +11,7 @@ describe Members::CreateService do it 'adds user to members' do params = { user_ids: project_user.id.to_s, access_level: Gitlab::Access::GUEST } - result = described_class.new(project, user, params).execute + result = described_class.new(user, params).execute(project) expect(result[:status]).to eq(:success) expect(project.users).to include project_user @@ -19,7 +19,7 @@ describe Members::CreateService do it 'adds no user to members' do params = { user_ids: '', access_level: Gitlab::Access::GUEST } - result = described_class.new(project, user, params).execute + result = described_class.new(user, params).execute(project) expect(result[:status]).to eq(:error) expect(result[:message]).to be_present @@ -30,7 +30,7 @@ describe Members::CreateService do user_ids = 1.upto(101).to_a.join(',') params = { user_ids: user_ids, access_level: Gitlab::Access::GUEST } - result = described_class.new(project, user, params).execute + result = described_class.new(user, params).execute(project) expect(result[:status]).to eq(:error) expect(result[:message]).to be_present diff --git a/spec/services/members/destroy_service_spec.rb b/spec/services/members/destroy_service_spec.rb index 91152df3ad9..10c264a90c5 100644 --- a/spec/services/members/destroy_service_spec.rb +++ b/spec/services/members/destroy_service_spec.rb @@ -1,112 +1,202 @@ require 'spec_helper' describe Members::DestroyService do - let(:user) { create(:user) } + let(:current_user) { create(:user) } let(:member_user) { create(:user) } - let(:project) { create(:project, :public) } let(:group) { create(:group, :public) } + let(:group_project) { create(:project, :public, group: group) } + let(:opts) { {} } shared_examples 'a service raising ActiveRecord::RecordNotFound' do it 'raises ActiveRecord::RecordNotFound' do - expect { described_class.new(source, user, params).execute }.to raise_error(ActiveRecord::RecordNotFound) + expect { described_class.new(current_user).execute(member) }.to raise_error(ActiveRecord::RecordNotFound) end end shared_examples 'a service raising Gitlab::Access::AccessDeniedError' do it 'raises Gitlab::Access::AccessDeniedError' do - expect { described_class.new(source, user, params).execute }.to raise_error(Gitlab::Access::AccessDeniedError) + expect { described_class.new(current_user).execute(member) }.to raise_error(Gitlab::Access::AccessDeniedError) end end + def number_of_assigned_issuables(user) + Issue.assigned_to(user).count + MergeRequest.assigned_to(user).count + end + shared_examples 'a service destroying a member' do it 'destroys the member' do - expect { described_class.new(source, user, params).execute }.to change { source.members.count }.by(-1) + expect { described_class.new(current_user).execute(member, opts) }.to change { member.source.members_and_requesters.count }.by(-1) end - context 'when the given member is an access requester' do - before do - source.members.find_by(user_id: member_user).destroy - source.update_attributes(request_access_enabled: true) - source.request_access(member_user) + it 'unassigns issues and merge requests' do + if member.invite? + expect { described_class.new(current_user).execute(member, opts) } + .not_to change { number_of_assigned_issuables(member_user) } + else + create :issue, assignees: [member_user] + issue = create :issue, project: group_project, assignees: [member_user] + merge_request = create :merge_request, target_project: group_project, source_project: group_project, assignee: member_user + + expect { described_class.new(current_user).execute(member, opts) } + .to change { number_of_assigned_issuables(member_user) }.from(3).to(1) + + expect(issue.reload.assignee_ids).to be_empty + expect(merge_request.reload.assignee_id).to be_nil end - let(:access_requester) { source.requesters.find_by(user_id: member_user) } + end - it_behaves_like 'a service raising ActiveRecord::RecordNotFound' + it 'destroys member notification_settings' do + if member_user.notification_settings.any? + expect { described_class.new(current_user).execute(member, opts) } + .to change { member_user.notification_settings.count }.by(-1) + else + expect { described_class.new(current_user).execute(member, opts) } + .not_to change { member_user.notification_settings.count } + end + end + end - %i[requesters all].each do |scope| - context "and #{scope} scope is passed" do - it 'destroys the access requester' do - expect { described_class.new(source, user, params).execute(scope) }.to change { source.requesters.count }.by(-1) - end + shared_examples 'a service destroying an access requester' do + it_behaves_like 'a service destroying a member' - it 'calls Member#after_decline_request' do - expect_any_instance_of(NotificationService).to receive(:decline_access_request).with(access_requester) + it 'calls Member#after_decline_request' do + expect_any_instance_of(NotificationService).to receive(:decline_access_request).with(member) - described_class.new(source, user, params).execute(scope) - end + described_class.new(current_user).execute(member) + end - context 'when current user is the member' do - it 'does not call Member#after_decline_request' do - expect_any_instance_of(NotificationService).not_to receive(:decline_access_request).with(access_requester) + context 'when current user is the member' do + it 'does not call Member#after_decline_request' do + expect_any_instance_of(NotificationService).not_to receive(:decline_access_request).with(member) - described_class.new(source, member_user, params).execute(scope) - end - end - end + described_class.new(member_user).execute(member) end end end - context 'when no member are found' do - let(:params) { { user_id: 42 } } + context 'with a member' do + before do + group_project.add_developer(member_user) + group.add_developer(member_user) + end + + context 'when current user cannot destroy the given member' do + it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do + let(:member) { group_project.members.find_by(user_id: member_user.id) } + end + + it_behaves_like 'a service destroying a member' do + let(:opts) { { skip_authorization: true } } + let(:member) { group_project.members.find_by(user_id: member_user.id) } + end + + it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do + let(:member) { group.members.find_by(user_id: member_user.id) } + end - it_behaves_like 'a service raising ActiveRecord::RecordNotFound' do - let(:source) { project } + it_behaves_like 'a service destroying a member' do + let(:opts) { { skip_authorization: true } } + let(:member) { group.members.find_by(user_id: member_user.id) } + end end - it_behaves_like 'a service raising ActiveRecord::RecordNotFound' do - let(:source) { group } + context 'when current user can destroy the given member' do + before do + group_project.add_master(current_user) + group.add_owner(current_user) + end + + it_behaves_like 'a service destroying a member' do + let(:member) { group_project.members.find_by(user_id: member_user.id) } + end + + it_behaves_like 'a service destroying a member' do + let(:member) { group.members.find_by(user_id: member_user.id) } + end end end - context 'when a member is found' do + context 'with an access requester' do before do - project.add_developer(member_user) - group.add_developer(member_user) + group_project.update_attributes(request_access_enabled: true) + group.update_attributes(request_access_enabled: true) + group_project.request_access(member_user) + group.request_access(member_user) end - let(:params) { { user_id: member_user.id } } - context 'when current user cannot destroy the given member' do + context 'when current user cannot destroy the given access requester' do it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do - let(:source) { project } + let(:member) { group_project.requesters.find_by(user_id: member_user.id) } + end + + it_behaves_like 'a service destroying a member' do + let(:opts) { { skip_authorization: true } } + let(:member) { group_project.requesters.find_by(user_id: member_user.id) } end it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do - let(:source) { group } + let(:member) { group.requesters.find_by(user_id: member_user.id) } + end + + it_behaves_like 'a service destroying a member' do + let(:opts) { { skip_authorization: true } } + let(:member) { group.requesters.find_by(user_id: member_user.id) } end end - context 'when current user can destroy the given member' do + context 'when current user can destroy the given access requester' do before do - project.add_master(user) - group.add_owner(user) + group_project.add_master(current_user) + group.add_owner(current_user) + end + + it_behaves_like 'a service destroying an access requester' do + let(:member) { group_project.requesters.find_by(user_id: member_user.id) } + end + + it_behaves_like 'a service destroying an access requester' do + let(:member) { group.requesters.find_by(user_id: member_user.id) } + end + end + end + + context 'with an invited user' do + let(:project_invited_member) { create(:project_member, :invited, project: group_project) } + let(:group_invited_member) { create(:group_member, :invited, group: group) } + + context 'when current user cannot destroy the given invited user' do + it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do + let(:member) { project_invited_member } end it_behaves_like 'a service destroying a member' do - let(:source) { project } + let(:opts) { { skip_authorization: true } } + let(:member) { project_invited_member } + end + + it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do + let(:member) { group_invited_member } end it_behaves_like 'a service destroying a member' do - let(:source) { group } + let(:opts) { { skip_authorization: true } } + let(:member) { group_invited_member } end + end - context 'when given a :id' do - let(:params) { { id: project.members.find_by!(user_id: user.id).id } } + context 'when current user can destroy the given invited user' do + before do + group_project.add_master(current_user) + group.add_owner(current_user) + end - it 'destroys the member' do - expect { described_class.new(project, user, params).execute } - .to change { project.members.count }.by(-1) - end + # Regression spec for issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/32504 + it_behaves_like 'a service destroying a member' do + let(:member) { project_invited_member } + end + + it_behaves_like 'a service destroying a member' do + let(:member) { group_invited_member } end end end diff --git a/spec/services/members/request_access_service_spec.rb b/spec/services/members/request_access_service_spec.rb index 0a704bba521..e93ba5a85c0 100644 --- a/spec/services/members/request_access_service_spec.rb +++ b/spec/services/members/request_access_service_spec.rb @@ -5,17 +5,17 @@ describe Members::RequestAccessService do shared_examples 'a service raising Gitlab::Access::AccessDeniedError' do it 'raises Gitlab::Access::AccessDeniedError' do - expect { described_class.new(source, user).execute }.to raise_error(Gitlab::Access::AccessDeniedError) + expect { described_class.new(user).execute(source) }.to raise_error(Gitlab::Access::AccessDeniedError) end end shared_examples 'a service creating a access request' do it 'succeeds' do - expect { described_class.new(source, user).execute }.to change { source.requesters.count }.by(1) + expect { described_class.new(user).execute(source) }.to change { source.requesters.count }.by(1) end it 'returns a <Source>Member' do - member = described_class.new(source, user).execute + member = described_class.new(user).execute(source) expect(member).to be_a "#{source.class}Member".constantize expect(member.requested_at).to be_present diff --git a/spec/services/members/update_service_spec.rb b/spec/services/members/update_service_spec.rb new file mode 100644 index 00000000000..a451272dd1f --- /dev/null +++ b/spec/services/members/update_service_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +describe Members::UpdateService do + let(:project) { create(:project, :public) } + let(:group) { create(:group, :public) } + let(:current_user) { create(:user) } + let(:member_user) { create(:user) } + let(:permission) { :update } + let(:member) { source.members_and_requesters.find_by!(user_id: member_user.id) } + let(:params) do + { access_level: Gitlab::Access::MASTER } + end + + shared_examples 'a service raising Gitlab::Access::AccessDeniedError' do + it 'raises Gitlab::Access::AccessDeniedError' do + expect { described_class.new(current_user, params).execute(member, permission: permission) } + .to raise_error(Gitlab::Access::AccessDeniedError) + end + end + + shared_examples 'a service updating a member' do + it 'updates the member' do + updated_member = described_class.new(current_user, params).execute(member, permission: permission) + + expect(updated_member).to be_valid + expect(updated_member.access_level).to eq(Gitlab::Access::MASTER) + end + end + + before do + project.add_developer(member_user) + group.add_developer(member_user) + end + + context 'when current user cannot update the given member' do + it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do + let(:source) { project } + end + + it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do + let(:source) { group } + end + end + + context 'when current user can update the given member' do + before do + project.add_master(current_user) + group.add_owner(current_user) + end + + it_behaves_like 'a service updating a member' do + let(:source) { project } + end + + it_behaves_like 'a service updating a member' do + let(:source) { group } + end + end +end diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index a0b97ceead9..ad5a289290c 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -123,6 +123,40 @@ describe Projects::UpdateService do end end + context 'when we update project but not enabling a wiki' do + it 'does not try to create an empty wiki' do + FileUtils.rm_rf(project.wiki.repository.path) + + result = update_project(project, user, { name: 'test1' }) + + expect(result).to eq({ status: :success }) + expect(project.wiki_repository_exists?).to be false + end + end + + context 'when enabling a wiki' do + it 'creates a wiki' do + project.project_feature.update(wiki_access_level: ProjectFeature::DISABLED) + FileUtils.rm_rf(project.wiki.repository.path) + + result = update_project(project, user, project_feature_attributes: { wiki_access_level: ProjectFeature::ENABLED }) + + expect(result).to eq({ status: :success }) + expect(project.wiki_repository_exists?).to be true + expect(project.wiki_enabled?).to be true + end + + it 'logs an error and creates a metric when wiki can not be created' do + project.project_feature.update(wiki_access_level: ProjectFeature::DISABLED) + + expect_any_instance_of(ProjectWiki).to receive(:wiki).and_raise(ProjectWiki::CouldNotCreateWikiError) + expect_any_instance_of(described_class).to receive(:log_error).with("Could not create wiki for #{project.full_name}") + expect(Gitlab::Metrics).to receive(:counter) + + update_project(project, user, project_feature_attributes: { wiki_access_level: ProjectFeature::ENABLED }) + end + end + context 'when updating a project that contains container images' do before do stub_container_registry_config(enabled: true) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c0f3366fb52..9f6f0204a16 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -186,6 +186,10 @@ RSpec.configure do |config| example.run if Gitlab::Database.postgresql? end + config.around(:each, :mysql) do |example| + example.run if Gitlab::Database.mysql? + end + # This makes sure the `ApplicationController#can?` method is stubbed with the # original implementation for all view specs. config.before(:each, type: :view) do diff --git a/spec/support/cycle_analytics_helpers.rb b/spec/support/cycle_analytics_helpers.rb index d5ef80cfab2..73cc64c0b74 100644 --- a/spec/support/cycle_analytics_helpers.rb +++ b/spec/support/cycle_analytics_helpers.rb @@ -26,7 +26,19 @@ module CycleAnalyticsHelpers ref: 'refs/heads/master').execute end - def create_merge_request_closing_issue(issue, message: nil, source_branch: nil, commit_message: 'commit message') + def create_cycle(user, project, issue, mr, milestone, pipeline) + issue.update(milestone: milestone) + pipeline.run + + ci_build = create(:ci_build, pipeline: pipeline, status: :success, author: user) + + merge_merge_requests_closing_issue(user, project, issue) + ProcessCommitWorker.new.perform(project.id, user.id, mr.commits.last.to_hash) + + ci_build + end + + def create_merge_request_closing_issue(user, project, issue, message: nil, source_branch: nil, commit_message: 'commit message') if !source_branch || project.repository.commit(source_branch).blank? source_branch = generate(:branch) project.repository.add_branch(user, source_branch, 'master') @@ -52,19 +64,19 @@ module CycleAnalyticsHelpers mr end - def merge_merge_requests_closing_issue(issue) + def merge_merge_requests_closing_issue(user, project, issue) merge_requests = issue.closed_by_merge_requests(user) merge_requests.each { |merge_request| MergeRequests::MergeService.new(project, user).execute(merge_request) } end - def deploy_master(environment: 'production') + def deploy_master(user, project, environment: 'production') dummy_job = case environment when 'production' - dummy_production_job + dummy_production_job(user, project) when 'staging' - dummy_staging_job + dummy_staging_job(user, project) else raise ArgumentError end @@ -72,25 +84,24 @@ module CycleAnalyticsHelpers CreateDeploymentService.new(dummy_job).execute end - def dummy_production_job - @dummy_job ||= new_dummy_job('production') + def dummy_production_job(user, project) + new_dummy_job(user, project, 'production') end - def dummy_staging_job - @dummy_job ||= new_dummy_job('staging') + def dummy_staging_job(user, project) + new_dummy_job(user, project, 'staging') end - def dummy_pipeline - @dummy_pipeline ||= - Ci::Pipeline.new( - sha: project.repository.commit('master').sha, - ref: 'master', - source: :push, - project: project, - protected: false) + def dummy_pipeline(project) + Ci::Pipeline.new( + sha: project.repository.commit('master').sha, + ref: 'master', + source: :push, + project: project, + protected: false) end - def new_dummy_job(environment) + def new_dummy_job(user, project, environment) project.environments.find_or_create_by(name: environment) Ci::Build.new( @@ -101,7 +112,7 @@ module CycleAnalyticsHelpers tag: false, name: 'dummy', stage: 'dummy', - pipeline: dummy_pipeline, + pipeline: dummy_pipeline(project), protected: false) end diff --git a/spec/support/features/variable_list_shared_examples.rb b/spec/support/features/variable_list_shared_examples.rb index 0d8f7a7aae6..f7f851eb1eb 100644 --- a/spec/support/features/variable_list_shared_examples.rb +++ b/spec/support/features/variable_list_shared_examples.rb @@ -261,6 +261,8 @@ shared_examples 'variable list' do click_button('Save variables') wait_for_requests + expect(all('.js-ci-variable-list-section .js-ci-variable-error-box ul li').count).to eq(1) + # We check the first row because it re-sorts to alphabetical order on refresh page.within('.js-ci-variable-list-section') do expect(find('.js-ci-variable-error-box')).to have_content(/Validation failed Variables have duplicate values \(.+\)/) diff --git a/spec/support/gitlab-git-test.git/packed-refs b/spec/support/gitlab-git-test.git/packed-refs index 507e4ce785a..ea50e4ad3f6 100644 --- a/spec/support/gitlab-git-test.git/packed-refs +++ b/spec/support/gitlab-git-test.git/packed-refs @@ -1,4 +1,4 @@ -# pack-refs with: peeled fully-peeled +# pack-refs with: peeled fully-peeled sorted 0b4bc9a49b562e85de7cc9e834518ea6828729b9 refs/heads/feature 12d65c8dd2b2676fa3ac47d955accc085a37a9c1 refs/heads/fix 6473c90867124755509e100d0d35ebdc85a0b6ae refs/heads/fix-blob-path diff --git a/spec/workers/cluster_wait_for_ingress_ip_address_worker_spec.rb b/spec/workers/cluster_wait_for_ingress_ip_address_worker_spec.rb new file mode 100644 index 00000000000..2e2e9afd25a --- /dev/null +++ b/spec/workers/cluster_wait_for_ingress_ip_address_worker_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +describe ClusterWaitForIngressIpAddressWorker do + describe '#perform' do + let(:service) { instance_double(Clusters::Applications::CheckIngressIpAddressService, execute: true) } + let(:application) { instance_double(Clusters::Applications::Ingress) } + let(:worker) { described_class.new } + + before do + allow(worker) + .to receive(:find_application) + .with('ingress', 117) + .and_yield(application) + + allow(Clusters::Applications::CheckIngressIpAddressService) + .to receive(:new) + .with(application) + .and_return(service) + + allow(described_class) + .to receive(:perform_in) + end + + it 'finds the application and calls CheckIngressIpAddressService#execute' do + worker.perform('ingress', 117) + + expect(service).to have_received(:execute) + end + end +end diff --git a/spec/workers/namespaceless_project_destroy_worker_spec.rb b/spec/workers/namespaceless_project_destroy_worker_spec.rb index ed8cedc0079..479d9396eca 100644 --- a/spec/workers/namespaceless_project_destroy_worker_spec.rb +++ b/spec/workers/namespaceless_project_destroy_worker_spec.rb @@ -22,7 +22,9 @@ describe NamespacelessProjectDestroyWorker do end end - context 'project has no namespace' do + # Only possible with schema 20180222043024 and lower. + # Project#namespace_id has not null constraint since then + context 'project has no namespace', :migration, schema: 20180222043024 do let!(:project) do project = build(:project, namespace_id: nil) project.save(validate: false) diff --git a/spec/workers/plugin_worker_spec.rb b/spec/workers/plugin_worker_spec.rb new file mode 100644 index 00000000000..9238a8199bc --- /dev/null +++ b/spec/workers/plugin_worker_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe PluginWorker do + include RepoHelpers + + let(:filename) { 'my_plugin.rb' } + let(:data) { { 'event_name' => 'project_create' } } + + subject { described_class.new } + + describe '#perform' do + it 'executes Gitlab::Plugin with expected values' do + allow(Gitlab::Plugin).to receive(:execute).with(filename, data).and_return([true, '']) + + expect(subject.perform(filename, data)).to be_truthy + end + + it 'logs message in case of plugin execution failure' do + allow(Gitlab::Plugin).to receive(:execute).with(filename, data).and_return([false, 'permission denied']) + + expect(Gitlab::PluginLogger).to receive(:error) + expect(subject.perform(filename, data)).to be_truthy + end + end +end diff --git a/vendor/prometheus/values.yaml b/vendor/prometheus/values.yaml index db967514be7..859f2ad82a4 100644 --- a/vendor/prometheus/values.yaml +++ b/vendor/prometheus/values.yaml @@ -10,6 +10,9 @@ nodeExporter: pushgateway: enabled: false +rbac: + create: false + server: image: tag: v2.1.0 diff --git a/yarn.lock b/yarn.lock index 4d7dc1be854..ab0ad265d81 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8732,9 +8732,9 @@ webpack-dev-middleware@1.12.2, webpack-dev-middleware@^1.12.0: range-parser "^1.0.3" time-stamp "^2.0.0" -webpack-dev-server@^2.11.1: - version "2.11.1" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-2.11.1.tgz#6f9358a002db8403f016e336816f4485384e5ec0" +webpack-dev-server@^2.11.2: + version "2.11.2" + resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-2.11.2.tgz#1f4f4c78bf1895378f376815910812daf79a216f" dependencies: ansi-html "0.0.7" array-includes "^3.0.3" |