diff options
author | Marin Jankovski <marin@gitlab.com> | 2018-02-08 15:47:14 +0300 |
---|---|---|
committer | Marin Jankovski <marin@gitlab.com> | 2018-02-08 15:47:14 +0300 |
commit | 6ea755fa45406cf31ac8243354c1bb833b60b79b (patch) | |
tree | 58aabe6558881b9ca2e9785a8b9bd735979b4930 | |
parent | 1afbd96d56e93629994fabec4e0383ca8fd1b9d2 (diff) | |
parent | 7e424eb852716495073881710e8a8851b4a4cd5a (diff) |
Merge branch '2018-02-08-ce-cut-off-to-10-5-stable' into '10-5-stable'
Update 10-5-stable to freeze point
See merge request gitlab-org/gitlab-ce!16993
375 files changed, 8350 insertions, 2340 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fc97c06f7c..9ad603fdc75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 10.4.3 (2018-02-05) + +### Security (4 changes) + +- Fix namespace access issue for GitHub, BitBucket, and GitLab.com project importers. +- Fix stored XSS in code blocks that ignore highlighting. +- Fix wilcard protected tags protecting all branches. +- Restrict Todo API mark_as_done endpoint to the user's todos only. + + ## 10.4.2 (2018-01-30) ### Fixed (6 changes) @@ -197,6 +207,16 @@ entry. - Use a background migration for issues.closed_at. +## 10.3.7 (2018-02-05) + +### Security (4 changes) + +- Fix namespace access issue for GitHub, BitBucket, and GitLab.com project importers. +- Fix stored XSS in code blocks that ignore highlighting. +- Fix wilcard protected tags protecting all branches. +- Restrict Todo API mark_as_done endpoint to the user's todos only. + + ## 10.3.6 (2018-01-22) ### Fixed (17 changes, 2 of them are from the community) @@ -415,6 +435,16 @@ entry. - Clean up schema of the "merge_requests" table. +## 10.2.8 (2018-02-07) + +### Security (4 changes) + +- Fix namespace access issue for GitHub, BitBucket, and GitLab.com project importers. +- Fix stored XSS in code blocks that ignore highlighting. +- Fix wilcard protected tags protecting all branches. +- Restrict Todo API mark_as_done endpoint to the user's todos only. + + ## 10.2.7 (2018-01-18) - No changes. diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index b53d377d909..9a55e28031d 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.80.0 +0.81.0 @@ -294,7 +294,7 @@ group :metrics do gem 'influxdb', '~> 0.2', require: false # Prometheus - gem 'prometheus-client-mmap', '~> 0.7.0.beta44' + gem 'prometheus-client-mmap', '~> 0.9.1' gem 'raindrops', '~> 0.18' end @@ -410,7 +410,7 @@ group :ed25519 do end # Gitaly GRPC client -gem 'gitaly-proto', '~> 0.83.0', require: 'gitaly' +gem 'gitaly-proto', '~> 0.84.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 e78c3c5f794..22c4fc0ef28 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.83.0) + gitaly-proto (0.84.0) google-protobuf (~> 3.1) grpc (~> 1.0) github-linguist (4.7.6) @@ -636,7 +636,7 @@ GEM parser unparser procto (0.0.3) - prometheus-client-mmap (0.7.0.beta44) + prometheus-client-mmap (0.9.1) pry (0.10.4) coderay (~> 1.1.0) method_source (~> 0.8.1) @@ -1056,7 +1056,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.2.0) - gitaly-proto (~> 0.83.0) + gitaly-proto (~> 0.84.0) github-linguist (~> 4.7.0) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-markup (~> 1.6.2) @@ -1132,7 +1132,7 @@ DEPENDENCIES peek-sidekiq (~> 1.0.3) pg (~> 0.18.2) premailer-rails (~> 1.9.7) - prometheus-client-mmap (~> 0.7.0.beta44) + prometheus-client-mmap (~> 0.9.1) pry-byebug (~> 3.4.1) pry-rails (~> 0.3.4) rack-attack (~> 4.4.1) diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index a8dafd31f12..9c4cc2338c8 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -2,7 +2,7 @@ import Sortable from 'vendor/Sortable'; import Vue from 'vue'; import AccessorUtilities from '../../lib/utils/accessor'; -import boardList from './board_list'; +import boardList from './board_list.vue'; import boardBlankState from './board_blank_state'; import './board_delete'; diff --git a/app/assets/javascripts/boards/components/board_list.js b/app/assets/javascripts/boards/components/board_list.vue index 591f1dc8313..9a0442e2afe 100644 --- a/app/assets/javascripts/boards/components/board_list.js +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -1,3 +1,4 @@ +<script> import Sortable from 'vendor/Sortable'; import boardNewIssue from './board_new_issue'; import boardCard from './board_card.vue'; @@ -8,6 +9,11 @@ const Store = gl.issueBoards.BoardsStore; export default { name: 'BoardList', + components: { + boardCard, + boardNewIssue, + loadingIcon, + }, props: { disabled: { type: Boolean, @@ -42,46 +48,6 @@ export default { showIssueForm: false, }; }, - components: { - boardCard, - boardNewIssue, - loadingIcon, - }, - methods: { - listHeight() { - return this.$refs.list.getBoundingClientRect().height; - }, - scrollHeight() { - return this.$refs.list.scrollHeight; - }, - scrollTop() { - return this.$refs.list.scrollTop + this.listHeight(); - }, - scrollToTop() { - this.$refs.list.scrollTop = 0; - }, - loadNextPage() { - const getIssues = this.list.nextPage(); - const loadingDone = () => { - this.list.loadingMore = false; - }; - - if (getIssues) { - this.list.loadingMore = true; - getIssues - .then(loadingDone) - .catch(loadingDone); - } - }, - toggleForm() { - this.showIssueForm = !this.showIssueForm; - }, - onScroll() { - if (!this.loadingMore && (this.scrollTop() > this.scrollHeight() - this.scrollOffset)) { - this.loadNextPage(); - } - }, - }, watch: { filters: { handler() { @@ -157,51 +123,90 @@ export default { eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop); this.$refs.list.removeEventListener('scroll', this.onScroll); }, - template: ` - <div class="board-list-component"> - <div - class="board-list-loading text-center" - aria-label="Loading issues" - v-if="loading"> - <loading-icon /> - </div> - <board-new-issue - :list="list" - v-if="list.type !== 'closed' && showIssueForm"/> - <ul - class="board-list" - v-show="!loading" - ref="list" - :data-board="list.id" - :class="{ 'is-smaller': showIssueForm }"> - <board-card - v-for="(issue, index) in issues" - ref="issue" - :index="index" - :list="list" - :issue="issue" - :issue-link-base="issueLinkBase" - :root-path="rootPath" - :disabled="disabled" - :key="issue.id" /> - <li - class="board-list-count text-center" - v-if="showCount" - data-issue-id="-1"> + methods: { + listHeight() { + return this.$refs.list.getBoundingClientRect().height; + }, + scrollHeight() { + return this.$refs.list.scrollHeight; + }, + scrollTop() { + return this.$refs.list.scrollTop + this.listHeight(); + }, + scrollToTop() { + this.$refs.list.scrollTop = 0; + }, + loadNextPage() { + const getIssues = this.list.nextPage(); + const loadingDone = () => { + this.list.loadingMore = false; + }; - <loading-icon - v-show="list.loadingMore" - label="Loading more issues" - /> + if (getIssues) { + this.list.loadingMore = true; + getIssues + .then(loadingDone) + .catch(loadingDone); + } + }, + toggleForm() { + this.showIssueForm = !this.showIssueForm; + }, + onScroll() { + if (!this.loadingMore && (this.scrollTop() > this.scrollHeight() - this.scrollOffset)) { + this.loadNextPage(); + } + }, + }, +}; +</script> - <span v-if="list.issues.length === list.issuesSize"> - Showing all issues - </span> - <span v-else> - Showing {{ list.issues.length }} of {{ list.issuesSize }} issues - </span> - </li> - </ul> +<template> + <div class="board-list-component"> + <div + class="board-list-loading text-center" + aria-label="Loading issues" + v-if="loading"> + <loading-icon /> </div> - `, -}; + <board-new-issue + :list="list" + v-if="list.type !== 'closed' && showIssueForm"/> + <ul + class="board-list" + v-show="!loading" + ref="list" + :data-board="list.id" + :class="{ 'is-smaller': showIssueForm }"> + <board-card + v-for="(issue, index) in issues" + ref="issue" + :index="index" + :list="list" + :issue="issue" + :issue-link-base="issueLinkBase" + :root-path="rootPath" + :disabled="disabled" + :key="issue.id" /> + <li + class="board-list-count text-center" + v-if="showCount" + data-issue-id="-1"> + <loading-icon + v-show="list.loadingMore" + label="Loading more issues" + /> + <span + v-if="list.issues.length === list.issuesSize" + > + Showing all issues + </span> + <span + v-else + > + Showing {{ list.issues.length }} of {{ list.issuesSize }} issues + </span> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js new file mode 100644 index 00000000000..76f93e5c6bd --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js @@ -0,0 +1,116 @@ +import _ from 'underscore'; +import axios from '../lib/utils/axios_utils'; +import { s__ } from '../locale'; +import Flash from '../flash'; +import { convertPermissionToBoolean } from '../lib/utils/common_utils'; +import statusCodes from '../lib/utils/http_status'; +import VariableList from './ci_variable_list'; + +function generateErrorBoxContent(errors) { + const errorList = [].concat(errors).map(errorString => ` + <li> + ${_.escape(errorString)} + </li> + `); + + return ` + <p> + ${s__('CiVariable|Validation failed')} + </p> + <ul> + ${errorList.join('')} + </ul> + `; +} + +// Used for the variable list on CI/CD projects/groups settings page +export default class AjaxVariableList { + constructor({ + container, + saveButton, + errorBox, + formField = 'variables', + saveEndpoint, + }) { + this.container = container; + this.saveButton = saveButton; + this.errorBox = errorBox; + this.saveEndpoint = saveEndpoint; + + this.variableList = new VariableList({ + container: this.container, + formField, + }); + + this.bindEvents(); + this.variableList.init(); + } + + bindEvents() { + this.saveButton.addEventListener('click', this.onSaveClicked.bind(this)); + } + + onSaveClicked() { + const loadingIcon = this.saveButton.querySelector('.js-secret-variables-save-loading-icon'); + loadingIcon.classList.toggle('hide', false); + this.errorBox.classList.toggle('hide', true); + // We use this to prevent a user from changing a key before we have a chance + // to match it up in `updateRowsWithPersistedVariables` + this.variableList.toggleEnableRow(false); + + return axios.patch(this.saveEndpoint, { + variables_attributes: this.variableList.getAllData(), + }, { + // We want to be able to process the `res.data` from a 400 error response + // and print the validation messages such as duplicate variable keys + validateStatus: status => ( + status >= statusCodes.OK && + status < statusCodes.MULTIPLE_CHOICES + ) || + status === statusCodes.BAD_REQUEST, + }) + .then((res) => { + loadingIcon.classList.toggle('hide', true); + this.variableList.toggleEnableRow(true); + + if (res.status === statusCodes.OK && res.data) { + this.updateRowsWithPersistedVariables(res.data.variables); + } else if (res.status === statusCodes.BAD_REQUEST) { + // Validation failed + this.errorBox.innerHTML = generateErrorBoxContent(res.data); + this.errorBox.classList.toggle('hide', false); + } + }) + .catch(() => { + loadingIcon.classList.toggle('hide', true); + this.variableList.toggleEnableRow(true); + Flash(s__('CiVariable|Error occured while saving variables')); + }); + } + + updateRowsWithPersistedVariables(persistedVariables = []) { + const persistedVariableMap = [].concat(persistedVariables).reduce((variableMap, variable) => ({ + ...variableMap, + [variable.key]: variable, + }), {}); + + this.container.querySelectorAll('.js-row').forEach((row) => { + // If we submitted a row that was destroyed, remove it so we don't try + // to destroy it again which would cause a BE error + const destroyInput = row.querySelector('.js-ci-variable-input-destroy'); + if (convertPermissionToBoolean(destroyInput.value)) { + row.remove(); + // Update the ID input so any future edits and `_destroy` will apply on the BE + } else { + const key = row.querySelector('.js-ci-variable-input-key').value; + const persistedVariable = persistedVariableMap[key]; + + if (persistedVariable) { + // eslint-disable-next-line no-param-reassign + row.querySelector('.js-ci-variable-input-id').value = persistedVariable.id; + row.setAttribute('data-is-persisted', 'true'); + } + } + }); + } +} diff --git a/app/assets/javascripts/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci_variable_list/ci_variable_list.js index e46478ddb98..d91789c2192 100644 --- a/app/assets/javascripts/ci_variable_list/ci_variable_list.js +++ b/app/assets/javascripts/ci_variable_list/ci_variable_list.js @@ -11,7 +11,7 @@ function createEnvironmentItem(value) { return { title: value === '*' ? ALL_ENVIRONMENTS_STRING : value, id: value, - text: value, + text: value === '*' ? s__('CiVariable|* (All environments)') : value, }; } @@ -41,11 +41,11 @@ export default class VariableList { selector: '.js-ci-variable-input-protected', default: 'true', }, - environment: { + environment_scope: { // We can't use a `.js-` class here because // gl_dropdown replaces the <input> and doesn't copy over the class // See https://gitlab.com/gitlab-org/gitlab-ce/issues/42458 - selector: `input[name="${this.formField}[variables_attributes][][environment]"]`, + selector: `input[name="${this.formField}[variables_attributes][][environment_scope]"]`, default: '*', }, _destroy: { @@ -104,12 +104,15 @@ export default class VariableList { setupToggleButtons($row[0]); + // Reset the resizable textarea + $row.find(this.inputMap.value.selector).css('height', ''); + const $environmentSelect = $row.find('.js-variable-environment-toggle'); if ($environmentSelect.length) { const createItemDropdown = new CreateItemDropdown({ $dropdown: $environmentSelect, defaultToggleLabel: ALL_ENVIRONMENTS_STRING, - fieldName: `${this.formField}[variables_attributes][][environment]`, + fieldName: `${this.formField}[variables_attributes][][environment_scope]`, getData: (term, callback) => callback(this.getEnvironmentValues()), createNewItemFromValue: createEnvironmentItem, onSelect: () => { @@ -117,7 +120,7 @@ export default class VariableList { // so they have the new value we just picked this.refreshDropdownData(); - $row.find(this.inputMap.environment.selector).trigger('trigger-change'); + $row.find(this.inputMap.environment_scope.selector).trigger('trigger-change'); }, }); @@ -143,7 +146,8 @@ export default class VariableList { $row.after($rowClone); } - removeRow($row) { + removeRow(row) { + const $row = $(row); const isPersisted = convertPermissionToBoolean($row.attr('data-is-persisted')); if (isPersisted) { @@ -155,6 +159,10 @@ export default class VariableList { } else { $row.remove(); } + + // Refresh the other dropdowns in the variable list + // so any value with the variable deleted is gone + this.refreshDropdownData(); } checkIfRowTouched($row) { @@ -165,6 +173,11 @@ export default class VariableList { }); } + toggleEnableRow(isEnabled = true) { + this.$container.find(this.inputMap.key.selector).attr('disabled', !isEnabled); + this.$container.find('.js-row-remove-button').attr('disabled', !isEnabled); + } + getAllData() { // Ignore the last empty row because we don't want to try persist // a blank variable and run into validation problems. @@ -185,7 +198,7 @@ export default class VariableList { } getEnvironmentValues() { - const valueMap = this.$container.find(this.inputMap.environment.selector).toArray() + const valueMap = this.$container.find(this.inputMap.environment_scope.selector).toArray() .reduce((prevValueMap, envInput) => ({ ...prevValueMap, [envInput.value]: envInput.value, diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 3d6ec37e6dd..b070a59cf15 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -32,6 +32,7 @@ export default class Clusters { installIngressPath, installRunnerPath, installPrometheusPath, + managePrometheusPath, clusterStatus, clusterStatusReason, helpPath, @@ -40,6 +41,7 @@ export default class Clusters { this.store = new ClustersStore(); this.store.setHelpPaths(helpPath, ingressHelpPath); + this.store.setManagePrometheusPath(managePrometheusPath); this.store.updateStatus(clusterStatus); this.store.updateStatusReason(clusterStatusReason); this.service = new ClustersService({ @@ -95,6 +97,7 @@ export default class Clusters { applications: this.state.applications, helpPath: this.state.helpPath, ingressHelpPath: this.state.ingressHelpPath, + managePrometheusPath: this.state.managePrometheusPath, }, }); }, diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index c13bbcee863..50e35bbbba5 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -32,6 +32,10 @@ type: String, required: false, }, + manageLink: { + type: String, + required: false, + }, description: { type: String, required: true, @@ -89,6 +93,12 @@ return label; }, + showManageButton() { + return this.manageLink && this.status === APPLICATION_INSTALLED; + }, + manageButtonLabel() { + return s__('ClusterIntegration|Manage'); + }, hasError() { return this.status === APPLICATION_ERROR || this.requestStatus === REQUEST_FAILURE; @@ -141,9 +151,21 @@ <div v-html="description"></div> </div> <div - class="table-section table-button-footer section-15 section-align-top" + class="table-section table-button-footer section-align-top" + :class="{ 'section-20': showManageButton, 'section-15': !showManageButton }" role="gridcell" > + <div + v-if="showManageButton" + class="btn-group table-action-buttons" + > + <a + class="btn" + :href="manageLink" + > + {{ manageButtonLabel }} + </a> + </div> <div class="btn-group table-action-buttons"> <loading-button class="js-cluster-application-install-button" diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index f4259700370..978881a4831 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -23,13 +23,19 @@ required: false, default: '', }, + managePrometheusPath: { + type: String, + required: false, + default: '', + }, }, computed: { generalApplicationDescription() { return sprintf( - _.escape(s__(`ClusterIntegration|Install applications on your Kubernetes cluster. - Read more about %{helpLink}`)), - { + _.escape(s__( + `ClusterIntegration|Install applications on your Kubernetes cluster. + Read more about %{helpLink}`, + )), { helpLink: `<a href="${this.helpPath}"> ${_.escape(s__('ClusterIntegration|installing applications'))} </a>`, @@ -96,11 +102,12 @@ }, prometheusDescription() { return sprintf( - _.escape(s__(`ClusterIntegration|Prometheus is an open-source monitoring system - with %{gitlabIntegrationLink} to monitor deployed applications.`)), - { + _.escape(s__( + `ClusterIntegration|Prometheus is an open-source monitoring system + with %{gitlabIntegrationLink} to monitor deployed applications.`, + )), { gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html" -target="_blank" rel="noopener noreferrer"> + target="_blank" rel="noopener noreferrer"> ${_.escape(s__('ClusterIntegration|GitLab Integration'))}</a>`, }, false, @@ -149,6 +156,7 @@ target="_blank" rel="noopener noreferrer"> 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" diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 49c3d184ef9..904ee5fd475 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -45,6 +45,10 @@ export default class ClusterStore { this.state.ingressHelpPath = ingressHelpPath; } + setManagePrometheusPath(managePrometheusPath) { + this.state.managePrometheusPath = managePrometheusPath; + } + updateStatus(status) { this.state.status = status; } diff --git a/app/assets/javascripts/commons/polyfills/element.js b/app/assets/javascripts/commons/polyfills/element.js index 9a1f73bf2ac..b593bde6aa2 100644 --- a/app/assets/javascripts/commons/polyfills/element.js +++ b/app/assets/javascripts/commons/polyfills/element.js @@ -18,3 +18,22 @@ Element.prototype.matches = Element.prototype.matches || while (i >= 0 && elms.item(i) !== this) { i -= 1; } return i > -1; }; + +// From the polyfill on MDN, https://developer.mozilla.org/en-US/docs/Web/API/ChildNode/remove#Polyfill +((arr) => { + arr.forEach((item) => { + if (Object.prototype.hasOwnProperty.call(item, 'remove')) { + return; + } + Object.defineProperty(item, 'remove', { + configurable: true, + enumerable: true, + writable: true, + value: function remove() { + if (this.parentNode !== null) { + this.parentNode.removeChild(this); + } + }, + }); + }); +})([Element.prototype, CharacterData.prototype, DocumentType.prototype]); diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index ab28b7d8d44..aceaffdfcb9 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -109,6 +109,21 @@ var Dispatcher; .then(callDefault) .catch(fail); break; + case 'admin:projects:index': + import('./pages/admin/projects/index/index') + .then(callDefault) + .catch(fail); + break; + case 'admin:users:index': + import('./pages/admin/users/shared') + .then(callDefault) + .catch(fail); + break; + case 'admin:users:show': + import('./pages/admin/users/shared') + .then(callDefault) + .catch(fail); + break; case 'dashboard:projects:index': case 'dashboard:projects:starred': import('./pages/dashboard/projects') diff --git a/app/assets/javascripts/filtered_search/filtered_search_bundle.js b/app/assets/javascripts/filtered_search/filtered_search_bundle.js index 6d5dd747224..293154917fa 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_bundle.js +++ b/app/assets/javascripts/filtered_search/filtered_search_bundle.js @@ -3,7 +3,6 @@ import './dropdown_hint'; import './dropdown_non_user'; import './dropdown_user'; import './dropdown_utils'; -import './filtered_search_token_keys'; import './filtered_search_dropdown_manager'; import './filtered_search_dropdown'; import './filtered_search_manager'; 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 ff046aa286a..b2add862051 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -3,11 +3,11 @@ import DropLab from '~/droplab/drop_lab'; import FilteredSearchContainer from './container'; class FilteredSearchDropdownManager { - constructor(baseEndpoint = '', tokenizer, page) { + constructor(baseEndpoint = '', tokenizer, page, isGroup, filteredSearchTokenKeys) { this.container = FilteredSearchContainer.container; this.baseEndpoint = baseEndpoint.replace(/\/$/, ''); this.tokenizer = tokenizer; - this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; + this.filteredSearchTokenKeys = filteredSearchTokenKeys; this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.page = page; @@ -29,7 +29,15 @@ class FilteredSearchDropdownManager { } setupMapping() { - this.mapping = { + const supportedTokens = this.filteredSearchTokenKeys.getKeys(); + const allowedMappings = { + hint: { + reference: null, + gl: 'DropdownHint', + element: this.container.querySelector('#js-dropdown-hint'), + }, + }; + const availableMappings = { author: { reference: null, gl: 'DropdownUser', @@ -64,12 +72,15 @@ class FilteredSearchDropdownManager { gl: 'DropdownEmoji', element: this.container.querySelector('#js-dropdown-my-reaction'), }, - hint: { - reference: null, - gl: 'DropdownHint', - element: this.container.querySelector('#js-dropdown-hint'), - }, }; + + supportedTokens.forEach((type) => { + if (availableMappings[type]) { + allowedMappings[type] = availableMappings[type]; + } + }); + + this.mapping = allowedMappings; } static addWordToInput(tokenName, tokenValue = '', clicked = false) { diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 58ed0012f01..532a5fe1090 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -3,20 +3,33 @@ import { visitUrl } from '../lib/utils/url_utility'; import Flash from '../flash'; import FilteredSearchContainer from './container'; import RecentSearchesRoot from './recent_searches_root'; +import FilteredSearchTokenKeys from './filtered_search_token_keys'; import RecentSearchesStore from './stores/recent_searches_store'; import RecentSearchesService from './services/recent_searches_service'; import eventHub from './event_hub'; import { addClassIfElementExists } from '../lib/utils/dom_utils'; class FilteredSearchManager { - constructor(page) { + constructor({ + page, + filteredSearchTokenKeys = FilteredSearchTokenKeys, + stateFiltersSelector = '.issues-state-filters', + }) { + this.isGroup = false; + this.states = ['opened', 'closed', 'merged', 'all']; + this.page = page; this.container = FilteredSearchContainer.container; this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.filteredSearchInputForm = this.filteredSearchInput.form; this.clearSearchButton = this.container.querySelector('.clear-search'); this.tokensContainer = this.container.querySelector('.tokens-container'); - this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; + this.filteredSearchTokenKeys = filteredSearchTokenKeys; + this.stateFiltersSelector = stateFiltersSelector; + this.recentsStorageKeyNames = { + issues: 'issue-recent-searches', + merge_requests: 'merge-request-recent-searches', + }; this.recentSearchesStore = new RecentSearchesStore({ isLocalStorageAvailable: RecentSearchesService.isAvailable(), @@ -25,11 +38,7 @@ class FilteredSearchManager { this.searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown'); const fullPath = this.searchHistoryDropdownElement ? this.searchHistoryDropdownElement.dataset.fullPath : 'project'; - let recentSearchesPagePrefix = 'issue-recent-searches'; - if (this.page === 'merge_requests') { - recentSearchesPagePrefix = 'merge-request-recent-searches'; - } - const recentSearchesKey = `${fullPath}-${recentSearchesPagePrefix}`; + const recentSearchesKey = `${fullPath}-${this.recentsStorageKeyNames[this.page]}`; this.recentSearchesService = new RecentSearchesService(recentSearchesKey); } @@ -58,7 +67,13 @@ class FilteredSearchManager { if (this.filteredSearchInput) { this.tokenizer = gl.FilteredSearchTokenizer; - this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, this.page); + this.dropdownManager = new gl.FilteredSearchDropdownManager( + this.filteredSearchInput.getAttribute('data-base-endpoint') || '', + this.tokenizer, + this.page, + this.isGroup, + this.filteredSearchTokenKeys, + ); this.recentSearchesRoot = new RecentSearchesRoot( this.recentSearchesStore, @@ -86,40 +101,33 @@ class FilteredSearchManager { } bindStateEvents() { - this.stateFilters = document.querySelector('.container-fluid .issues-state-filters'); + this.stateFilters = document.querySelector(`.container-fluid ${this.stateFiltersSelector}`); if (this.stateFilters) { this.searchStateWrapper = this.searchState.bind(this); - this.stateFilters.querySelector('[data-state="opened"]') - .addEventListener('click', this.searchStateWrapper); - this.stateFilters.querySelector('[data-state="closed"]') - .addEventListener('click', this.searchStateWrapper); - this.stateFilters.querySelector('[data-state="all"]') - .addEventListener('click', this.searchStateWrapper); - - this.mergedState = this.stateFilters.querySelector('[data-state="merged"]'); - if (this.mergedState) { - this.mergedState.addEventListener('click', this.searchStateWrapper); - } + this.applyToStateFilters((filterEl) => { + filterEl.addEventListener('click', this.searchStateWrapper); + }); } } unbindStateEvents() { if (this.stateFilters) { - this.stateFilters.querySelector('[data-state="opened"]') - .removeEventListener('click', this.searchStateWrapper); - this.stateFilters.querySelector('[data-state="closed"]') - .removeEventListener('click', this.searchStateWrapper); - this.stateFilters.querySelector('[data-state="all"]') - .removeEventListener('click', this.searchStateWrapper); - - if (this.mergedState) { - this.mergedState.removeEventListener('click', this.searchStateWrapper); - } + this.applyToStateFilters((filterEl) => { + filterEl.removeEventListener('click', this.searchStateWrapper); + }); } } + applyToStateFilters(callback) { + this.stateFilters.querySelectorAll('a[data-state]').forEach((filterEl) => { + if (this.states.indexOf(filterEl.dataset.state) > -1) { + callback(filterEl); + } + }); + } + bindEvents() { this.handleFormSubmit = this.handleFormSubmit.bind(this); this.setDropdownWrapper = this.dropdownManager.setDropdown.bind(this.dropdownManager); diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js index be595d7df1a..087ef5cd6f2 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js @@ -71,7 +71,7 @@ const conditions = [{ value: 'none', }]; -class FilteredSearchTokenKeys { +export default class FilteredSearchTokenKeys { static get() { return tokenKeys; } @@ -121,6 +121,3 @@ class FilteredSearchTokenKeys { .find(condition => condition.tokenKey === key && condition.value === value) || null; } } - -window.gl = window.gl || {}; -gl.FilteredSearchTokenKeys = FilteredSearchTokenKeys; diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index e035ba462db..b8f0566f48c 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -152,14 +152,14 @@ export default { showLeaveGroupModal(group, parentGroup) { this.targetGroup = group; this.targetParentGroup = parentGroup; - this.showModal = true; + this.updateModal = true; this.groupLeaveConfirmationMessage = s__(`GroupsTree|Are you sure you want to leave the "${group.fullName}" group?`); }, hideLeaveGroupModal() { - this.showModal = false; + this.updateModal = false; }, leaveGroup() { - this.showModal = false; + this.updateModal = false; this.targetGroup.isBeingRemoved = true; this.service.leaveGroup(this.targetGroup.leavePath) .then(res => res.json()) diff --git a/app/assets/javascripts/job.js b/app/assets/javascripts/job.js index d0b7ea75082..f39ae764d3c 100644 --- a/app/assets/javascripts/job.js +++ b/app/assets/javascripts/job.js @@ -217,7 +217,7 @@ export default class Job { } this.isLogComplete = log.complete; - if (!log.complete) { + if (log.complete === false) { this.timeout = setTimeout(() => { this.getBuildTrace(); }, 4000); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 5811d059e0b..7d2cf4b634f 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -1,5 +1,6 @@ import axios from './axios_utils'; import { getLocationHash } from './url_utility'; +import { convertToCamelCase } from './text_utility'; export const getPagePath = (index = 0) => $('body').attr('data-page').split(':')[index]; @@ -395,6 +396,26 @@ export const spriteIcon = (icon, className = '') => { return `<svg ${classAttribute}><use xlink:href="${gon.sprite_icons}#${icon}" /></svg>`; }; +/** + * This method takes in object with snake_case property names + * and returns new object with camelCase property names + * + * Reasoning for this method is to ensure consistent property + * naming conventions across JS code. + */ +export const convertObjectPropsToCamelCase = (obj = {}) => { + if (obj === null) { + return {}; + } + + return Object.keys(obj).reduce((acc, prop) => { + const result = acc; + + result[convertToCamelCase(prop)] = obj[prop]; + return acc; + }, {}); +}; + export const imagePath = imgUrl => `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`; window.gl = window.gl || {}; diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 1fa6715180e..d6cccbef42b 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -10,6 +10,20 @@ window.timeago = timeago; window.dateFormat = dateFormat; /** + * Returns i18n month names array. + * If `abbreviated` is provided, returns abbreviated + * name. + * + * @param {Boolean} abbreviated + */ +const getMonthNames = (abbreviated) => { + if (abbreviated) { + return [s__('Jan'), s__('Feb'), s__('Mar'), s__('Apr'), s__('May'), s__('Jun'), s__('Jul'), s__('Aug'), s__('Sep'), s__('Oct'), s__('Nov'), s__('Dec')]; + } + return [s__('January'), s__('February'), s__('March'), s__('April'), s__('May'), s__('June'), s__('July'), s__('August'), s__('September'), s__('October'), s__('November'), s__('December')]; +}; + +/** * Given a date object returns the day of the week in English * @param {date} date * @returns {String} @@ -143,7 +157,6 @@ export const getDayDifference = (a, b) => { * @param {Number} seconds * @return {String} */ -// eslint-disable-next-line import/prefer-default-export export function timeIntervalInWords(intervalInSeconds) { const secondsInteger = parseInt(intervalInSeconds, 10); const minutes = Math.floor(secondsInteger / 60); @@ -158,7 +171,7 @@ export function timeIntervalInWords(intervalInSeconds) { return text; } -export function dateInWords(date, abbreviated = false) { +export function dateInWords(date, abbreviated = false, hideYear = false) { if (!date) return date; const month = date.getMonth(); @@ -169,9 +182,115 @@ export function dateInWords(date, abbreviated = false) { const monthName = abbreviated ? monthNamesAbbr[month] : monthNames[month]; + if (hideYear) { + return `${monthName} ${date.getDate()}`; + } + return `${monthName} ${date.getDate()}, ${year}`; } +/** + * Returns month name based on provided date. + * + * @param {Date} date + * @param {Boolean} abbreviated + */ +export const monthInWords = (date, abbreviated = false) => { + if (!date) { + return ''; + } + + return getMonthNames(abbreviated)[date.getMonth()]; +}; + +/** + * Returns number of days in a month for provided date. + * courtesy: https://stacko(verflow.com/a/1185804/414749 + * + * @param {Date} date + */ +export const totalDaysInMonth = (date) => { + if (!date) { + return 0; + } + return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); +}; + +/** + * Returns list of Dates referring to Sundays of the month + * based on provided date + * + * @param {Date} date + */ +export const getSundays = (date) => { + if (!date) { + return []; + } + + const daysToSunday = ['Saturday', 'Friday', 'Thursday', 'Wednesday', 'Tuesday', 'Monday', 'Sunday']; + + const month = date.getMonth(); + const year = date.getFullYear(); + const sundays = []; + const dateOfMonth = new Date(year, month, 1); + + while (dateOfMonth.getMonth() === month) { + const dayName = getDayName(dateOfMonth); + if (dayName === 'Sunday') { + sundays.push(new Date(dateOfMonth.getTime())); + } + + const daysUntilNextSunday = daysToSunday.indexOf(dayName) + 1; + dateOfMonth.setDate(dateOfMonth.getDate() + daysUntilNextSunday); + } + + return sundays; +}; + +/** + * Returns list of Dates representing a timeframe of Months from month of provided date (inclusive) + * up to provided length + * + * For eg; + * If current month is January 2018 and `length` provided is `6` + * Then this method will return list of Date objects as follows; + * + * [ October 2017, November 2017, December 2017, January 2018, February 2018, March 2018 ] + * + * If current month is March 2018 and `length` provided is `3` + * Then this method will return list of Date objects as follows; + * + * [ February 2018, March 2018, April 2018 ] + * + * @param {Number} length + * @param {Date} date + */ +export const getTimeframeWindow = (length, date) => { + if (!length) { + return []; + } + + const currentDate = date instanceof Date ? date : new Date(); + const currentMonthIndex = Math.floor(length / 2); + const timeframe = []; + + // Move date object backward to the first month of timeframe + currentDate.setDate(1); + currentDate.setMonth(currentDate.getMonth() - currentMonthIndex); + + // Iterate and update date for the size of length + // and push date reference to timeframe list + for (let i = 0; i < length; i += 1) { + timeframe.push(new Date(currentDate.getTime())); + currentDate.setMonth(currentDate.getMonth() + 1); + } + + // Change date of last timeframe item to last date of the month + timeframe[length - 1].setDate(totalDaysInMonth(timeframe[length - 1])); + + return timeframe; +}; + window.gl = window.gl || {}; window.gl.utils = { ...(window.gl.utils || {}), diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index 625e53ee9de..bb151929431 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -6,4 +6,6 @@ export default { ABORTED: 0, NO_CONTENT: 204, OK: 200, + MULTIPLE_CHOICES: 300, + BAD_REQUEST: 400, }; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 62d80c4a649..94d03621bff 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -73,3 +73,10 @@ export function capitalizeFirstCharacter(text) { * @returns {String} */ export const stripHtml = (string, replace = '') => string.replace(/<[^>]*>/g, replace); + +/** + * Converts snake_case string to camelCase + * + * @param {*} string + */ +export const convertToCamelCase = string => string.replace(/(_\w)/g, s => s[1].toUpperCase()); diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js index 93f8f6ee926..2cb238529aa 100644 --- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js +++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js @@ -2,7 +2,9 @@ /* global ace */ import Vue from 'vue'; -import Flash from '../../flash'; +import axios from '~/lib/utils/axios_utils'; +import flash from '~/flash'; +import { __ } from '~/locale'; ((global) => { global.mergeConflicts = global.mergeConflicts || {}; @@ -49,27 +51,26 @@ import Flash from '../../flash'; loadEditor() { this.loading = true; - $.get(this.file.content_path) - .done((file) => { + axios.get(this.file.content_path) + .then(({ data }) => { const content = this.$el.querySelector('pre'); - const fileContent = document.createTextNode(file.content); + const fileContent = document.createTextNode(data.content); content.textContent = fileContent.textContent; - this.originalContent = file.content; + this.originalContent = data.content; this.fileLoaded = true; this.editor = ace.edit(content); this.editor.$blockScrolling = Infinity; // Turn off annoying warning - this.editor.getSession().setMode(`ace/mode/${file.blob_ace_mode}`); + this.editor.getSession().setMode(`ace/mode/${data.blob_ace_mode}`); this.editor.on('change', () => { this.saveDiffResolution(); }); this.saveDiffResolution(); + this.loading = false; }) - .fail(() => { - new Flash('Failed to load the file, please try again.'); - }) - .always(() => { + .catch(() => { + flash(__('An error occurred while loading the file')); this.loading = false; }); }, diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 5afae93724b..031badc7026 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -27,6 +27,7 @@ hasMetrics: convertPermissionToBoolean(metricsData.hasMetrics), documentationPath: metricsData.documentationPath, settingsPath: metricsData.settingsPath, + clustersPath: metricsData.clustersPath, tagsPath: metricsData.tagsPath, projectPath: metricsData.projectPath, metricsEndpoint: metricsData.additionalMetrics, @@ -132,6 +133,7 @@ :selected-state="state" :documentation-path="documentationPath" :settings-path="settingsPath" + :clusters-path="clustersPath" :empty-getting-started-svg-path="emptyGettingStartedSvgPath" :empty-loading-svg-path="emptyLoadingSvgPath" :empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath" diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue index 56cd60c583b..9517b8ccb67 100644 --- a/app/assets/javascripts/monitoring/components/empty_state.vue +++ b/app/assets/javascripts/monitoring/components/empty_state.vue @@ -10,6 +10,11 @@ required: false, default: '', }, + clustersPath: { + type: String, + required: false, + default: '', + }, selectedState: { type: String, required: true, @@ -35,7 +40,10 @@ title: 'Get started with performance monitoring', description: `Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments.`, - buttonText: 'Configure Prometheus', + buttonText: 'Install Prometheus on clusters', + buttonPath: this.clustersPath, + secondaryButtonText: 'Configure existing Prometheus', + secondaryButtonPath: this.settingsPath, }, loading: { svgUrl: this.emptyLoadingSvgPath, @@ -43,6 +51,7 @@ description: `Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available.`, buttonText: 'View documentation', + buttonPath: this.documentationPath, }, noData: { svgUrl: this.emptyUnableToConnectSvgPath, @@ -50,12 +59,14 @@ description: `You are connected to the Prometheus server, but there is currently no data to display.`, buttonText: 'Configure Prometheus', + buttonPath: this.settingsPath, }, unableToConnect: { svgUrl: this.emptyUnableToConnectSvgPath, title: 'Unable to connect to Prometheus server', description: 'Ensure connectivity is available from the GitLab server to the ', buttonText: 'View documentation', + buttonPath: this.documentationPath, }, }, }; @@ -65,13 +76,6 @@ return this.states[this.selectedState]; }, - buttonPath() { - if (this.selectedState === 'gettingStarted') { - return this.settingsPath; - } - return this.documentationPath; - }, - showButtonDescription() { if (this.selectedState === 'unableToConnect') return true; return false; @@ -99,11 +103,21 @@ </p> <div class="state-button"> <a + v-if="currentState.buttonPath" class="btn btn-success" - :href="buttonPath" + :href="currentState.buttonPath" > {{ currentState.buttonText }} </a> </div> + <div class="state-button"> + <a + v-if="currentState.secondaryButtonPath" + class="btn" + :href="currentState.secondaryButtonPath" + > + {{ currentState.secondaryButtonText }} + </a> + </div> </div> </template> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 30e7ccc8229..045077de383 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -102,6 +102,7 @@ .then(() => { this.isEditing = false; this.isRequesting = false; + this.oldContent = null; $(this.$refs.noteBody.$el).renderGFM(); this.$refs.noteBody.resetAutoSave(); callback(); diff --git a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue new file mode 100644 index 00000000000..14315d5492e --- /dev/null +++ b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue @@ -0,0 +1,125 @@ +<script> + import _ from 'underscore'; + import modal from '~/vue_shared/components/modal.vue'; + import { s__, sprintf } from '~/locale'; + + export default { + components: { + modal, + }, + props: { + deleteProjectUrl: { + type: String, + required: false, + default: '', + }, + projectName: { + type: String, + required: false, + default: '', + }, + csrfToken: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + enteredProjectName: '', + }; + }, + computed: { + title() { + return sprintf(s__('AdminProjects|Delete Project %{projectName}?'), + { + projectName: `'${_.escape(this.projectName)}'`, + }, + false, + ); + }, + text() { + return sprintf(s__(`AdminProjects| + You’re about to permanently delete the project %{projectName}, its repository, + and all related resources including issues, merge requests, etc.. Once you confirm and press + %{strong_start}Delete project%{strong_end}, it cannot be undone or recovered.`), + { + projectName: `<strong>${_.escape(this.projectName)}</strong>`, + strong_start: '<strong>', + strong_end: '</strong>', + }, + false, + ); + }, + confirmationTextLabel() { + return sprintf(s__('AdminUsers|To confirm, type %{projectName}'), + { + projectName: `<code>${_.escape(this.projectName)}</code>`, + }, + false, + ); + }, + primaryButtonLabel() { + return s__('AdminProjects|Delete project'); + }, + canSubmit() { + return this.enteredProjectName === this.projectName; + }, + }, + methods: { + onCancel() { + this.enteredProjectName = ''; + }, + onSubmit() { + this.$refs.form.submit(); + this.enteredProjectName = ''; + }, + }, + }; +</script> + +<template> + <modal + id="delete-project-modal" + :title="title" + :text="text" + kind="danger" + :primary-button-label="primaryButtonLabel" + :submit-disabled="!canSubmit" + @submit="onSubmit" + @cancel="onCancel" + > + <template + slot="body" + slot-scope="props" + > + <p v-html="props.text"></p> + <p v-html="confirmationTextLabel"></p> + <form + ref="form" + :action="deleteProjectUrl" + method="post" + > + <input + ref="method" + type="hidden" + name="_method" + value="delete" + /> + <input + type="hidden" + name="authenticity_token" + :value="csrfToken" + /> + <input + name="projectName" + class="form-control" + type="text" + v-model="enteredProjectName" + aria-labelledby="input-label" + autocomplete="off" + /> + </form> + </template> + </modal> +</template> diff --git a/app/assets/javascripts/pages/admin/projects/index/index.js b/app/assets/javascripts/pages/admin/projects/index/index.js new file mode 100644 index 00000000000..a87b27090a8 --- /dev/null +++ b/app/assets/javascripts/pages/admin/projects/index/index.js @@ -0,0 +1,37 @@ +import Vue from 'vue'; + +import Translate from '~/vue_shared/translate'; +import csrf from '~/lib/utils/csrf'; + +import deleteProjectModal from './components/delete_project_modal.vue'; + +export default () => { + Vue.use(Translate); + + const deleteProjectModalEl = document.getElementById('delete-project-modal'); + + const deleteModal = new Vue({ + el: deleteProjectModalEl, + data: { + deleteProjectUrl: '', + projectName: '', + }, + render(createElement) { + return createElement(deleteProjectModal, { + props: { + deleteProjectUrl: this.deleteProjectUrl, + projectName: this.projectName, + csrfToken: csrf.token, + }, + }); + }, + }); + + $(document).on('shown.bs.modal', (event) => { + if (event.relatedTarget.classList.contains('delete-project-button')) { + const buttonProps = event.relatedTarget.dataset; + deleteModal.deleteProjectUrl = buttonProps.deleteProjectUrl; + deleteModal.projectName = buttonProps.projectName; + } + }); +}; diff --git a/app/assets/javascripts/pages/admin/users/shared/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/shared/components/delete_user_modal.vue new file mode 100644 index 00000000000..7b5e333011e --- /dev/null +++ b/app/assets/javascripts/pages/admin/users/shared/components/delete_user_modal.vue @@ -0,0 +1,174 @@ +<script> + import _ from 'underscore'; + import modal from '~/vue_shared/components/modal.vue'; + import { s__, sprintf } from '~/locale'; + + export default { + components: { + modal, + }, + props: { + deleteUserUrl: { + type: String, + required: false, + default: '', + }, + blockUserUrl: { + type: String, + required: false, + default: '', + }, + deleteContributions: { + type: Boolean, + required: false, + default: false, + }, + username: { + type: String, + required: false, + default: '', + }, + csrfToken: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + enteredUsername: '', + }; + }, + computed: { + title() { + const keepContributionsTitle = s__('AdminUsers|Delete User %{username}?'); + const deleteContributionsTitle = s__('AdminUsers|Delete User %{username} and contributions?'); + + return sprintf( + this.deleteContributions ? deleteContributionsTitle : keepContributionsTitle, { + username: `'${_.escape(this.username)}'`, + }, false); + }, + text() { + const keepContributionsText = s__(`AdminArea| + You are about to permanently delete the user %{username}. + This will delete all of the issues, merge requests, and groups linked to them. + To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead. + Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered.`); + + const deleteContributionsText = s__(`AdminArea| + You are about to permanently delete the user %{username}. + Issues, merge requests, and groups linked to them will be transferred to a system-wide "Ghost-user". + To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead. + Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered.`); + + return sprintf(this.deleteContributions ? deleteContributionsText : keepContributionsText, + { + username: `<strong>${_.escape(this.username)}</strong>`, + strong_start: '<strong>', + strong_end: '</strong>', + }, + false, + ); + }, + confirmationTextLabel() { + return sprintf(s__('AdminUsers|To confirm, type %{username}'), + { + username: `<code>${_.escape(this.username)}</code>`, + }, + false, + ); + }, + primaryButtonLabel() { + const keepContributionsLabel = s__('AdminUsers|Delete user'); + const deleteContributionsLabel = s__('AdminUsers|Delete user and contributions'); + + return this.deleteContributions ? deleteContributionsLabel : keepContributionsLabel; + }, + secondaryButtonLabel() { + return s__('AdminUsers|Block user'); + }, + canSubmit() { + return this.enteredUsername === this.username; + }, + }, + methods: { + onCancel() { + this.enteredUsername = ''; + }, + onSecondaryAction() { + const form = this.$refs.form; + + form.action = this.blockUserUrl; + this.$refs.method.value = 'put'; + + form.submit(); + }, + onSubmit() { + this.$refs.form.submit(); + this.enteredUsername = ''; + }, + }, + }; +</script> + +<template> + <modal + id="delete-user-modal" + :title="title" + :text="text" + kind="danger" + :primary-button-label="primaryButtonLabel" + :secondary-button-label="secondaryButtonLabel" + :submit-disabled="!canSubmit" + @submit="onSubmit" + @cancel="onCancel" + > + <template + slot="body" + slot-scope="props" + > + <p v-html="props.text"></p> + <p v-html="confirmationTextLabel"></p> + <form + ref="form" + :action="deleteUserUrl" + method="post" + > + <input + ref="method" + type="hidden" + name="_method" + value="delete" + /> + <input + type="hidden" + name="authenticity_token" + :value="csrfToken" + /> + <input + type="text" + name="username" + class="form-control" + v-model="enteredUsername" + aria-labelledby="input-label" + autocomplete="off" + /> + </form> + </template> + <template + slot="secondary-button" + slot-scope="props" + > + <button + type="button" + class="btn js-secondary-button btn-warning" + :disabled="!canSubmit" + @click="onSecondaryAction" + data-dismiss="modal" + > + {{ secondaryButtonLabel }} + </button> + </template> + </modal> +</template> diff --git a/app/assets/javascripts/pages/admin/users/shared/index.js b/app/assets/javascripts/pages/admin/users/shared/index.js new file mode 100644 index 00000000000..d2a0f82fa2b --- /dev/null +++ b/app/assets/javascripts/pages/admin/users/shared/index.js @@ -0,0 +1,43 @@ +import Vue from 'vue'; + +import Translate from '~/vue_shared/translate'; +import csrf from '~/lib/utils/csrf'; + +import deleteUserModal from './components/delete_user_modal.vue'; + +export default () => { + Vue.use(Translate); + + const deleteUserModalEl = document.getElementById('delete-user-modal'); + + const deleteModal = new Vue({ + el: deleteUserModalEl, + data: { + deleteUserUrl: '', + blockUserUrl: '', + deleteContributions: '', + username: '', + }, + render(createElement) { + return createElement(deleteUserModal, { + props: { + deleteUserUrl: this.deleteUserUrl, + blockUserUrl: this.blockUserUrl, + deleteContributions: this.deleteContributions, + username: this.username, + csrfToken: csrf.token, + }, + }); + }, + }); + + $(document).on('shown.bs.modal', (event) => { + if (event.relatedTarget.classList.contains('delete-user-button')) { + const buttonProps = event.relatedTarget.dataset; + deleteModal.deleteUserUrl = buttonProps.deleteUserUrl; + deleteModal.blockUserUrl = buttonProps.blockUserUrl; + deleteModal.deleteContributions = event.relatedTarget.hasAttribute('data-delete-contributions'); + deleteModal.username = buttonProps.username; + } + }); +}; diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index 78db543a64d..fbdfabd1e95 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -3,6 +3,8 @@ import initFilteredSearch from '~/pages/search/init_filtered_search'; import { FILTERED_SEARCH } from '~/pages/constants'; export default () => { - initFilteredSearch(FILTERED_SEARCH.ISSUES); + initFilteredSearch({ + page: FILTERED_SEARCH.ISSUES, + }); projectSelect(); }; diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index 9b3af4537e7..f6d284bf9ef 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -3,6 +3,8 @@ import initFilteredSearch from '~/pages/search/init_filtered_search'; import { FILTERED_SEARCH } from '~/pages/constants'; export default () => { - initFilteredSearch(FILTERED_SEARCH.MERGE_REQUESTS); + initFilteredSearch({ + page: FILTERED_SEARCH.MERGE_REQUESTS, + }); projectSelect(); }; diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js index f26c7360fbe..ad79f7e09ac 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -1,11 +1,12 @@ -import SecretValues from '~/behaviors/secret_values'; +import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; export default () => { - const secretVariableTable = document.querySelector('.js-secret-variable-table'); - if (secretVariableTable) { - const secretVariableTableValues = new SecretValues({ - container: secretVariableTable, - }); - secretVariableTableValues.init(); - } + const variableListEl = document.querySelector('.js-ci-variable-list-section'); + // eslint-disable-next-line no-new + new AjaxVariableList({ + container: variableListEl, + saveButton: variableListEl.querySelector('.js-secret-variables-save-button'), + errorBox: variableListEl.querySelector('.js-ci-variable-error-box'), + saveEndpoint: variableListEl.dataset.saveEndpoint, + }); }; diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index 39c043edc38..70fdb0ef40d 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -8,7 +8,9 @@ import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; document.addEventListener('DOMContentLoaded', () => { - initFilteredSearch(FILTERED_SEARCH.ISSUES); + initFilteredSearch({ + page: FILTERED_SEARCH.ISSUES, + }); new IssuableIndex(ISSUABLE_INDEX.ISSUE); new ShortcutsNavigation(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js index adadbf28e49..a7aa616319f 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -6,7 +6,9 @@ import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; document.addEventListener('DOMContentLoaded', () => { - initFilteredSearch(FILTERED_SEARCH.MERGE_REQUESTS); + initFilteredSearch({ + page: FILTERED_SEARCH.MERGE_REQUESTS, + }); new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new new UsersSelect(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index 18dc1dc03a5..a563d0f9961 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -1,9 +1,11 @@ import initSettingsPanels from '~/settings_panels'; import SecretValues from '~/behaviors/secret_values'; +import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; export default function () { // Initialize expandable settings panels initSettingsPanels(); + const runnerToken = document.querySelector('.js-secret-runner-token'); if (runnerToken) { const runnerTokenSecretValue = new SecretValues({ @@ -12,11 +14,12 @@ export default function () { runnerTokenSecretValue.init(); } - const secretVariableTable = document.querySelector('.js-secret-variable-table'); - if (secretVariableTable) { - const secretVariableTableValues = new SecretValues({ - container: secretVariableTable, - }); - secretVariableTableValues.init(); - } + const variableListEl = document.querySelector('.js-ci-variable-list-section'); + // eslint-disable-next-line no-new + new AjaxVariableList({ + container: variableListEl, + saveButton: variableListEl.querySelector('.js-secret-variables-save-button'), + errorBox: variableListEl.querySelector('.js-ci-variable-error-box'), + saveEndpoint: variableListEl.dataset.saveEndpoint, + }); } diff --git a/app/assets/javascripts/pages/search/init_filtered_search.js b/app/assets/javascripts/pages/search/init_filtered_search.js index 44853636aea..250f9d992ab 100644 --- a/app/assets/javascripts/pages/search/init_filtered_search.js +++ b/app/assets/javascripts/pages/search/init_filtered_search.js @@ -1,7 +1,7 @@ -export default (page) => { +export default ({ page }) => { const filteredSearchEnabled = gl.FilteredSearchManager && document.querySelector('.filtered-search'); if (filteredSearchEnabled) { - const filteredSearchManager = new gl.FilteredSearchManager(page); + const filteredSearchManager = new gl.FilteredSearchManager({ page }); filteredSearchManager.setup(); } }; diff --git a/app/assets/javascripts/pipelines/components/async_button.vue b/app/assets/javascripts/pipelines/components/async_button.vue index 77553ca67cc..a5f22c4ec80 100644 --- a/app/assets/javascripts/pipelines/components/async_button.vue +++ b/app/assets/javascripts/pipelines/components/async_button.vue @@ -31,10 +31,9 @@ type: String, required: true, }, - confirmActionMessage: { - type: String, - required: false, - default: '', + id: { + type: Number, + required: true, }, }, data() { @@ -49,11 +48,10 @@ }, methods: { onClick() { - if (this.confirmActionMessage !== '' && confirm(this.confirmActionMessage)) { - this.makeRequest(); - } else if (this.confirmActionMessage === '') { - this.makeRequest(); - } + eventHub.$emit('actionConfirmationModal', { + id: this.id, + callback: this.makeRequest, + }); }, makeRequest() { this.isLoading = true; diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_table.vue index 6681b89e629..62fe479fdf4 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table.vue @@ -1,5 +1,7 @@ <script> import pipelinesTableRowComponent from './pipelines_table_row.vue'; + import stopConfirmationModal from './stop_confirmation_modal.vue'; + import retryConfirmationModal from './retry_confirmation_modal.vue'; /** * Pipelines Table Component. @@ -9,6 +11,8 @@ export default { components: { pipelinesTableRowComponent, + stopConfirmationModal, + retryConfirmationModal, }, props: { pipelines: { @@ -70,5 +74,7 @@ :auto-devops-help-path="autoDevopsHelpPath" :view-type="viewType" /> + <stop-confirmation-modal /> + <retry-confirmation-modal /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue index d0e4cf7ff40..0e3a10ed7f4 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue @@ -305,6 +305,9 @@ css-class="js-pipelines-retry-button btn-default btn-retry" title="Retry" icon="repeat" + :id="pipeline.id" + data-toggle="modal" + data-target="#retry-confirmation-modal" /> <async-button-component @@ -313,7 +316,9 @@ css-class="js-pipelines-cancel-button btn-remove" title="Cancel" icon="close" - confirm-action-message="Are you sure you want to cancel this pipeline?" + :id="pipeline.id" + data-toggle="modal" + data-target="#stop-confirmation-modal" /> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/retry_confirmation_modal.vue b/app/assets/javascripts/pipelines/components/retry_confirmation_modal.vue new file mode 100644 index 00000000000..e2ac08d67bc --- /dev/null +++ b/app/assets/javascripts/pipelines/components/retry_confirmation_modal.vue @@ -0,0 +1,65 @@ +<script> + import modal from '~/vue_shared/components/modal.vue'; + import { s__, sprintf } from '~/locale'; + import eventHub from '../event_hub'; + + export default { + components: { + modal, + }, + data() { + return { + id: '', + callback: () => {}, + }; + }, + computed: { + title() { + return sprintf(s__('Pipeline|Retry pipeline #%{id}?'), { + id: `'${this.id}'`, + }, false); + }, + text() { + return sprintf(s__('Pipeline|You’re about to retry pipeline %{id}.'), { + id: `<strong>#${this.id}</strong>`, + }, false); + }, + primaryButtonLabel() { + return s__('Pipeline|Retry pipeline'); + }, + }, + created() { + eventHub.$on('actionConfirmationModal', this.updateModal); + }, + beforeDestroy() { + eventHub.$off('actionConfirmationModal', this.updateModal); + }, + methods: { + updateModal(action) { + this.id = action.id; + this.callback = action.callback; + }, + onSubmit() { + this.callback(); + }, + }, + }; +</script> + +<template> + <modal + id="retry-confirmation-modal" + :title="title" + :text="text" + kind="danger" + :primary-button-label="primaryButtonLabel" + @submit="onSubmit" + > + <template + slot="body" + slot-scope="props" + > + <p v-html="props.text"></p> + </template> + </modal> +</template> diff --git a/app/assets/javascripts/pipelines/components/stop_confirmation_modal.vue b/app/assets/javascripts/pipelines/components/stop_confirmation_modal.vue new file mode 100644 index 00000000000..d737d567787 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/stop_confirmation_modal.vue @@ -0,0 +1,65 @@ +<script> + import modal from '~/vue_shared/components/modal.vue'; + import { s__, sprintf } from '~/locale'; + import eventHub from '../event_hub'; + + export default { + components: { + modal, + }, + data() { + return { + id: '', + callback: () => {}, + }; + }, + computed: { + title() { + return sprintf(s__('Pipeline|Stop pipeline #%{id}?'), { + id: `'${this.id}'`, + }, false); + }, + text() { + return sprintf(s__('Pipeline|You’re about to stop pipeline %{id}.'), { + id: `<strong>#${this.id}</strong>`, + }, false); + }, + primaryButtonLabel() { + return s__('Pipeline|Stop pipeline'); + }, + }, + created() { + eventHub.$on('actionConfirmationModal', this.updateModal); + }, + beforeDestroy() { + eventHub.$off('actionConfirmationModal', this.updateModal); + }, + methods: { + updateModal(action) { + this.id = action.id; + this.callback = action.callback; + }, + onSubmit() { + this.callback(); + }, + }, + }; +</script> + +<template> + <modal + id="stop-confirmation-modal" + :title="title" + :text="text" + kind="danger" + :primary-button-label="primaryButtonLabel" + @submit="onSubmit" + > + <template + slot="body" + slot-scope="props" + > + <p v-html="props.text"></p> + </template> + </modal> +</template> diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index ba4ac850346..e116ee23601 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -1,7 +1,9 @@ /* 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 Flash from '../flash'; -import { getPagePath } from '../lib/utils/common_utils'; +import { getPagePath } from '~/lib/utils/common_utils'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import flash from '../flash'; ((global) => { class Profile { @@ -57,8 +59,8 @@ import { getPagePath } from '../lib/utils/common_utils'; onUpdateNotifs(e, data) { return data.saved ? - new Flash("Notification settings saved", "notice") : - new Flash("Failed to save new settings", "alert"); + flash(__('Notification settings saved'), 'notice') : + flash(__('Failed to save new settings')); } saveForm() { @@ -70,21 +72,18 @@ import { getPagePath } from '../lib/utils/common_utils'; formData.append('user[avatar]', avatarBlob, 'avatar.png'); } - return $.ajax({ + axios({ + method: this.form.attr('method'), url: this.form.attr('action'), - type: this.form.attr('method'), data: formData, - dataType: "json", - processData: false, - contentType: false, - success: response => new Flash(response.message, 'notice'), - error: jqXHR => new Flash(jqXHR.responseJSON.message, 'alert'), - complete: () => { - window.scrollTo(0, 0); - // Enable submit button after requests ends - return self.form.find(':input[disabled]').enable(); - } - }); + }) + .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)); } setNewRepoCookie() { diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 98b524f7e3f..8f4a8704c3b 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -1,4 +1,5 @@ /* eslint-disable no-return-assign, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-unused-vars, no-cond-assign, consistent-return, object-shorthand, prefer-arrow-callback, func-names, space-before-function-paren, prefer-template, quotes, class-methods-use-this, no-sequences, wrap-iife, no-lonely-if, no-else-return, no-param-reassign, vars-on-top, max-len */ +import axios from './lib/utils/axios_utils'; import { isInGroupsPage, isInProjectPage, getGroupSlug, getProjectSlug } from './lib/utils/common_utils'; /** @@ -146,23 +147,25 @@ export default class SearchAutocomplete { this.loadingSuggestions = true; - return $.get(this.autocompletePath, { - project_id: this.projectId, - project_ref: this.projectRef, - term: term, - }, (response) => { - var firstCategory, i, lastCategory, len, suggestion; + return axios.get(this.autocompletePath, { + params: { + project_id: this.projectId, + project_ref: this.projectRef, + term: term, + }, + }).then((response) => { // Hide dropdown menu if no suggestions returns - if (!response.length) { + if (!response.data.length) { this.disableAutocomplete(); return; } const data = []; // List results - firstCategory = true; - for (i = 0, len = response.length; i < len; i += 1) { - suggestion = response[i]; + let firstCategory = true; + let lastCategory; + for (let i = 0, len = response.data.length; i < len; i += 1) { + const suggestion = response.data[i]; // Add group header before list each group if (lastCategory !== suggestion.category) { if (!firstCategory) { @@ -177,7 +180,7 @@ export default class SearchAutocomplete { lastCategory = suggestion.category; } data.push({ - id: (suggestion.category.toLowerCase()) + "-" + suggestion.id, + id: `${suggestion.category.toLowerCase()}-${suggestion.id}`, category: suggestion.category, text: suggestion.label, url: suggestion.url, @@ -187,13 +190,17 @@ export default class SearchAutocomplete { if (data.length) { data.push('separator'); data.push({ - text: "Result name contains \"" + term + "\"", - url: "/search?search=" + term + "&project_id=" + (this.projectInputEl.val()) + "&group_id=" + (this.groupInputEl.val()), + text: `Result name contains "${term}"`, + url: `/search?search=${term}&project_id=${this.projectInputEl.val()}&group_id=${this.groupInputEl.val()}`, }); } - return callback(data); - }) - .always(() => { this.loadingSuggestions = false; }); + + callback(data); + + this.loadingSuggestions = false; + }).catch(() => { + this.loadingSuggestions = false; + }); } getCategoryContents() { diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue index 02153fb86a5..8a86c409b62 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -2,6 +2,7 @@ import Flash from '../../../flash'; import editForm from './edit_form.vue'; import Icon from '../../../vue_shared/components/icon.vue'; + import { __ } from '../../../locale'; export default { components: { @@ -40,8 +41,7 @@ this.service.update('issue', { confidential }) .then(() => location.reload()) .catch(() => { - Flash(`Something went wrong trying to - change the confidentiality of this issue`); + Flash(__('Something went wrong trying to change the confidentiality of this issue')); }); }, }, @@ -58,7 +58,7 @@ /> </div> <div class="title hide-collapsed"> - Confidentiality + {{ __('Confidentiality') }} <a v-if="isEditable" class="pull-right confidential-edit" @@ -84,7 +84,7 @@ aria-hidden="true" class="sidebar-item-icon inline" /> - Not confidential + {{ __('Not confidential') }} </div> <div v-else @@ -95,7 +95,7 @@ aria-hidden="true" class="sidebar-item-icon inline is-active" /> - This issue is confidential + {{ __('This issue is confidential') }} </div> </div> </div> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue index 6a81235a1a7..c569843b05f 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue @@ -1,5 +1,6 @@ <script> import editFormButtons from './edit_form_buttons.vue'; + import { s__ } from '../../../locale'; export default { components: { @@ -19,6 +20,14 @@ type: Function, }, }, + computed: { + confidentialityOnWarning() { + return s__('confidentiality|You are going to turn on the confidentiality. This means that only team members with <strong>at least Reporter access</strong> are able to see and leave comments on the issue.'); + }, + confidentialityOffWarning() { + return s__('confidentiality|You are going to turn off the confidentiality. This means <strong>everyone</strong> will be able to see and leave a comment on this issue.'); + }, + }, }; </script> @@ -26,15 +35,13 @@ <div class="dropdown open"> <div class="dropdown-menu sidebar-item-warning-message"> <div> - <p v-if="!isConfidential"> - You are going to turn on the confidentiality. This means that only team members with - <strong>at least Reporter access</strong> - are able to see and leave comments on the issue. + <p + v-if="!isConfidential" + v-html="confidentialityOnWarning"> </p> - <p v-else> - You are going to turn off the confidentiality. This means - <strong>everyone</strong> - will be able to see and leave a comment on this issue. + <p + v-else + v-html="confidentialityOffWarning"> </p> <edit-form-buttons :is-confidential="isConfidential" diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue index 7ed0619ee6b..49d5dfeea1a 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue @@ -32,7 +32,7 @@ export default { class="btn btn-default append-right-10" @click="toggleForm" > - Cancel + {{ __('Cancel') }} </button> <button type="button" diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form.vue b/app/assets/javascripts/sidebar/components/lock/edit_form.vue index e7a87636aa7..bc32e974bc3 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form.vue @@ -1,6 +1,7 @@ <script> import editFormButtons from './edit_form_buttons.vue'; import issuableMixin from '../../../vue_shared/mixins/issuable'; + import { __, sprintf } from '../../../locale'; export default { components: { @@ -25,6 +26,14 @@ type: Function, }, }, + computed: { + lockWarning() { + return sprintf(__('Lock this %{issuableDisplayName}? Only <strong>project members</strong> will be able to comment.'), { issuableDisplayName: this.issuableDisplayName }); + }, + unlockWarning() { + return sprintf(__('Unlock this %{issuableDisplayName}? <strong>Everyone</strong> will be able to comment.'), { issuableDisplayName: this.issuableDisplayName }); + }, + }, }; </script> @@ -33,19 +42,14 @@ <div class="dropdown-menu sidebar-item-warning-message"> <p class="text" - v-if="isLocked"> - Unlock this {{ issuableDisplayName }}? - <strong>Everyone</strong> - will be able to comment. + v-if="isLocked" + v-html="unlockWarning"> </p> <p class="text" - v-else> - Lock this {{ issuableDisplayName }}? - Only - <strong>project members</strong> - will be able to comment. + v-else + v-html="lockWarning"> </p> <edit-form-buttons diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue index 02876a6c175..3a344c89299 100644 --- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue @@ -72,7 +72,7 @@ </div> <div class="title hide-collapsed"> - Lock {{ issuableDisplayName }} + {{ sprintf(__('Lock %{issuableDisplayName}'), { issuableDisplayName: issuableDisplayName }) }} <button v-if="isEditable" class="pull-right lock-edit btn btn-blank" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js index fd0d4570d68..b5ebccd3795 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js @@ -68,7 +68,7 @@ export default { <div class="compare-display-container"> <div class="compare-display pull-left"> <span class="compare-label"> - Spent + {{ s__('TimeTracking|Spent') }} </span> <span class="compare-value spent"> {{ timeSpentHumanReadable }} @@ -76,7 +76,7 @@ export default { </div> <div class="compare-display estimated pull-right"> <span class="compare-label"> - Est + {{ s__('TimeTrackingEstimated|Est') }} </span> <span class="compare-value"> {{ timeEstimateHumanReadable }} diff --git a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js index ad1b9179db0..2d324c71379 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js @@ -9,7 +9,7 @@ export default { template: ` <div class="time-tracking-estimate-only-pane"> <span class="bold"> - Estimated: + {{ s__('TimeTracking|Estimated:') }} </span> {{ timeEstimateHumanReadable }} </div> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js index 142ad437509..19f74ad3c6d 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js @@ -1,3 +1,5 @@ +import { sprintf, s__ } from '../../../locale'; + export default { name: 'time-tracking-help-state', props: { @@ -10,33 +12,39 @@ export default { href() { return `${this.rootPath}help/workflow/time_tracking.md`; }, + estimateText() { + return sprintf( + s__('estimateCommand|%{slash_command} will update the estimated time with the latest command.'), { + slash_command: '<code>/estimate</code>', + }, false, + ); + }, + spendText() { + return sprintf( + s__('spendCommand|%{slash_command} will update the sum of the time spent.'), { + slash_command: '<code>/spend</code>', + }, false, + ); + }, }, template: ` <div class="time-tracking-help-state"> <div class="time-tracking-info"> <h4> - Track time with quick actions + {{ __('Track time with quick actions') }} </h4> <p> - Quick actions can be used in the issues description and comment boxes. + {{ __('Quick actions can be used in the issues description and comment boxes.') }} </p> - <p> - <code> - /estimate - </code> - will update the estimated time with the latest command. + <p v-html="estimateText"> </p> - <p> - <code> - /spend - </code> - will update the sum of the time spent. + <p v-html="spendText"> </p> <a class="btn btn-default learn-more-button" :href="href" > - Learn more + {{ __('Learn more') }} </a> </div> </div> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js index d1dd1dcdd27..38da76c6771 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js @@ -3,7 +3,7 @@ export default { template: ` <div class="time-tracking-no-tracking-pane"> <span class="no-value"> - No estimate or time spent + {{ __('No estimate or time spent') }} </span> </div> `, diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js index ed0d71a4f79..866178e2b23 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js @@ -110,7 +110,7 @@ export default { :time-estimate-human-readable="timeEstimateHumanReadable" /> <div class="title hide-collapsed"> - Time tracking + {{ __('Time tracking') }} <div class="help-button pull-right" v-if="!showHelpState" diff --git a/app/assets/javascripts/vue_shared/components/modal.vue b/app/assets/javascripts/vue_shared/components/modal.vue index 8227428d8ba..5f1364421aa 100644 --- a/app/assets/javascripts/vue_shared/components/modal.vue +++ b/app/assets/javascripts/vue_shared/components/modal.vue @@ -46,6 +46,11 @@ required: false, default: '', }, + secondaryButtonLabel: { + type: String, + required: false, + default: '', + }, submitDisabled: { type: Boolean, required: false, @@ -129,6 +134,21 @@ > {{ closeButtonLabel }} </button> + + <slot + v-if="secondaryButtonLabel" + name="secondary-button" + > + <button + v-if="secondaryButtonLabel" + type="button" + class="btn" + data-dismiss="modal" + > + {{ secondaryButtonLabel }} + </button> + </slot> + <button v-if="primaryButtonLabel" type="button" diff --git a/app/assets/stylesheets/framework/ci_variable_list.scss b/app/assets/stylesheets/framework/ci_variable_list.scss index 8f654ab363c..5fe835dd8f9 100644 --- a/app/assets/stylesheets/framework/ci_variable_list.scss +++ b/app/assets/stylesheets/framework/ci_variable_list.scss @@ -8,7 +8,11 @@ .ci-variable-row { display: flex; - align-items: flex-end; + align-items: flex-start; + + @media (max-width: $screen-xs-max) { + align-items: flex-end; + } &:not(:last-child) { margin-bottom: $gl-btn-padding; @@ -41,6 +45,7 @@ .ci-variable-row-body { display: flex; + align-items: flex-start; width: 100%; @media (max-width: $screen-xs-max) { @@ -65,6 +70,8 @@ flex: 0 1 auto; display: flex; align-items: center; + padding-top: 5px; + padding-bottom: 5px; } .ci-variable-row-remove-button { @@ -85,4 +92,8 @@ outline: none; color: $gl-text-color; } + + &[disabled] { + color: $gl-text-color-disabled; + } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 0d21a9f5f77..25ee081ea9c 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -214,6 +214,7 @@ $tooltip-font-size: 12px; * Padding */ $gl-padding: 16px; +$gl-padding-8: 8px; $gl-col-padding: 15px; $gl-btn-padding: 10px; $gl-input-padding: 10px; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 794bc668562..4eba05a492d 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -205,7 +205,7 @@ } .prometheus-state { - max-width: 430px; + max-width: 460px; margin: 10px auto; text-align: center; @@ -213,6 +213,10 @@ max-width: 80vw; margin: 0 auto; } + + .state-button { + padding: $gl-padding / 2; + } } .environments-actions { diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 6353482ede7..47672783d5a 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -135,6 +135,17 @@ padding-top: 0; } +.integration-settings-form { + .well { + padding: $gl-padding / 2; + box-shadow: none; + } + + .svg-container { + max-width: 150px; + } +} + .token-token-container { #impersonation-token-token { width: 80%; diff --git a/app/controllers/ci/lints_controller.rb b/app/controllers/ci/lints_controller.rb index be667687c18..e9bd1689a1e 100644 --- a/app/controllers/ci/lints_controller.rb +++ b/app/controllers/ci/lints_controller.rb @@ -16,10 +16,7 @@ module Ci @builds = @config_processor.builds @jobs = @config_processor.jobs end - rescue - @error = 'Undefined error' - @status = false - ensure + render :show end end diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb index 4311f9d4db9..5e4e8a87153 100644 --- a/app/controllers/concerns/lfs_request.rb +++ b/app/controllers/concerns/lfs_request.rb @@ -10,6 +10,8 @@ module LfsRequest extend ActiveSupport::Concern + CONTENT_TYPE = 'application/vnd.git-lfs+json'.freeze + included do before_action :require_lfs_enabled! before_action :lfs_check_access! @@ -50,7 +52,7 @@ module LfsRequest message: 'Access forbidden. Check your access level.', documentation_url: help_url }, - content_type: "application/vnd.git-lfs+json", + content_type: CONTENT_TYPE, status: 403 ) end @@ -61,7 +63,7 @@ module LfsRequest message: 'Not found.', documentation_url: help_url }, - content_type: "application/vnd.git-lfs+json", + content_type: CONTENT_TYPE, status: 404 ) end diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index 3d61458c064..c1acb50b76c 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -32,6 +32,7 @@ module ServiceParams :issues_events, :issues_url, :jira_issue_transition_id, + :manual_configuration, :merge_requests_events, :mock_service_url, :namespace, diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb index 10038ff3ad9..913e13bf734 100644 --- a/app/controllers/groups/variables_controller.rb +++ b/app/controllers/groups/variables_controller.rb @@ -1,60 +1,43 @@ module Groups class VariablesController < Groups::ApplicationController - before_action :variable, only: [:show, :update, :destroy] before_action :authorize_admin_build! - def index - redirect_to group_settings_ci_cd_path(group) - end - def show + respond_to do |format| + format.json do + render status: :ok, json: { variables: GroupVariableSerializer.new.represent(@group.variables) } + end + end end def update - if variable.update(variable_params) - redirect_to group_variables_path(group), - notice: 'Variable was successfully updated.' + if @group.update(group_variables_params) + respond_to do |format| + format.json { return render_group_variables } + end else - render "show" + respond_to do |format| + format.json { render_error } + end end end - def create - @variable = group.variables.create(variable_params) - .present(current_user: current_user) + private - if @variable.persisted? - redirect_to group_settings_ci_cd_path(group), - notice: 'Variable was successfully created.' - else - render "show" - end + def render_group_variables + render status: :ok, json: { variables: GroupVariableSerializer.new.represent(@group.variables) } end - def destroy - if variable.destroy - redirect_to group_settings_ci_cd_path(group), - status: 302, - notice: 'Variable was successfully removed.' - else - redirect_to group_settings_ci_cd_path(group), - status: 302, - notice: 'Failed to remove the variable.' - end + def render_error + render status: :bad_request, json: @group.errors.full_messages end - private - - def variable_params - params.require(:variable).permit(*variable_params_attributes) + def group_variables_params + params.permit(variables_attributes: [*variable_params_attributes]) end def variable_params_attributes - %i[key value protected] - end - - def variable - @variable ||= group.variables.find(params[:id]).present(current_user: current_user) + %i[id key value protected _destroy] end def authorize_admin_build! diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index 71ae60cb8cd..45910a9be44 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -5,6 +5,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403 rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404 + rescue_from Gitlab::GitAccess::ProjectCreationError, with: :render_422 # GET /foo/bar.git/info/refs?service=git-upload-pack (git pull) # GET /foo/bar.git/info/refs?service=git-receive-pack (git push) @@ -55,8 +56,15 @@ class Projects::GitHttpController < Projects::GitHttpClientController render plain: exception.message, status: :not_found end + def render_422(exception) + render plain: exception.message, status: :unprocessable_entity + end + def access - @access ||= access_klass.new(access_actor, project, 'http', authentication_abilities: authentication_abilities, redirected_path: redirected_path) + @access ||= access_klass.new(access_actor, project, + 'http', authentication_abilities: authentication_abilities, + namespace_path: params[:namespace_id], project_path: project_path, + redirected_path: redirected_path) end def access_actor @@ -68,12 +76,17 @@ class Projects::GitHttpController < Projects::GitHttpClientController # Use the magic string '_any' to indicate we do not know what the # changes are. This is also what gitlab-shell does. access.check(git_command, '_any') + @project ||= access.project end def access_klass @access_klass ||= wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess end + def project_path + @project_path ||= params[:project_id].sub(/\.git$/, '') + end + def log_user_activity Users::ActivityService.new(user, 'pull').execute end diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb index 536f908d2c5..c77f10ef1dd 100644 --- a/app/controllers/projects/lfs_api_controller.rb +++ b/app/controllers/projects/lfs_api_controller.rb @@ -98,7 +98,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController json: { message: lfs_read_only_message }, - content_type: 'application/vnd.git-lfs+json', + content_type: LfsRequest::CONTENT_TYPE, status: 403 ) end diff --git a/app/controllers/projects/lfs_locks_api_controller.rb b/app/controllers/projects/lfs_locks_api_controller.rb new file mode 100644 index 00000000000..3fff0fd69ae --- /dev/null +++ b/app/controllers/projects/lfs_locks_api_controller.rb @@ -0,0 +1,70 @@ +class Projects::LfsLocksApiController < Projects::GitHttpClientController + include LfsRequest + + def create + @result = Lfs::LockFileService.new(project, user, params).execute + + render_json(@result[:lock]) + end + + def unlock + @result = Lfs::UnlockFileService.new(project, user, params).execute + + render_json(@result[:lock]) + end + + def index + @result = Lfs::LocksFinderService.new(project, user, params).execute + + render_json(@result[:locks]) + end + + def verify + @result = Lfs::LocksFinderService.new(project, user, {}).execute + + ours, theirs = split_by_owner(@result[:locks]) + + render_json({ ours: ours, theirs: theirs }, false) + end + + private + + def render_json(data, process = true) + render json: build_payload(data, process), + content_type: LfsRequest::CONTENT_TYPE, + status: @result[:http_status] + end + + def build_payload(data, process) + data = LfsFileLockSerializer.new.represent(data) if process + + return data if @result[:status] == :success + + # When the locking failed due to an existent Lock, the existent record + # is returned in `@result[:lock]` + error_payload(@result[:message], @result[:lock] ? data : {}) + end + + def error_payload(message, custom_attrs = {}) + custom_attrs.merge({ + message: message, + documentation_url: help_url + }) + end + + def split_by_owner(locks) + groups = locks.partition { |lock| lock.user_id == user.id } + + groups.map! do |records| + LfsFileLockSerializer.new.represent(records, root: false) + end + end + + def download_request? + params[:action] == 'index' + end + + def upload_request? + %w(create unlock verify).include?(params[:action]) + end +end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 8af4e379f0a..8eed957d9fe 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -50,10 +50,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo set_pipeline_variables - # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37432 - Gitlab::GitalyClient.allow_n_plus_1_calls do - render - end + render end format.json do diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb index 6a825137564..7eb509e2e64 100644 --- a/app/controllers/projects/variables_controller.rb +++ b/app/controllers/projects/variables_controller.rb @@ -1,60 +1,41 @@ class Projects::VariablesController < Projects::ApplicationController - before_action :variable, only: [:show, :update, :destroy] before_action :authorize_admin_build! - layout 'project_settings' - - def index - redirect_to project_settings_ci_cd_path(@project) - end - def show + respond_to do |format| + format.json do + render status: :ok, json: { variables: VariableSerializer.new.represent(@project.variables) } + end + end end def update - if variable.update(variable_params) - redirect_to project_variables_path(project), - notice: 'Variable was successfully updated.' + if @project.update(variables_params) + respond_to do |format| + format.json { return render_variables } + end else - render "show" + respond_to do |format| + format.json { render_error } + end end end - def create - @variable = project.variables.create(variable_params) - .present(current_user: current_user) + private - if @variable.persisted? - redirect_to project_settings_ci_cd_path(project), - notice: 'Variable was successfully created.' - else - render "show" - end + def render_variables + render status: :ok, json: { variables: VariableSerializer.new.represent(@project.variables) } end - def destroy - if variable.destroy - redirect_to project_settings_ci_cd_path(project), - status: 302, - notice: 'Variable was successfully removed.' - else - redirect_to project_settings_ci_cd_path(project), - status: 302, - notice: 'Failed to remove the variable.' - end + def render_error + render status: :bad_request, json: @project.errors.full_messages end - private - - def variable_params - params.require(:variable).permit(*variable_params_attributes) + def variables_params + params.permit(variables_attributes: [*variable_params_attributes]) end def variable_params_attributes %i[id key value protected _destroy] end - - def variable - @variable ||= project.variables.find(params[:id]).present(current_user: current_user) - end end diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb index c04f61de79c..4450766485f 100644 --- a/app/finders/snippets_finder.rb +++ b/app/finders/snippets_finder.rb @@ -28,7 +28,7 @@ class SnippetsFinder < UnionFinder segments << items.public_to_user(current_user) segments << authorized_to_user(items) if current_user - find_union(segments, Snippet) + find_union(segments, Snippet.includes(:author)) end def authorized_to_user(items) diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 7548bc30247..e293b3ef329 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -146,6 +146,7 @@ module ApplicationSettingsHelper :akismet_enabled, :authorized_keys_enabled, :auto_devops_enabled, + :auto_devops_domain, :circuitbreaker_access_retries, :circuitbreaker_check_interval, :circuitbreaker_failure_count_threshold, diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb index d72457efec0..16451993e93 100644 --- a/app/helpers/auto_devops_helper.rb +++ b/app/helpers/auto_devops_helper.rb @@ -9,21 +9,28 @@ module AutoDevopsHelper end def auto_devops_warning_message(project) - missing_domain = !project.auto_devops&.has_domain? - missing_service = !project.deployment_platform&.active? - - if missing_service + if missing_auto_devops_service?(project) params = { kubernetes: link_to('Kubernetes cluster', project_clusters_path(project)) } - if missing_domain + if missing_auto_devops_domain?(project) _('Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly.') % params else _('Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly.') % params end - elsif missing_domain + elsif missing_auto_devops_domain?(project) _('Auto Review Apps and Auto Deploy need a domain name to work correctly.') end end + + private + + def missing_auto_devops_domain?(project) + !(project.auto_devops || project.build_auto_devops)&.has_domain? + end + + def missing_auto_devops_service?(project) + !project.deployment_platform&.active? + end end diff --git a/app/helpers/graph_helper.rb b/app/helpers/graph_helper.rb index 6d303ba857d..1022070ab6f 100644 --- a/app/helpers/graph_helper.rb +++ b/app/helpers/graph_helper.rb @@ -1,10 +1,6 @@ module GraphHelper - def get_refs(repo, commit) - refs = "" - # Commit::ref_names already strips the refs/XXX from important refs (e.g. refs/heads/XXX) - # so anything leftover is internally used by GitLab - commit_refs = commit.ref_names(repo).reject { |name| name.starts_with?('refs/') } - refs << commit_refs.join(' ') + def refs(repo, commit) + refs = commit.ref_names(repo).join(' ') # append note count notes_count = @graph.notes[commit.id] diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 80bda7f22ff..0dee6df525d 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -117,6 +117,11 @@ class ApplicationSetting < ActiveRecord::Base validates :repository_storages, presence: true validate :check_repository_storages + validates :auto_devops_domain, + allow_blank: true, + hostname: { allow_numeric_hostname: true, require_valid_tld: true }, + if: :auto_devops_enabled? + validates :enabled_git_access_protocol, inclusion: { in: %w(ssh http), allow_blank: true, allow_nil: true } diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index f84bf132854..2abe90dd181 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -394,7 +394,7 @@ module Ci @config_processor ||= begin Gitlab::Ci::YamlProcessor.new(ci_yaml_file) - rescue Gitlab::Ci::YamlProcessor::ValidationError, Psych::SyntaxError => e + rescue Gitlab::Ci::YamlProcessor::ValidationError => e self.yaml_errors = e.message nil rescue diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index dcbb397fb78..13c784bea0d 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -2,9 +2,11 @@ module Ci class Runner < ActiveRecord::Base extend Gitlab::Ci::Model include Gitlab::SQL::Pattern + include RedisCacheable RUNNER_QUEUE_EXPIRY_TIME = 60.minutes ONLINE_CONTACT_TIMEOUT = 1.hour + UPDATE_DB_RUNNER_INFO_EVERY = 40.minutes AVAILABLE_SCOPES = %w[specific shared active paused online].freeze FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level].freeze @@ -47,6 +49,8 @@ module Ci ref_protected: 1 } + cached_attr_reader :version, :revision, :platform, :architecture, :contacted_at + # Searches for runners matching the given query. # # This method uses ILIKE on PostgreSQL and LIKE on MySQL. @@ -152,6 +156,18 @@ module Ci ensure_runner_queue_value == value if value.present? end + def update_cached_info(values) + values = values&.slice(:version, :revision, :platform, :architecture) || {} + values[:contacted_at] = Time.now + + cache_attributes(values) + + if persist_cached_data? + self.assign_attributes(values) + self.save if self.changed? + end + end + private def cleanup_runner_queue @@ -164,6 +180,17 @@ module Ci "runner:build_queue:#{self.token}" end + def persist_cached_data? + # Use a random threshold to prevent beating DB updates. + # It generates a distribution between [40m, 80m]. + + contacted_at_max_age = UPDATE_DB_RUNNER_INFO_EVERY + Random.rand(UPDATE_DB_RUNNER_INFO_EVERY) + + real_contacted_at = read_attribute(:contacted_at) + real_contacted_at.nil? || + (Time.now - real_contacted_at) >= contacted_at_max_age + end + def tag_constraints unless has_tags? || run_untagged? errors.add(:tags_list, diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index 9b0787ee6ca..aa22e9d5d58 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -10,10 +10,26 @@ module Clusters default_value_for :version, VERSION + state_machine :status do + after_transition any => [:installed] do |application| + application.cluster.projects.each do |project| + project.find_or_initialize_service('prometheus').update(active: true) + end + end + end + def chart 'stable/prometheus' end + def service_name + 'prometheus-prometheus-server' + end + + def service_port + 80 + end + def chart_values_file "#{Rails.root}/vendor/#{name}/values.yaml" end @@ -21,6 +37,22 @@ module Clusters def install_command Gitlab::Kubernetes::Helm::InstallCommand.new(name, chart: chart, chart_values_file: chart_values_file) end + + def proxy_client + return unless kube_client + + proxy_url = kube_client.proxy_url('service', service_name, service_port, Gitlab::Kubernetes::Helm::NAMESPACE) + + # ensures headers containing auth data are appended to original k8s client options + options = kube_client.rest_client.options.merge(headers: kube_client.headers) + RestClient::Resource.new(proxy_url, options) + end + + private + + def kube_client + cluster&.kubeclient + end end end end diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 5ecbd4cbceb..8678f70f78c 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -49,6 +49,9 @@ module Clusters scope :enabled, -> { where(enabled: true) } scope :disabled, -> { where(enabled: false) } + scope :for_environment, -> (env) { where(environment_scope: ['*', '', env.slug]) } + scope :for_all_environments, -> { where(environment_scope: ['*', '']) } + def status_name if provider provider.status_name diff --git a/app/models/concerns/redis_cacheable.rb b/app/models/concerns/redis_cacheable.rb new file mode 100644 index 00000000000..b889f4202dc --- /dev/null +++ b/app/models/concerns/redis_cacheable.rb @@ -0,0 +1,41 @@ +module RedisCacheable + extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + + CACHED_ATTRIBUTES_EXPIRY_TIME = 24.hours + + class_methods do + def cached_attr_reader(*attributes) + attributes.each do |attribute| + define_method("#{attribute}") do + cached_attribute(attribute) || read_attribute(attribute) + end + end + end + end + + def cached_attribute(attribute) + (cached_attributes || {})[attribute] + end + + def cache_attributes(values) + Gitlab::Redis::SharedState.with do |redis| + redis.set(cache_attribute_key, values.to_json, ex: CACHED_ATTRIBUTES_EXPIRY_TIME) + end + end + + private + + def cache_attribute_key + "cache:#{self.class.name}:#{self.id}:attributes" + end + + def cached_attributes + strong_memoize(:cached_attributes) do + Gitlab::Redis::SharedState.with do |redis| + data = redis.get(cache_attribute_key) + JSON.parse(data, symbolize_names: true) if data + end + end + end +end diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 5c1cce98ad4..dfd7d94450b 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -7,11 +7,12 @@ module Routable has_one :route, as: :source, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :redirect_routes, as: :source, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - validates_associated :route validates :route, presence: true scope :with_route, -> { includes(:route) } + after_validation :set_path_errors + before_validation do if full_path_changed? || full_name_changed? prepare_route @@ -125,6 +126,11 @@ module Routable private + def set_path_errors + route_path_errors = self.errors.delete(:"route.path") + self.errors[:path].concat(route_path_errors) if route_path_errors + end + def uncached_full_path if route && route.path.present? @full_path ||= route.path # rubocop:disable Gitlab/ModuleWithInstanceVariables diff --git a/app/models/group.rb b/app/models/group.rb index 5b7f1b38612..75bf013ecd2 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -31,9 +31,12 @@ class Group < Namespace has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + accepts_nested_attributes_for :variables, allow_destroy: true + validate :visibility_level_allowed_by_projects validate :visibility_level_allowed_by_sub_groups validate :visibility_level_allowed_by_parent + validates :variables, variable_duplicates: true validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 } diff --git a/app/models/lfs_file_lock.rb b/app/models/lfs_file_lock.rb new file mode 100644 index 00000000000..50bb6ca382d --- /dev/null +++ b/app/models/lfs_file_lock.rb @@ -0,0 +1,12 @@ +class LfsFileLock < ActiveRecord::Base + belongs_to :project + belongs_to :user + + validates :project_id, :user_id, :path, presence: true + + def can_be_unlocked_by?(current_user, forced = false) + return true if current_user.id == user_id + + forced && current_user.can?(:admin_project, project) + end +end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 69a846da9be..c1c27ccf3e5 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -290,7 +290,7 @@ class MergeRequestDiff < ActiveRecord::Base end def keep_around_commits - [repository, merge_request.source_project.repository].each do |repo| + [repository, merge_request.source_project.repository].uniq.each do |repo| repo.keep_around(start_commit_sha) repo.keep_around(head_commit_sha) repo.keep_around(base_commit_sha) diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 06655298950..d95489ee9f2 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -20,6 +20,9 @@ class Namespace < ActiveRecord::Base has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :project_statistics + + # This should _not_ be `inverse_of: :namespace`, because that would also set + # `user.namespace` when this user creates a group with themselves as `owner`. belongs_to :owner, class_name: "User" belongs_to :parent, class_name: "Namespace" @@ -29,7 +32,6 @@ class Namespace < ActiveRecord::Base validates :owner, presence: true, unless: ->(n) { n.type == "Group" } validates :name, presence: true, - uniqueness: { scope: :parent_id }, length: { maximum: 255 }, namespace_name: true diff --git a/app/models/project.rb b/app/models/project.rb index 33085151217..0590cc1c720 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -179,6 +179,7 @@ class Project < ActiveRecord::Base has_many :releases has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :lfs_objects, through: :lfs_objects_projects + has_many :lfs_file_locks has_many :project_group_links has_many :invited_groups, through: :project_group_links, source: :group has_many :pages_domains @@ -245,8 +246,7 @@ class Project < ActiveRecord::Base validates :path, presence: true, project_path: true, - length: { maximum: 255 }, - uniqueness: { scope: :namespace_id } + length: { maximum: 255 } validates :namespace, presence: true validates :name, uniqueness: { scope: :namespace_id } @@ -261,6 +261,7 @@ class Project < ActiveRecord::Base validates :repository_storage, presence: true, inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } } + validates :variables, variable_duplicates: true has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -1601,7 +1602,7 @@ class Project < ActiveRecord::Base def auto_devops_variables return [] unless auto_devops_enabled? - auto_devops&.variables || [] + (auto_devops || build_auto_devops)&.variables end def append_or_update_attribute(name, value) diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb index 9a52edbff8e..112ed7ed434 100644 --- a/app/models/project_auto_devops.rb +++ b/app/models/project_auto_devops.rb @@ -6,13 +6,17 @@ class ProjectAutoDevops < ActiveRecord::Base validates :domain, allow_blank: true, hostname: { allow_numeric_hostname: true } + def instance_domain + Gitlab::CurrentSettings.auto_devops_domain + end + def has_domain? - domain.present? + domain.present? || instance_domain.present? end def variables variables = [] - variables << { key: 'AUTO_DEVOPS_DOMAIN', value: domain, public: true } if domain.present? + variables << { key: 'AUTO_DEVOPS_DOMAIN', value: domain.presence || instance_domain, public: true } if has_domain? variables end end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index fa7b3f2bcaf..1bb576ff971 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -7,11 +7,14 @@ class PrometheusService < MonitoringService # Access to prometheus is directly through the API prop_accessor :api_url + boolean_accessor :manual_configuration - with_options presence: true, if: :activated? do + with_options presence: true, if: :manual_configuration? do validates :api_url, url: true end + before_save :synchronize_service_state! + after_save :clear_reactive_cache! def initialize_properties @@ -20,12 +23,20 @@ class PrometheusService < MonitoringService end end + def show_active_box? + false + end + + def editable? + manual_configuration? || !prometheus_installed? + end + def title 'Prometheus' end def description - s_('PrometheusService|Prometheus monitoring') + s_('PrometheusService|Time-series monitoring service') end def self.to_param @@ -33,8 +44,16 @@ class PrometheusService < MonitoringService end def fields + return [] unless editable? + [ { + type: 'checkbox', + name: 'manual_configuration', + title: s_('PrometheusService|Active'), + required: true + }, + { type: 'text', name: 'api_url', title: 'API URL', @@ -59,7 +78,7 @@ class PrometheusService < MonitoringService end def deployment_metrics(deployment) - metrics = with_reactive_cache(Gitlab::Prometheus::Queries::DeploymentQuery.name, deployment.id, &method(:rename_data_to_metrics)) + metrics = with_reactive_cache(Gitlab::Prometheus::Queries::DeploymentQuery.name, deployment.environment.id, deployment.id, &method(:rename_data_to_metrics)) metrics&.merge(deployment_time: deployment.created_at.to_i) || {} end @@ -68,7 +87,7 @@ class PrometheusService < MonitoringService end def additional_deployment_metrics(deployment) - with_reactive_cache(Gitlab::Prometheus::Queries::AdditionalMetricsDeploymentQuery.name, deployment.id, &:itself) + with_reactive_cache(Gitlab::Prometheus::Queries::AdditionalMetricsDeploymentQuery.name, deployment.environment.id, deployment.id, &:itself) end def matched_metrics @@ -79,6 +98,9 @@ class PrometheusService < MonitoringService def calculate_reactive_cache(query_class_name, *args) return unless active? && project && !project.pending_delete? + environment_id = args.first + client = client(environment_id) + data = Kernel.const_get(query_class_name).new(client).query(*args) { success: true, @@ -89,14 +111,55 @@ class PrometheusService < MonitoringService { success: false, result: err.message } end - def client - @prometheus ||= Gitlab::PrometheusClient.new(api_url: api_url) + def client(environment_id = nil) + if manual_configuration? + Gitlab::PrometheusClient.new(RestClient::Resource.new(api_url)) + else + cluster = cluster_with_prometheus(environment_id) + raise Gitlab::PrometheusError, "couldn't find cluster with Prometheus installed" unless cluster + + rest_client = client_from_cluster(cluster) + raise Gitlab::PrometheusError, "couldn't create proxy Prometheus client" unless rest_client + + Gitlab::PrometheusClient.new(rest_client) + end + end + + def prometheus_installed? + return false if template? + return false unless project + + project.clusters.enabled.any? { |cluster| cluster.application_prometheus&.installed? } end private + def cluster_with_prometheus(environment_id = nil) + clusters = if environment_id + ::Environment.find_by(id: environment_id).try do |env| + # sort results by descending order based on environment_scope being longer + # thus more closely matching environment slug + project.clusters.enabled.for_environment(env).sort_by { |c| c.environment_scope&.length }.reverse! + end + else + project.clusters.enabled.for_all_environments + end + + clusters&.detect { |cluster| cluster.application_prometheus&.installed? } + end + + def client_from_cluster(cluster) + cluster.application_prometheus.proxy_client + end + def rename_data_to_metrics(metrics) metrics[:metrics] = metrics.delete :data metrics end + + def synchronize_service_state! + self.active = prometheus_installed? || manual_configuration? + + true + end end diff --git a/app/models/repository.rb b/app/models/repository.rb index 3d6f8f0c305..1cf55fd4332 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -164,6 +164,13 @@ class Repository commits end + # Returns a list of commits that are not present in any reference + def new_commits(newrev) + refs = ::Gitlab::Git::RevList.new(raw, newrev: newrev).new_refs + + refs.map { |sha| commit(sha.strip) } + end + # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/384 def find_commits_by_message(query, ref = nil, path = nil, limit = 1000, offset = 0) unless exists? && has_visible_content? && query.present? diff --git a/app/models/route.rb b/app/models/route.rb index 3d4b5a8b5ee..07d96c21cf1 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -75,7 +75,7 @@ class Route < ActiveRecord::Base def ensure_permanent_paths return if path.nil? - errors.add(:path, "#{path} has been taken before. Please use another one") if conflicting_redirect_exists? + errors.add(:path, "has been taken before") if conflicting_redirect_exists? end def conflicting_redirect_exists? diff --git a/app/models/user.rb b/app/models/user.rb index 4996cea718c..05c93d3cb17 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -77,7 +77,7 @@ class User < ActiveRecord::Base # # Namespace for personal projects - has_one :namespace, -> { where(type: nil) }, dependent: :destroy, foreign_key: :owner_id, autosave: true # rubocop:disable Cop/ActiveRecordDependent + has_one :namespace, -> { where(type: nil) }, dependent: :destroy, foreign_key: :owner_id, inverse_of: :owner, autosave: true # rubocop:disable Cop/ActiveRecordDependent # Profile has_many :keys, -> do @@ -151,12 +151,9 @@ class User < ActiveRecord::Base validates :projects_limit, presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE } - validates :username, - user_path: true, - presence: true, - uniqueness: { case_sensitive: false } + validates :username, presence: true - validate :namespace_uniq, if: :username_changed? + validates :namespace, presence: true validate :namespace_move_dir_allowed, if: :username_changed? validate :unique_email, if: :email_changed? @@ -171,7 +168,8 @@ class User < ActiveRecord::Base before_save :ensure_user_rights_and_limits, if: ->(user) { user.new_record? || user.external_changed? } before_save :skip_reconfirmation!, if: ->(user) { user.email_changed? && user.read_only_attribute?(:email) } before_save :check_for_verified_email, if: ->(user) { user.email_changed? && !user.new_record? } - after_save :ensure_namespace_correct + before_validation :ensure_namespace_correct + after_validation :set_username_errors after_update :username_changed_hook, if: :username_changed? after_destroy :post_destroy_hook after_destroy :remove_key_cache @@ -505,17 +503,6 @@ class User < ActiveRecord::Base end end - def namespace_uniq - # Return early if username already failed the first uniqueness validation - return if errors.key?(:username) && - errors[:username].include?('has already been taken') - - existing_namespace = Namespace.by_path(username) - if existing_namespace && existing_namespace != namespace - errors.add(:username, 'has already been taken') - end - end - def namespace_move_dir_allowed if namespace&.any_project_has_container_registry_tags? errors.add(:username, 'cannot be changed if a personal project has container registry tags.') @@ -884,19 +871,18 @@ class User < ActiveRecord::Base end def ensure_namespace_correct - # Ensure user has namespace - create_namespace!(path: username, name: username) unless namespace - - if username_changed? - unless namespace.update_attributes(path: username, name: username) - namespace.errors.each do |attribute, message| - self.errors.add(:"namespace_#{attribute}", message) - end - raise ActiveRecord::RecordInvalid.new(namespace) - end + if namespace + namespace.path = namespace.name = username if username_changed? + else + build_namespace(path: username, name: username) end end + def set_username_errors + namespace_path_errors = self.errors.delete(:"namespace.path") + self.errors[:username].concat(namespace_path_errors) if namespace_path_errors + end + def username_changed_hook system_hook_service.execute_hooks_for(self, :rename) end diff --git a/app/presenters/ci/group_variable_presenter.rb b/app/presenters/ci/group_variable_presenter.rb index 81fea106a5c..98d68bc7a83 100644 --- a/app/presenters/ci/group_variable_presenter.rb +++ b/app/presenters/ci/group_variable_presenter.rb @@ -7,19 +7,15 @@ module Ci end def form_path - if variable.persisted? - group_variable_path(group, variable) - else - group_variables_path(group) - end + group_settings_ci_cd_path(group) end def edit_path - group_variable_path(group, variable) + group_variables_path(group) end def delete_path - group_variable_path(group, variable) + group_variables_path(group) end end end diff --git a/app/presenters/ci/variable_presenter.rb b/app/presenters/ci/variable_presenter.rb index 5d7998393a6..96159f88c59 100644 --- a/app/presenters/ci/variable_presenter.rb +++ b/app/presenters/ci/variable_presenter.rb @@ -7,19 +7,15 @@ module Ci end def form_path - if variable.persisted? - project_variable_path(project, variable) - else - project_variables_path(project) - end + project_settings_ci_cd_path(project) end def edit_path - project_variable_path(project, variable) + project_variables_path(project) end def delete_path - project_variable_path(project, variable) + project_variables_path(project) end end end diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index c6806b7cc26..08ae49562c7 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -3,6 +3,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated include GitlabRoutingHelper include MarkupHelper include TreeHelper + include Gitlab::Utils::StrongMemoize presents :merge_request @@ -43,7 +44,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end def revert_in_fork_path - if user_can_fork_project? && can_be_reverted?(current_user) + if user_can_fork_project? && cached_can_be_reverted? continue_params = { to: merge_request_path(merge_request), notice: "#{edit_in_new_fork_notice} Try to cherry-pick this commit again.", @@ -151,7 +152,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end def can_revert_on_current_merge_request? - user_can_collaborate_with_project? && can_be_reverted?(current_user) + user_can_collaborate_with_project? && cached_can_be_reverted? end def can_cherry_pick_on_current_merge_request? @@ -164,6 +165,12 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated private + def cached_can_be_reverted? + strong_memoize(:can_be_reverted) do + can_be_reverted?(current_user) + end + end + def conflicts @conflicts ||= MergeRequests::Conflicts::ListService.new(merge_request) end diff --git a/app/serializers/group_variable_entity.rb b/app/serializers/group_variable_entity.rb new file mode 100644 index 00000000000..62cf0b21e1e --- /dev/null +++ b/app/serializers/group_variable_entity.rb @@ -0,0 +1,7 @@ +class GroupVariableEntity < Grape::Entity + expose :id + expose :key + expose :value + + expose :protected?, as: :protected +end diff --git a/app/serializers/group_variable_serializer.rb b/app/serializers/group_variable_serializer.rb new file mode 100644 index 00000000000..8f8205924aa --- /dev/null +++ b/app/serializers/group_variable_serializer.rb @@ -0,0 +1,3 @@ +class GroupVariableSerializer < BaseSerializer + entity GroupVariableEntity +end diff --git a/app/serializers/lfs_file_lock_entity.rb b/app/serializers/lfs_file_lock_entity.rb new file mode 100644 index 00000000000..264a77adc3f --- /dev/null +++ b/app/serializers/lfs_file_lock_entity.rb @@ -0,0 +1,11 @@ +class LfsFileLockEntity < Grape::Entity + root 'locks', 'lock' + + expose :path + expose(:id) { |entity| entity.id.to_s } + expose(:created_at, as: :locked_at) { |entity| entity.created_at.to_s(:iso8601) } + + expose :owner do + expose(:name) { |entity| entity.user&.name } + end +end diff --git a/app/serializers/lfs_file_lock_serializer.rb b/app/serializers/lfs_file_lock_serializer.rb new file mode 100644 index 00000000000..ba8fb1a461d --- /dev/null +++ b/app/serializers/lfs_file_lock_serializer.rb @@ -0,0 +1,3 @@ +class LfsFileLockSerializer < BaseSerializer + entity LfsFileLockEntity +end diff --git a/app/serializers/variable_entity.rb b/app/serializers/variable_entity.rb new file mode 100644 index 00000000000..d576745c073 --- /dev/null +++ b/app/serializers/variable_entity.rb @@ -0,0 +1,7 @@ +class VariableEntity < Grape::Entity + expose :id + expose :key + expose :value + + expose :protected?, as: :protected +end diff --git a/app/serializers/variable_serializer.rb b/app/serializers/variable_serializer.rb new file mode 100644 index 00000000000..32ae82ab51c --- /dev/null +++ b/app/serializers/variable_serializer.rb @@ -0,0 +1,3 @@ +class VariableSerializer < BaseSerializer + entity VariableEntity +end diff --git a/app/services/ci/ensure_stage_service.rb b/app/services/ci/ensure_stage_service.rb index dc2f49e8db1..87f19b333de 100644 --- a/app/services/ci/ensure_stage_service.rb +++ b/app/services/ci/ensure_stage_service.rb @@ -7,6 +7,8 @@ module Ci # stage. # class EnsureStageService < BaseService + EnsureStageError = Class.new(StandardError) + def execute(build) @build = build @@ -22,8 +24,16 @@ module Ci private - def ensure_stage + def ensure_stage(attempts: 2) find_stage || create_stage + rescue ActiveRecord::RecordNotUnique + retry if (attempts -= 1) > 0 + + raise EnsureStageError, <<~EOS + We failed to find or create a unique pipeline stage after 2 retries. + This should never happen and is most likely the result of a bug in + the database load balancing code. + EOS end def find_stage diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index c552193e66b..6128b2a8fbb 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -1,7 +1,7 @@ module Ci class RetryBuildService < ::BaseService CLONE_ACCESSORS = %i[pipeline project ref tag options commands name - allow_failure stage_id stage stage_idx trigger_request + allow_failure stage stage_id stage_idx trigger_request yaml_variables when environment coverage_regex description tag_list protected].freeze diff --git a/app/services/lfs/lock_file_service.rb b/app/services/lfs/lock_file_service.rb new file mode 100644 index 00000000000..bbe10f84ef4 --- /dev/null +++ b/app/services/lfs/lock_file_service.rb @@ -0,0 +1,39 @@ +module Lfs + class LockFileService < BaseService + def execute + unless can?(current_user, :push_code, project) + raise Gitlab::GitAccess::UnauthorizedError, 'You have no permissions' + end + + create_lock! + rescue ActiveRecord::RecordNotUnique + error('already locked', 409, current_lock) + rescue Gitlab::GitAccess::UnauthorizedError => ex + error(ex.message, 403) + rescue => ex + error(ex.message, 500) + end + + private + + def current_lock + project.lfs_file_locks.find_by(path: params[:path]) + end + + def create_lock! + lock = project.lfs_file_locks.create!(user: current_user, + path: params[:path]) + + success(http_status: 201, lock: lock) + end + + def error(message, http_status, lock = nil) + { + status: :error, + message: message, + http_status: http_status, + lock: lock + } + end + end +end diff --git a/app/services/lfs/locks_finder_service.rb b/app/services/lfs/locks_finder_service.rb new file mode 100644 index 00000000000..13c6cc6f81c --- /dev/null +++ b/app/services/lfs/locks_finder_service.rb @@ -0,0 +1,17 @@ +module Lfs + class LocksFinderService < BaseService + def execute + success(locks: find_locks) + rescue => ex + error(ex.message, 500) + end + + private + + def find_locks + options = params.slice(:id, :path).compact.symbolize_keys + + project.lfs_file_locks.where(options) + end + end +end diff --git a/app/services/lfs/unlock_file_service.rb b/app/services/lfs/unlock_file_service.rb new file mode 100644 index 00000000000..6c93dc69bb0 --- /dev/null +++ b/app/services/lfs/unlock_file_service.rb @@ -0,0 +1,43 @@ +module Lfs + class UnlockFileService < BaseService + def execute + unless can?(current_user, :push_code, project) + raise Gitlab::GitAccess::UnauthorizedError, 'You have no permissions' + end + + unlock_file + rescue Gitlab::GitAccess::UnauthorizedError => ex + error(ex.message, 403) + rescue ActiveRecord::RecordNotFound + error('Lock not found', 404) + rescue => ex + error(ex.message, 500) + end + + private + + def unlock_file + forced = params[:force] == true + + if lock.can_be_unlocked_by?(current_user, forced) + lock.destroy! + + success(lock: lock, http_status: :ok) + elsif forced + error('You must have master access to force delete a lock', 403) + else + error("#{lock.path} is locked by GitLab User #{lock.user_id}", 403) + end + end + + def lock + return @lock if defined?(@lock) + + @lock = if params[:id].present? + project.lfs_file_locks.find(params[:id]) + elsif params[:path].present? + project.lfs_file_locks.find_by!(path: params[:path]) + end + end + end +end diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index 634bf3bd690..a18b1c90765 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -9,10 +9,7 @@ module MergeRequests merge_request.source_branch = params[:source_branch] merge_request.merge_params['force_remove_source_branch'] = params.delete(:force_remove_source_branch) - # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37439 - Gitlab::GitalyClient.allow_n_plus_1_calls do - create(merge_request) - end + create(merge_request) end def before_create(merge_request) diff --git a/app/validators/abstract_path_validator.rb b/app/validators/abstract_path_validator.rb index adbccb65a84..e43b66cbe3a 100644 --- a/app/validators/abstract_path_validator.rb +++ b/app/validators/abstract_path_validator.rb @@ -13,10 +13,6 @@ class AbstractPathValidator < ActiveModel::EachValidator raise NotImplementedError end - def self.full_path(record, value) - value - end - def self.valid_path?(path) encode!(path) "#{path}/" =~ path_regex @@ -28,7 +24,7 @@ class AbstractPathValidator < ActiveModel::EachValidator return end - full_path = self.class.full_path(record, value) + full_path = record.build_full_path return unless full_path unless self.class.valid_path?(full_path) diff --git a/app/validators/namespace_path_validator.rb b/app/validators/namespace_path_validator.rb index 4a0aa64ae0c..7b0ae4db5d4 100644 --- a/app/validators/namespace_path_validator.rb +++ b/app/validators/namespace_path_validator.rb @@ -12,8 +12,4 @@ class NamespacePathValidator < AbstractPathValidator def self.format_error_message Gitlab::PathRegex.namespace_format_message end - - def self.full_path(record, value) - record.build_full_path - end end diff --git a/app/validators/project_path_validator.rb b/app/validators/project_path_validator.rb index 829b596ad3c..424fd77a6a3 100644 --- a/app/validators/project_path_validator.rb +++ b/app/validators/project_path_validator.rb @@ -12,8 +12,4 @@ class ProjectPathValidator < AbstractPathValidator def self.format_error_message Gitlab::PathRegex.project_path_format_message end - - def self.full_path(record, value) - record.build_full_path - end end diff --git a/app/validators/user_path_validator.rb b/app/validators/user_path_validator.rb deleted file mode 100644 index adf02901802..00000000000 --- a/app/validators/user_path_validator.rb +++ /dev/null @@ -1,15 +0,0 @@ -class UserPathValidator < AbstractPathValidator - extend Gitlab::EncodingHelper - - def self.path_regex - Gitlab::PathRegex.root_namespace_path_regex - end - - def self.format_regex - Gitlab::PathRegex.namespace_format_regex - end - - def self.format_error_message - Gitlab::PathRegex.namespace_format_message - end -end diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index fb5e6f337a7..60f12030f98 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -249,7 +249,12 @@ .help-block It will automatically build, test, and deploy applications based on a predefined CI/CD configuration = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md') - + .form-group + = f.label :auto_devops_domain, class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :auto_devops_domain, class: 'form-control', placeholder: 'domain.com' + .help-block + = s_("AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages.") .form-group .col-sm-offset-2.col-sm-10 .checkbox diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml index c69c2761189..b5d7b889504 100644 --- a/app/views/admin/projects/_projects.html.haml +++ b/app/views/admin/projects/_projects.html.haml @@ -5,7 +5,12 @@ %li.project-row{ class: ('no-description' if project.description.blank?) } .controls = link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn" - = link_to 'Delete', [project.namespace.becomes(Namespace), project], data: { confirm: remove_project_message(project) }, method: :delete, class: "btn btn-remove" + %button.delete-project-button.btn.btn-danger{ data: { toggle: 'modal', + target: '#delete-project-modal', + delete_project_url: project_path(project), + project_name: project.name }, type: 'button' } + = s_('AdminProjects|Delete') + .stats %span.badge = storage_counter(project.statistics.storage_size) @@ -31,3 +36,5 @@ = paginate @projects, theme: 'gitlab' - else .nothing-here-block No projects found + + #delete-project-modal diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml index ca6e43e091c..3b85586e45b 100644 --- a/app/views/admin/users/_user.html.haml +++ b/app/views/admin/users/_user.html.haml @@ -38,12 +38,19 @@ %li.divider - if user.can_be_removed? %li - = link_to 'Remove user', admin_user_path(user), - data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, - class: 'text-danger', - method: :delete + %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal', + target: '#delete-user-modal', + delete_user_url: admin_user_path(user), + block_user_url: block_admin_user_path(user), + username: user.name, + delete_contributions: 'false' }, type: 'button' } + = s_('AdminUsers|Delete user') + %li - = link_to 'Remove user and contributions', admin_user_path(user, hard_delete: true), - data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and comments authored by this user, and groups owned solely by them, will also be removed! Are you sure?" }, - class: 'text-danger', - method: :delete + %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal', + target: '#delete-user-modal', + delete_user_url: admin_user_path(user, hard_delete: true), + block_user_url: block_admin_user_path(user), + username: user.name, + delete_contributions: 'true' }, type: 'button' } + = s_('AdminUsers|Delete user and contributions') diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index 38ce1564eff..0ef4b71f4fe 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -76,3 +76,6 @@ = render partial: 'admin/users/user', collection: @users = paginate @users, theme: "gitlab" + +#delete-user-modal + diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 63c5a15de1c..101667508a9 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -172,13 +172,19 @@ .panel.panel-danger .panel-heading - Remove user + = s_('AdminUsers|Delete user') .panel-body - if @user.can_be_removed? && can?(current_user, :destroy_user, @user) %p Deleting a user has the following effects: = render 'users/deletion_guidance', user: @user %br - = link_to 'Remove user', admin_user_path(@user), data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove" + %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal', + target: '#delete-user-modal', + delete_user_url: admin_user_path(@user), + block_user_url: block_admin_user_path(@user), + username: @user.name, + delete_contributions: 'false' }, type: 'button' } + = s_('AdminUsers|Delete user') - else - if @user.solo_owned_groups.present? %p @@ -192,7 +198,7 @@ .panel.panel-danger .panel-heading - Remove user and contributions + = s_('AdminUsers|Delete user and contributions') .panel-body - if can?(current_user, :destroy_user, @user) %p @@ -204,7 +210,15 @@ the user, and projects in them, will also be removed. Commits to other projects are unaffected. %br - = link_to 'Remove user and contributions', admin_user_path(@user, hard_delete: true), data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove" + %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal', + target: '#delete-user-modal', + delete_user_url: admin_user_path(@user, hard_delete: true), + block_user_url: block_admin_user_path(@user), + username: @user.name, + delete_contributions: 'true' }, type: 'button' } + = s_('AdminUsers|Delete user and contributions') - else %p You don't have access to delete this user. + + #delete-user-modal diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml index fbfe3e56588..d355e7799df 100644 --- a/app/views/ci/variables/_content.html.haml +++ b/app/views/ci/variables/_content.html.haml @@ -1,3 +1 @@ -%p.append-bottom-default - Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. - You can use variables for passwords, secret keys, or whatever you want. += _('Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want.') diff --git a/app/views/ci/variables/_form.html.haml b/app/views/ci/variables/_form.html.haml deleted file mode 100644 index eebd0955c80..00000000000 --- a/app/views/ci/variables/_form.html.haml +++ /dev/null @@ -1,19 +0,0 @@ -= form_for @variable, as: :variable, url: @variable.form_path do |f| - = form_errors(@variable) - - .form-group - = f.label :key, "Key", class: "label-light" - = f.text_field :key, class: "form-control", placeholder: @variable.placeholder, required: true - .form-group - = f.label :value, "Value", class: "label-light" - = f.text_area :value, class: "form-control", placeholder: @variable.placeholder - .form-group - .checkbox - = f.label :protected do - = f.check_box :protected - %strong Protected - .help-block - This variable will be passed only to pipelines running on protected branches and tags - = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'protected-secret-variables'), target: '_blank' - - = f.submit btn_text, class: "btn btn-save" diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index 6e399fc7392..e402801a776 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -1,16 +1,20 @@ -.row.prepend-top-default.append-bottom-default - .col-lg-12 - %h5.prepend-top-0 - Add a variable - = render "ci/variables/form", btn_text: "Add new variable" - %hr - %h5.prepend-top-0 - Your variables (#{@variables.size}) - - if @variables.empty? - %p.settings-message.text-center.append-bottom-0 - No variables found, add one with the form above. - - else - .js-secret-variable-table - = render "ci/variables/table" - %button.btn.btn-info.js-secret-value-reveal-button{ data: { secret_reveal_status: 'false' } } +- save_endpoint = local_assigns.fetch(:save_endpoint, nil) + +.row + .col-lg-12.js-ci-variable-list-section{ data: { save_endpoint: save_endpoint } } + .hide.alert.alert-danger.js-ci-variable-error-box + + %ul.ci-variable-list + - @variables.each.each do |variable| + = render 'ci/variables/variable_row', form_field: 'variables', variable: variable + = render 'ci/variables/variable_row', form_field: 'variables' + .prepend-top-20 + %button.btn.btn-success.js-secret-variables-save-button{ type: 'button' } + %span.hide.js-secret-variables-save-loading-icon + = icon('spinner spin') + = _('Save variables') + %button.btn.btn-info.btn-inverted.prepend-left-10.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@variables.size == 0}" } } + - if @variables.size == 0 + = n_('Hide value', 'Hide values', @variables.size) + - else = n_('Reveal value', 'Reveal values', @variables.size) diff --git a/app/views/ci/variables/_show.html.haml b/app/views/ci/variables/_show.html.haml deleted file mode 100644 index 6d75ae96124..00000000000 --- a/app/views/ci/variables/_show.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -- page_title "Variables" - -.row.prepend-top-default.append-bottom-default - .col-lg-3 - = render "ci/variables/content" - .col-lg-9 - %h4.prepend-top-0 - Update variable - = render "ci/variables/form", btn_text: "Save variable" diff --git a/app/views/ci/variables/_table.html.haml b/app/views/ci/variables/_table.html.haml deleted file mode 100644 index 2298930d0c7..00000000000 --- a/app/views/ci/variables/_table.html.haml +++ /dev/null @@ -1,32 +0,0 @@ -.table-responsive.variables-table - %table.table - %colgroup - %col - %col - %col - %col{ width: 100 } - %thead - %th Key - %th Value - %th Protected - %th - %tbody - - @variables.each do |variable| - - if variable.id? - %tr - %td.variable-key= variable.key - %td.variable-value - %span.js-secret-value-placeholder - = '*' * 6 - %span.hide.js-secret-value - = variable.value - %td.variable-protected= Gitlab::Utils.boolean_to_yes_no(variable.protected) - %td.variable-menu - = link_to variable.edit_path, class: "btn btn-transparent btn-variable-edit" do - %span.sr-only - Update - = icon("pencil") - = link_to variable.delete_path, class: "btn btn-transparent btn-variable-delete", method: :delete, data: { confirm: "Are you sure?" } do - %span.sr-only - Remove - = icon("trash") diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index 472da2a6a72..dd82922ec55 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -1,4 +1,11 @@ - breadcrumb_title "CI / CD Settings" - page_title "CI / CD" -= render 'ci/variables/index' +%h4 + = _('Secret variables') + = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank', rel: 'noopener noreferrer' + +%p + = render "ci/variables/content" + += render 'ci/variables/index', save_endpoint: group_variables_path diff --git a/app/views/groups/variables/show.html.haml b/app/views/groups/variables/show.html.haml deleted file mode 100644 index df533952b76..00000000000 --- a/app/views/groups/variables/show.html.haml +++ /dev/null @@ -1 +0,0 @@ -= render 'ci/variables/show' diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index eba9cd253bb..f0963cf9da8 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -1,7 +1,7 @@ .layout-page{ class: page_with_sidebar_class } - if defined?(nav) && nav = render "layouts/nav/sidebar/#{nav}" - .content-wrapper + .content-wrapper{ class: "#{@content_wrapper_class}" } = render 'shared/outdated_browser' .mobile-overlay .alert-wrapper diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml index a60afde06d2..2b1b23ba198 100644 --- a/app/views/projects/clusters/show.html.haml +++ b/app/views/projects/clusters/show.html.haml @@ -14,7 +14,8 @@ cluster_status: @cluster.status_name, 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_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-ip-address'), + manage_prometheus_path: edit_project_service_path(@cluster.project, 'prometheus') } } .js-cluster-application-notice .flash-container diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml index 5257b42548e..10812f67cbe 100644 --- a/app/views/projects/environments/metrics.html.haml +++ b/app/views/projects/environments/metrics.html.haml @@ -13,6 +13,7 @@ = link_to @environment.name, environment_path(@environment) #prometheus-graphs{ data: { "settings-path": edit_project_service_path(@project, 'prometheus'), + "clusters-path": project_clusters_path(@project), "documentation-path": help_page_path('administration/monitoring/prometheus/index.md'), "empty-getting-started-svg-path": image_path('illustrations/monitoring/getting_started.svg'), "empty-loading-svg-path": image_path('illustrations/monitoring/loading.svg'), diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index ffb9238a65a..300a39fe257 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -7,7 +7,7 @@ .repo-charts{ class: container_class } %h4.sub-header - Programming languages used in this repository + = _("Programming languages used in this repository") .row .col-md-4 @@ -30,9 +30,11 @@ .row.tree-ref-header .col-md-6 %h4 - Commit statistics for - %strong= @ref - #{@commits_graph.start_date.strftime('%b %d')} - #{@commits_graph.end_date.strftime('%b %d')} + - start_time = capture do + #{@commits_graph.start_date.strftime('%b %d')} + - end_time = capture do + #{@commits_graph.end_date.strftime('%b %d')} + = (_("Commit statistics for %{ref} %{start_time} - %{end_time}") % { ref: "<strong>#{@ref}</strong>", start_time: start_time, end_time: end_time }).html_safe .col-md-6 .tree-ref-container @@ -45,32 +47,35 @@ .col-md-6 %ul.commit-stats %li - Total: - %strong #{@commits_graph.commits.size} commits + - total = capture do + #{@commits_graph.commits.size} + = (_("Total: %{total}") % { total: "<strong>#{total} commits</strong>" }).html_safe %li - Average per day: - %strong #{@commits_graph.commit_per_day} commits + - average = capture do + #{@commits_graph.commit_per_day} + = (_("Average per day: %{average}") % { average: "<strong>#{average} commits</strong>" }).html_safe %li - Authors: - %strong= @commits_graph.authors + - authors = capture do + #{@commits_graph.authors} + = (_("Authors: %{authors}") % { authors: "<strong>#{authors}</strong>" }).html_safe .col-md-6 %div %p.slead - Commits per day of month + = _("Commits per day of month") %canvas#month-chart .row .col-md-6 .col-md-6 %div %p.slead - Commits per weekday + = _("Commits per weekday") %canvas#weekday-chart .row .col-md-6 .col-md-6 %div %p.slead - Commits per day hour (UTC) + = _("Commits per day hour (UTC)") %canvas#hour-chart %script#projectChartData{ type: "application/json" } diff --git a/app/views/projects/network/_head.html.haml b/app/views/projects/network/_head.html.haml index 8f6805268d5..f08526f485e 100644 --- a/app/views/projects/network/_head.html.haml +++ b/app/views/projects/network/_head.html.haml @@ -6,4 +6,4 @@ = render partial: 'shared/ref_switcher', locals: {destination: 'graph'} .oneline - You can move around the graph by using the arrow keys. + = _("You can move around the graph by using the arrow keys.") diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml index 8a19497c55b..2efb7fc719f 100644 --- a/app/views/projects/network/show.html.haml +++ b/app/views/projects/network/show.html.haml @@ -7,14 +7,14 @@ .project-network .controls = form_tag project_network_path(@project, @id), method: :get, class: 'form-inline network-form' do |f| - = text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: "Git revision", class: 'search-input form-control input-mx-250 search-sha' + = text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: _("Git revision"), class: 'search-input form-control input-mx-250 search-sha' = button_tag class: 'btn btn-success' do = icon('search') .inline.prepend-left-20 .checkbox.light = label_tag :filter_ref do = check_box_tag :filter_ref, 1, @options[:filter_ref] - %span Begin with the selected commit + %span= _("Begin with the selected commit") - if @commit .network-graph{ data: { url: @url, commit_url: @commit_url, ref: @ref, commit_id: @commit.id } } diff --git a/app/views/projects/network/show.json.erb b/app/views/projects/network/show.json.erb index 122e84b41b2..7491b37310d 100644 --- a/app/views/projects/network/show.json.erb +++ b/app/views/projects/network/show.json.erb @@ -13,7 +13,7 @@ }, time: c.time, space: c.spaces.first, - refs: get_refs(@graph.repo, c), + refs: refs(@graph.repo, c), id: c.sha, date: c.date, message: c.message, diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index 21acd857ce7..0808b28a9df 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -9,7 +9,7 @@ %p= @service.description .col-lg-9 - = form_for(@service, as: :service, url: project_service_path(@project, @service.to_param), method: :put, html: { class: 'gl-show-field-errors form-horizontal js-integration-settings-form', data: { 'can-test' => @service.can_test?, 'test-url' => test_project_service_path(@project, @service) } }) do |form| + = form_for(@service, as: :service, url: project_service_path(@project, @service.to_param), method: :put, html: { class: 'gl-show-field-errors form-horizontal integration-settings-form js-integration-settings-form', data: { 'can-test' => @service.can_test?, 'test-url' => test_project_service_path(@project, @service) } }) do |form| = render 'shared/service_settings', form: form, subject: @service - if @service.editable? .footer-block.row-content-block diff --git a/app/views/projects/services/prometheus/_help.html.haml b/app/views/projects/services/prometheus/_help.html.haml new file mode 100644 index 00000000000..5e320a252d8 --- /dev/null +++ b/app/views/projects/services/prometheus/_help.html.haml @@ -0,0 +1,33 @@ +%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 + +%h4.append-bottom-default + = s_('PrometheusService|Manual configuration') + +- unless @service.editable? + .well + = s_('PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters') diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 664a4554692..756f31f91d9 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -29,14 +29,14 @@ %section.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 - Secret variables - = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank' + = _('Secret variables') + = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank', rel: 'noopener noreferrer' %button.btn.js-settings-toggle = expanded ? 'Collapse' : 'Expand' - %p + %p.append-bottom-0 = render "ci/variables/content" .settings-content - = render 'ci/variables/index' + = render 'ci/variables/index', save_endpoint: project_variables_path(@project) %section.settings.no-animate{ class: ('expanded' if expanded) } .settings-header diff --git a/app/views/projects/variables/show.html.haml b/app/views/projects/variables/show.html.haml deleted file mode 100644 index df533952b76..00000000000 --- a/app/views/projects/variables/show.html.haml +++ /dev/null @@ -1 +0,0 @@ -= render 'ci/variables/show' diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml index 0a692d9653f..d5e7d3b87b7 100644 --- a/app/views/shared/issuable/_label_page_create.html.haml +++ b/app/views/shared/issuable/_label_page_create.html.haml @@ -2,16 +2,16 @@ = dropdown_title("Create new label", options: { back: true }) = dropdown_content do .dropdown-labels-error.js-label-error - %input#new_label_name.default-dropdown-input{ type: "text", placeholder: "Name new label" } + %input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') } .suggest-colors.suggest-colors-dropdown - suggested_colors.each do |color| = link_to '#', style: "background-color: #{color}", data: { color: color } do   .dropdown-label-color-input .dropdown-label-color-preview.js-dropdown-label-color-preview - %input#new_label_color.default-dropdown-input{ type: "text", placeholder: "Assign custom color like #FF0000" } + %input#new_label_color.default-dropdown-input{ type: "text", placeholder: _('Assign custom color like #FF0000') } .clearfix %button.btn.btn-primary.pull-left.js-new-label-btn{ type: "button" } - Create + = _('Create') %button.btn.btn-default.pull-right.js-cancel-label-btn{ type: "button" } - Cancel + = _('Cancel') diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml index ad031e6af80..6a83321abcb 100644 --- a/app/views/shared/issuable/_label_page_default.html.haml +++ b/app/views/shared/issuable/_label_page_default.html.haml @@ -1,4 +1,4 @@ -- title = local_assigns.fetch(:title, 'Assign labels') +- title = local_assigns.fetch(:title, _('Assign labels')) - show_create = local_assigns.fetch(:show_create, true) - show_footer = local_assigns.fetch(:show_footer, true) - filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search') @@ -8,7 +8,7 @@ - if show_boards_content .issue-board-dropdown-content %p - Create lists from labels. Issues with that label appear in that list. + = _('Create lists from labels. Issues with that label appear in that list.') = dropdown_filter(filter_placeholder) = dropdown_content - if current_board_parent && show_footer @@ -17,11 +17,11 @@ - if can?(current_user, :admin_label, current_board_parent) %li %a.dropdown-toggle-page{ href: "#" } - Create new label + = _('Create new label') %li = link_to labels_path, :"data-is-link" => true do - if show_create && can?(current_user, :admin_label, current_board_parent) - Manage labels + = _('Manage labels') - else - View labels + = _('View labels') = dropdown_loading diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index cc00c3c0bfd..15fd01c8429 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -9,7 +9,7 @@ .block.issuable-sidebar-header - if current_user %span.issuable-header-text.hide-collapsed.pull-left - Todo + = _('Todo') %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" } = sidebar_gutter_toggle_icon - if current_user @@ -29,9 +29,9 @@ %span.has-tooltip{ title: "#{issuable.milestone.title}<br>#{milestone_tooltip_title(issuable.milestone)}", data: { container: 'body', html: 1, placement: 'left' } } = issuable.milestone.title - else - None + = _('None') .title.hide-collapsed - Milestone + = _('Milestone') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' @@ -39,16 +39,17 @@ - if issuable.milestone = link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_tooltip_title(issuable.milestone), data: { container: "body", html: 1 } - else - %span.no-value None + %span.no-value + = _('None') .selectbox.hide-collapsed = f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil - = dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: project_milestones_path(@project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true, default_no: true, selected: (issuable.milestone.name if issuable.milestone), null_default: true }}) + = dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: project_milestones_path(@project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true, default_no: true, selected: (issuable.milestone.name if issuable.milestone), null_default: true }}) - if issuable.has_attribute?(:time_estimate) #issuable-time-tracker.block // Fallback while content is loading .title.hide-collapsed - Time tracking + = _('Time tracking') = icon('spinner spin', 'aria-hidden': 'true') - if issuable.has_attribute?(:due_date) .block.due_date @@ -57,7 +58,7 @@ %span.js-due-date-sidebar-value = issuable.due_date.try(:to_s, :medium) || 'None' .title.hide-collapsed - Due date + = _('Due date') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' @@ -66,21 +67,23 @@ - if issuable.due_date %span.bold= issuable.due_date.to_s(:medium) - else - %span.no-value No due date + %span.no-value + = _('No due date') - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) %span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable.due_date.nil?) } \- %a.js-remove-due-date{ href: "#", role: "button" } - remove due date + = _('remove due date') - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .selectbox.hide-collapsed = f.hidden_field :due_date, value: issuable.due_date.try(:strftime, 'yy-mm-dd') .dropdown %button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable.to_ability_name}[due_date]", ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable) } } - %span.dropdown-toggle-text Due date + %span.dropdown-toggle-text + = _('Due date') = icon('chevron-down', 'aria-hidden': 'true') .dropdown-menu.dropdown-menu-due-date - = dropdown_title('Due date') + = dropdown_title(_('Due date')) = dropdown_content do .js-due-date-calendar @@ -92,7 +95,7 @@ %span = selected_labels.size .title.hide-collapsed - Labels + = _('Labels') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' @@ -101,7 +104,8 @@ - selected_labels.each do |label| = link_to_label(label, subject: issuable.project, type: issuable.to_ability_name) - else - %span.no-value None + %span.no-value + = _('None') .selectbox.hide-collapsed - selected_labels.each do |label| = hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil @@ -131,29 +135,29 @@ - project_ref = cross_project_reference(@project, issuable) .block.project-reference .sidebar-collapsed-icon.dont-change-state - = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left") + = clipboard_button(text: project_ref, title: _('Copy reference to clipboard'), placement: "left") .cross-project-reference.hide-collapsed %span - Reference: + = _('Reference:') %cite{ title: project_ref } = project_ref - = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left") + = clipboard_button(text: project_ref, title: _('Copy reference to clipboard'), placement: "left") - if current_user && issuable.can_move?(current_user) .block.js-sidebar-move-issue-block - .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body' }, title: 'Move issue' } + .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body' }, title: _('Move issue') } = custom_icon('icon_arrow_right') .dropdown.sidebar-move-issue-dropdown.hide-collapsed %button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button', data: { toggle: 'dropdown' } } - Move issue + = _('Move issue') .dropdown-menu.dropdown-menu-selectable - = dropdown_title('Move issue') - = dropdown_filter('Search project', search_id: 'sidebar-move-issue-dropdown-search') + = dropdown_title(_('Move issue')) + = dropdown_filter(_('Search project'), search_id: 'sidebar-move-issue-dropdown-search') = dropdown_content = dropdown_loading = dropdown_footer add_content_class: true do %button.btn.btn-new.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ disabled: true } - Move + = _('Move') = icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon') %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable, can_edit_issuable).to_json.html_safe diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index 0fca4162ec9..304df38a096 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -1,7 +1,7 @@ - if issuable.is_a?(Issue) #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]", signed_in: signed_in } } .title.hide-collapsed - Assignee + = _('Assignee') = icon('spinner spin') - else .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } @@ -10,35 +10,35 @@ - else = icon('user', 'aria-hidden': 'true') .title.hide-collapsed - Assignee + = _('Assignee') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' - if !signed_in - %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" } + %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => _('Toggle sidebar') } = sidebar_gutter_toggle_icon .value.hide-collapsed - if issuable.assignee = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do - if !issuable.can_be_merged_by?(issuable.assignee) - %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } + %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: _('Not allowed to merge') } = icon('exclamation-triangle', 'aria-hidden': 'true') %span.username = issuable.assignee.to_reference - else %span.assign-yourself.no-value - No assignee + = _('No assignee') - if can_edit_issuable \- %a.js-assign-yourself{ href: '#' } - assign yourself + = _('assign yourself') .selectbox.hide-collapsed - issuable.assignees.each do |assignee| = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username } - - options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: current_user&.username, current_user: true, project_id: @project&.id, author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } } - - title = 'Select assignee' + - options = { toggle_class: 'js-user-search js-author-search', title: _('Assign to'), filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: _('Search users'), data: { first_user: current_user&.username, current_user: true, project_id: @project&.id, author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } } + - title = _('Select assignee') - if issuable.is_a?(Issue) - unless issuable.assignees.any? diff --git a/app/views/shared/issuable/_sidebar_todo.html.haml b/app/views/shared/issuable/_sidebar_todo.html.haml index 574e2958ae8..b77e104c072 100644 --- a/app/views/shared/issuable/_sidebar_todo.html.haml +++ b/app/views/shared/issuable/_sidebar_todo.html.haml @@ -1,11 +1,11 @@ - is_collapsed = local_assigns.fetch(:is_collapsed, false) -- mark_content = is_collapsed ? icon('check-square', class: 'todo-undone') : 'Mark done' -- todo_content = is_collapsed ? icon('plus-square') : 'Add todo' +- mark_content = is_collapsed ? icon('check-square', class: 'todo-undone') : _('Mark done') +- todo_content = is_collapsed ? icon('plus-square') : _('Add todo') %button.issuable-todo-btn.js-issuable-todo{ type: 'button', class: (is_collapsed ? 'btn-blank sidebar-collapsed-icon dont-change-state has-tooltip' : 'btn btn-default issuable-header-btn pull-right'), - title: (todo.nil? ? 'Add todo' : 'Mark done'), - 'aria-label' => (todo.nil? ? 'Add todo' : 'Mark done'), + title: (todo.nil? ? _('Add todo') : _('Mark done')), + 'aria-label' => (todo.nil? ? _('Add todo') : _('Mark done')), data: issuable_todo_button_data(issuable, todo, is_collapsed) } %span.issuable-todo-inner.js-issuable-todo-inner< - if todo diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index 7388f20a9fd..57b445321e2 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -1,7 +1,7 @@ - link_project = local_assigns.fetch(:link_project, false) %li.snippet-row - = image_tag avatar_icon(snippet.author_email), class: "avatar s40 hidden-xs", alt: '' + = image_tag avatar_icon(snippet.author), class: "avatar s40 hidden-xs", alt: '' .title = link_to reliable_snippet_path(snippet) do diff --git a/changelogs/unreleased-ee/39118-dynamic-pipeline-variables-fe.yml b/changelogs/unreleased-ee/39118-dynamic-pipeline-variables-fe.yml new file mode 100644 index 00000000000..a38b447e345 --- /dev/null +++ b/changelogs/unreleased-ee/39118-dynamic-pipeline-variables-fe.yml @@ -0,0 +1,6 @@ +--- +title: Update CI/CD secret variables list to be dynamic and save without reloading + the page +merge_request: 4110 +author: +type: added diff --git a/changelogs/unreleased/26388-push-to-create-a-new-project.yml b/changelogs/unreleased/26388-push-to-create-a-new-project.yml new file mode 100644 index 00000000000..f641fcced37 --- /dev/null +++ b/changelogs/unreleased/26388-push-to-create-a-new-project.yml @@ -0,0 +1,5 @@ +--- +title: User can now git push to create a new project +merge_request: 16547 +author: +type: added diff --git a/changelogs/unreleased/34416-issue-i18n.yml b/changelogs/unreleased/34416-issue-i18n.yml new file mode 100644 index 00000000000..523073ee43b --- /dev/null +++ b/changelogs/unreleased/34416-issue-i18n.yml @@ -0,0 +1,5 @@ +--- +title: Translate issuable sidebar +merge_request: +author: +type: other diff --git a/changelogs/unreleased/35856-implement-file-locking-api.yml b/changelogs/unreleased/35856-implement-file-locking-api.yml new file mode 100644 index 00000000000..fa848ad9ed8 --- /dev/null +++ b/changelogs/unreleased/35856-implement-file-locking-api.yml @@ -0,0 +1,5 @@ +--- +title: Backport of LFS File Locking API +merge_request: 16935 +author: +type: added diff --git a/changelogs/unreleased/38175-add-domain-field-to-auto-devops-application-setting.yml b/changelogs/unreleased/38175-add-domain-field-to-auto-devops-application-setting.yml new file mode 100644 index 00000000000..475e1dc12b5 --- /dev/null +++ b/changelogs/unreleased/38175-add-domain-field-to-auto-devops-application-setting.yml @@ -0,0 +1,5 @@ +--- +title: Add Auto DevOps Domain application setting +merge_request: 16604 +author: +type: changed diff --git a/changelogs/unreleased/38265-stuckcijobsworker-wrongly-detects-cancels-stuck-builds-when-per-job-timeout-is-more-than-an-hour.yml b/changelogs/unreleased/38265-stuckcijobsworker-wrongly-detects-cancels-stuck-builds-when-per-job-timeout-is-more-than-an-hour.yml new file mode 100644 index 00000000000..4d8e6acfcb7 --- /dev/null +++ b/changelogs/unreleased/38265-stuckcijobsworker-wrongly-detects-cancels-stuck-builds-when-per-job-timeout-is-more-than-an-hour.yml @@ -0,0 +1,5 @@ +--- +title: Update runner info on all authenticated requests +merge_request: 16756 +author: +type: changed diff --git a/changelogs/unreleased/40755-snippets-author-n-1.yml b/changelogs/unreleased/40755-snippets-author-n-1.yml new file mode 100644 index 00000000000..6e09c8a54ec --- /dev/null +++ b/changelogs/unreleased/40755-snippets-author-n-1.yml @@ -0,0 +1,5 @@ +--- +title: Fix N+1 query problem for snippets dashboard. +merge_request: 16944 +author: +type: performance diff --git a/changelogs/unreleased/41209-ci-linter-fails-on-gitlab-ci-blob-viewer.yml b/changelogs/unreleased/41209-ci-linter-fails-on-gitlab-ci-blob-viewer.yml new file mode 100644 index 00000000000..61d6bf8fd36 --- /dev/null +++ b/changelogs/unreleased/41209-ci-linter-fails-on-gitlab-ci-blob-viewer.yml @@ -0,0 +1,5 @@ +--- +title: 'Handle all Psych YAML parser exceptions (fixes #41209)' +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/41763-search-api.yml b/changelogs/unreleased/41763-search-api.yml new file mode 100644 index 00000000000..0a760a66510 --- /dev/null +++ b/changelogs/unreleased/41763-search-api.yml @@ -0,0 +1,5 @@ +--- +title: Add search support into the API +merge_request: 16878 +author: +type: added diff --git a/changelogs/unreleased/42462-edit-note.yml b/changelogs/unreleased/42462-edit-note.yml new file mode 100644 index 00000000000..8df98f3ecef --- /dev/null +++ b/changelogs/unreleased/42462-edit-note.yml @@ -0,0 +1,5 @@ +--- +title: Fix cnacel edit note button reverting changes +merge_request: 42462 +author: +type: fixed diff --git a/changelogs/unreleased/dm-route-path-validation.yml b/changelogs/unreleased/dm-route-path-validation.yml new file mode 100644 index 00000000000..df3ed1de1b9 --- /dev/null +++ b/changelogs/unreleased/dm-route-path-validation.yml @@ -0,0 +1,5 @@ +--- +title: Validate user, group and project paths consistently, and only once +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/dm-user-namespace-route-path-validation.yml b/changelogs/unreleased/dm-user-namespace-route-path-validation.yml new file mode 100644 index 00000000000..36615e5b976 --- /dev/null +++ b/changelogs/unreleased/dm-user-namespace-route-path-validation.yml @@ -0,0 +1,5 @@ +--- +title: Validate user namespace before saving so that errors persist on model +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/internationalize-charts-page.yml b/changelogs/unreleased/internationalize-charts-page.yml new file mode 100644 index 00000000000..481b83fb059 --- /dev/null +++ b/changelogs/unreleased/internationalize-charts-page.yml @@ -0,0 +1,5 @@ +--- +title: Internationalize charts page +merge_request: 16687 +author: selrahman +type: changed diff --git a/changelogs/unreleased/internationalize-graph-page.yml b/changelogs/unreleased/internationalize-graph-page.yml new file mode 100644 index 00000000000..904dbd606d7 --- /dev/null +++ b/changelogs/unreleased/internationalize-graph-page.yml @@ -0,0 +1,5 @@ +--- +title: Internationalize graph page selrahman +merge_request: 16688 +author: Shah El-Rahman +type: changed diff --git a/changelogs/unreleased/move-board-list-vue-component.yml b/changelogs/unreleased/move-board-list-vue-component.yml new file mode 100644 index 00000000000..9c566b43cc2 --- /dev/null +++ b/changelogs/unreleased/move-board-list-vue-component.yml @@ -0,0 +1,5 @@ +--- +title: Move BoardList vue component to vue file +merge_request: 16888 +author: George Tsiolis +type: performance diff --git a/changelogs/unreleased/osw-remove-duplicate-can-be-reverted-calls.yml b/changelogs/unreleased/osw-remove-duplicate-can-be-reverted-calls.yml new file mode 100644 index 00000000000..03940555162 --- /dev/null +++ b/changelogs/unreleased/osw-remove-duplicate-can-be-reverted-calls.yml @@ -0,0 +1,5 @@ +--- +title: Remove duplicate calls of MergeRequest#can_be_reverted? +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/pawel-connect_to_prometheus_through_proxy-30480.yml b/changelogs/unreleased/pawel-connect_to_prometheus_through_proxy-30480.yml new file mode 100644 index 00000000000..b2bb173912a --- /dev/null +++ b/changelogs/unreleased/pawel-connect_to_prometheus_through_proxy-30480.yml @@ -0,0 +1,6 @@ +--- +title: Implement multi server support and use kube proxy to connect to Prometheus + servers inside K8S cluster +merge_request: 16182 +author: +type: added diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb index 5e3e4c966cb..e9326653cbe 100644 --- a/config/initializers/mime_types.rb +++ b/config/initializers/mime_types.rb @@ -14,4 +14,4 @@ Mime::Type.register "video/webm", :webm Mime::Type.register "video/ogg", :ogv Mime::Type.unregister :json -Mime::Type.register 'application/json', :json, %w(application/vnd.git-lfs+json application/json) +Mime::Type.register 'application/json', :json, [LfsRequest::CONTENT_TYPE, 'application/json'] diff --git a/config/routes/git_http.rb b/config/routes/git_http.rb index a53c94326d4..ff51823897d 100644 --- a/config/routes/git_http.rb +++ b/config/routes/git_http.rb @@ -16,6 +16,13 @@ scope(path: '*namespace_id/:project_id', get '/*oid', action: :deprecated end + scope(path: 'info/lfs') do + resources :lfs_locks, controller: :lfs_locks_api, path: 'locks' do + post :unlock, on: :member + post :verify, on: :collection + end + end + # GitLab LFS object storage scope(path: 'gitlab-lfs/objects/*oid', controller: :lfs_storage, constraints: { oid: /[a-f0-9]{64}/ }) do get '/', action: :download diff --git a/config/routes/group.rb b/config/routes/group.rb index b17611d8623..7a4740a4df7 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -28,7 +28,7 @@ constraints(GroupUrlConstrainer.new) do resource :ci_cd, only: [:show], controller: 'ci_cd' end - resources :variables, only: [:index, :show, :update, :create, :destroy] + resource :variables, only: [:show, :update] resources :children, only: [:index] diff --git a/config/routes/project.rb b/config/routes/project.rb index bcaa68c8ce5..1912808f9c0 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -156,7 +156,8 @@ constraints(ProjectUrlConstrainer.new) do end end - resources :variables, only: [:index, :show, :update, :create, :destroy] + resource :variables, only: [:show, :update] + resources :triggers, only: [:index, :create, :edit, :update, :destroy] do member do post :take_ownership diff --git a/db/migrate/20180116193854_create_lfs_file_locks.rb b/db/migrate/20180116193854_create_lfs_file_locks.rb new file mode 100644 index 00000000000..23b0c90484b --- /dev/null +++ b/db/migrate/20180116193854_create_lfs_file_locks.rb @@ -0,0 +1,30 @@ +class CreateLfsFileLocks < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + create_table :lfs_file_locks do |t| + t.references :project, null: false, foreign_key: { on_delete: :cascade } + t.references :user, null: false, index: true, foreign_key: { on_delete: :cascade } + t.datetime :created_at, null: false + t.string :path, limit: 511 + end + + add_index :lfs_file_locks, [:project_id, :path], unique: true + end + + def down + if foreign_keys_for(:lfs_file_locks, :project_id).any? + remove_foreign_key :lfs_file_locks, column: :project_id + end + + if index_exists?(:lfs_file_locks, [:project_id, :path]) + remove_concurrent_index :lfs_file_locks, [:project_id, :path] + end + + drop_table :lfs_file_locks + end +end diff --git a/db/migrate/20180122162010_add_auto_devops_domain_to_application_settings.rb b/db/migrate/20180122162010_add_auto_devops_domain_to_application_settings.rb new file mode 100644 index 00000000000..7e16cb83087 --- /dev/null +++ b/db/migrate/20180122162010_add_auto_devops_domain_to_application_settings.rb @@ -0,0 +1,13 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddAutoDevopsDomainToApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + add_column :application_settings, :auto_devops_domain, :string + end +end diff --git a/db/migrate/20180206200543_reset_events_primary_key_sequence.rb b/db/migrate/20180206200543_reset_events_primary_key_sequence.rb new file mode 100644 index 00000000000..eb5c4a6a1e7 --- /dev/null +++ b/db/migrate/20180206200543_reset_events_primary_key_sequence.rb @@ -0,0 +1,35 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class ResetEventsPrimaryKeySequence < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + class Event < ActiveRecord::Base + self.table_name = 'events' + end + + def up + if Gitlab::Database.postgresql? + reset_primary_key_for_postgresql + else + reset_primary_key_for_mysql + end + end + + def down + # No-op + end + + def reset_primary_key_for_postgresql + reset_pk_sequence!(Event.table_name) + end + + def reset_primary_key_for_mysql + amount = Event.pluck('COALESCE(MAX(id), 1)').first + + execute "ALTER TABLE #{Event.table_name} AUTO_INCREMENT = #{amount}" + end +end diff --git a/db/post_migrate/20180119121225_remove_redundant_pipeline_stages.rb b/db/post_migrate/20180119121225_remove_redundant_pipeline_stages.rb new file mode 100644 index 00000000000..61ea85eb2a7 --- /dev/null +++ b/db/post_migrate/20180119121225_remove_redundant_pipeline_stages.rb @@ -0,0 +1,66 @@ +class RemoveRedundantPipelineStages < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up(attempts: 100) + remove_redundant_pipeline_stages! + remove_outdated_index! + add_unique_index! + rescue ActiveRecord::RecordNotUnique + retry if (attempts -= 1) > 0 + + raise StandardError, <<~EOS + Failed to add an unique index to ci_stages, despite retrying the + migration 100 times. + + See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/16580. + EOS + end + + def down + remove_concurrent_index :ci_stages, [:pipeline_id, :name], unique: true + add_concurrent_index :ci_stages, [:pipeline_id, :name] + end + + private + + def remove_outdated_index! + return unless index_exists?(:ci_stages, [:pipeline_id, :name]) + + remove_concurrent_index :ci_stages, [:pipeline_id, :name] + end + + def add_unique_index! + add_concurrent_index :ci_stages, [:pipeline_id, :name], unique: true + end + + def remove_redundant_pipeline_stages! + disable_statement_timeout + + redundant_stages_ids = <<~SQL + SELECT id FROM ci_stages WHERE (pipeline_id, name) IN ( + SELECT pipeline_id, name FROM ci_stages + GROUP BY pipeline_id, name HAVING COUNT(*) > 1 + ) + SQL + + execute <<~SQL + UPDATE ci_builds SET stage_id = NULL WHERE stage_id IN (#{redundant_stages_ids}) + SQL + + if Gitlab::Database.postgresql? + execute <<~SQL + DELETE FROM ci_stages WHERE id IN (#{redundant_stages_ids}) + SQL + else # We can't modify a table we are selecting from on MySQL + execute <<~SQL + DELETE a FROM ci_stages AS a, ci_stages AS b + WHERE a.pipeline_id = b.pipeline_id AND a.name = b.name + AND a.id <> b.id + SQL + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 432eb095746..d07a4c31618 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: 20180204200836) do +ActiveRecord::Schema.define(version: 20180206200543) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -155,6 +155,7 @@ ActiveRecord::Schema.define(version: 20180204200836) do t.integer "gitaly_timeout_medium", default: 30, null: false t.integer "gitaly_timeout_fast", default: 10, null: false t.boolean "authorized_keys_enabled", default: true, null: false + t.string "auto_devops_domain" end create_table "audit_events", force: :cascade do |t| @@ -450,7 +451,7 @@ ActiveRecord::Schema.define(version: 20180204200836) do t.integer "lock_version" end - add_index "ci_stages", ["pipeline_id", "name"], name: "index_ci_stages_on_pipeline_id_and_name", using: :btree + add_index "ci_stages", ["pipeline_id", "name"], name: "index_ci_stages_on_pipeline_id_and_name", unique: true, using: :btree add_index "ci_stages", ["pipeline_id"], name: "index_ci_stages_on_pipeline_id", using: :btree add_index "ci_stages", ["project_id"], name: "index_ci_stages_on_project_id", using: :btree @@ -946,6 +947,16 @@ ActiveRecord::Schema.define(version: 20180204200836) do add_index "labels", ["title"], name: "index_labels_on_title", using: :btree add_index "labels", ["type", "project_id"], name: "index_labels_on_type_and_project_id", using: :btree + create_table "lfs_file_locks", force: :cascade do |t| + t.integer "project_id", null: false + t.integer "user_id", null: false + t.datetime "created_at", null: false + t.string "path", limit: 511 + end + + add_index "lfs_file_locks", ["project_id", "path"], name: "index_lfs_file_locks_on_project_id_and_path", unique: true, using: :btree + add_index "lfs_file_locks", ["user_id"], name: "index_lfs_file_locks_on_user_id", using: :btree + create_table "lfs_objects", force: :cascade do |t| t.string "oid", null: false t.integer "size", limit: 8, null: false @@ -1997,6 +2008,8 @@ ActiveRecord::Schema.define(version: 20180204200836) do add_foreign_key "label_priorities", "projects", on_delete: :cascade add_foreign_key "labels", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "labels", "projects", name: "fk_7de4989a69", on_delete: :cascade + add_foreign_key "lfs_file_locks", "projects", on_delete: :cascade + add_foreign_key "lfs_file_locks", "users", on_delete: :cascade add_foreign_key "lists", "boards", name: "fk_0d3f677137", on_delete: :cascade add_foreign_key "lists", "labels", name: "fk_7a5553d60f", on_delete: :cascade add_foreign_key "members", "users", name: "fk_2e88fb7ce9", on_delete: :cascade diff --git a/doc/administration/index.md b/doc/administration/index.md index 0b199eecefd..e53268e5f3e 100644 --- a/doc/administration/index.md +++ b/doc/administration/index.md @@ -1,7 +1,7 @@ # Administrator documentation Learn how to administer your GitLab instance (Community Edition and -[Enterprise Editions](https://about.gitlab.com/gitlab-ee/)). +[Enterprise Editions](https://about.gitlab.com/products/)). Regular users don't have access to GitLab administration tools and settings. GitLab.com is administered by GitLab, Inc., therefore, only GitLab team members have diff --git a/doc/administration/job_artifacts.md b/doc/administration/job_artifacts.md index 33f8a69c249..d86a54daadd 100644 --- a/doc/administration/job_artifacts.md +++ b/doc/administration/job_artifacts.md @@ -87,10 +87,10 @@ _The artifacts are stored by default in ### Using object storage -In [GitLab Enterprise Edition Premium][eep] you can use an object storage like -AWS S3 to store the artifacts. +> Available in [GitLab Premium](https://about.gitlab.com/products/) and +[GitLab.com Silver](https://about.gitlab.com/gitlab-com/). -[Learn how to use the object storage option.][ee-os] +Use an [Object storage option][ee-os] like AWS S3 to store job artifacts. ## Expiring artifacts @@ -198,4 +198,3 @@ memory and disk I/O. [restart gitlab]: restart_gitlab.md "How to restart GitLab" [gitlab workhorse]: https://gitlab.com/gitlab-org/gitlab-workhorse "GitLab Workhorse repository" [ee-os]: https://docs.gitlab.com/ee/administration/job_artifacts.html#using-object-storage -[eep]: https://about.gitlab.com/gitlab-ee/ "GitLab Enterprise Edition Premium" diff --git a/doc/administration/repository_storage_types.md b/doc/administration/repository_storage_types.md index 21184fed6e9..c5b286f6804 100644 --- a/doc/administration/repository_storage_types.md +++ b/doc/administration/repository_storage_types.md @@ -87,6 +87,6 @@ prefixed with `#{namespace}/#{project_name}`, which is true for CI Cache and LFS | Pages | Yes | No | - | - | | Docker Registry | Yes | No | - | - | | CI Build Logs | No | No | - | - | -| CI Artifacts | No | No | Yes (EEP) | - | +| CI Artifacts | No | No | Yes (Premium) | - | | CI Cache | No | No | Yes | - | -| LFS Objects | Yes | No | Yes (EEP) | - | +| LFS Objects | Yes | No | Yes (Premium) | - | diff --git a/doc/api/search.md b/doc/api/search.md new file mode 100644 index 00000000000..1fba9c3fbb8 --- /dev/null +++ b/doc/api/search.md @@ -0,0 +1,797 @@ +# Search API + +[Introduced][ce-41763] in GitLab 10.5 + +Every API call to search must be authenticated. + +## Global Search API + +Search globally across the GitLab instance. + +``` +GET /search +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------| +| `scope` | string | yes | The scope to search in | +| `search` | string | yes | The search query | + +Search the expression within the specified scope. Currently these scopes are supported: projects, issues, merge_requests, milestones, snippet_titles, snippet_blobs. + +The response depends on the requested scope. + +### Scope: projects + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/search?scope=projects&search=flight +``` + +Example response: + +```json +[ + { + "id": 6, + "description": "Nobis sed ipsam vero quod cupiditate veritatis hic.", + "name": "Flight", + "name_with_namespace": "Twitter / Flight", + "path": "flight", + "path_with_namespace": "twitter/flight", + "created_at": "2017-09-05T07:58:01.621Z", + "default_branch": "master", + "tag_list":[], + "ssh_url_to_repo": "ssh://jarka@localhost:2222/twitter/flight.git", + "http_url_to_repo": "http://localhost:3000/twitter/flight.git", + "web_url": "http://localhost:3000/twitter/flight", + "avatar_url": null, + "star_count": 0, + "forks_count": 0, + "last_activity_at": "2018-01-31T09:56:30.902Z" + } +] +``` + +### Scope: issues + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/search?scope=issues&search=file +``` + +Example response: + +```json +[ + { + "id": 83, + "iid": 1, + "project_id": 12, + "title": "Add file", + "description": "Add first file", + "state": "opened", + "created_at": "2018-01-24T06:02:15.514Z", + "updated_at": "2018-02-06T12:36:23.263Z", + "closed_at": null, + "labels":[], + "milestone": null, + "assignees": [{ + "id": 20, + "name": "Ceola Deckow", + "username": "sammy.collier", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/c23d85a4f50e0ea76ab739156c639231?s=80&d=identicon", + "web_url": "http://localhost:3000/sammy.collier" + }], + "author": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "assignee": { + "id": 20, + "name": "Ceola Deckow", + "username": "sammy.collier", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/c23d85a4f50e0ea76ab739156c639231?s=80&d=identicon", + "web_url": "http://localhost:3000/sammy.collier" + }, + "user_notes_count": 0, + "upvotes": 0, + "downvotes": 0, + "due_date": null, + "confidential": false, + "discussion_locked": null, + "web_url": "http://localhost:3000/h5bp/7bp/subgroup-prj/issues/1", + "time_stats": { + "time_estimate": 0, + "total_time_spent": 0, + "human_time_estimate": null, + "human_total_time_spent": null + } + } +] +``` + +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. + +### Scope: merge_requests + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/search?scope=merge_requests&search=file +``` + +Example response: + +```json +[ + { + "id": 56, + "iid": 8, + "project_id": 6, + "title": "Add first file", + "description": "This is a test MR to add file", + "state": "opened", + "created_at": "2018-01-22T14:21:50.830Z", + "updated_at": "2018-02-06T12:40:33.295Z", + "target_branch": "master", + "source_branch": "jaja-test", + "upvotes": 0, + "downvotes": 0, + "author": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "assignee": { + "id": 5, + "name": "Jacquelyn Kutch", + "username": "abigail", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/3138c66095ee4bd11a508c2f7f7772da?s=80&d=identicon", + "web_url": "http://localhost:3000/abigail" + }, + "source_project_id": 6, + "target_project_id": 6, + "labels": [ + "ruby", + "tests" + ], + "work_in_progress": false, + "milestone": { + "id": 13, + "iid": 3, + "project_id": 6, + "title": "v2.0", + "description": "Qui aut qui eos dolor beatae itaque tempore molestiae.", + "state": "active", + "created_at": "2017-09-05T07:58:29.099Z", + "updated_at": "2017-09-05T07:58:29.099Z", + "due_date": null, + "start_date": null + }, + "merge_when_pipeline_succeeds": false, + "merge_status": "can_be_merged", + "sha": "78765a2d5e0a43585945c58e61ba2f822e4d090b", + "merge_commit_sha": null, + "user_notes_count": 0, + "discussion_locked": null, + "should_remove_source_branch": null, + "force_remove_source_branch": true, + "web_url": "http://localhost:3000/twitter/flight/merge_requests/8", + "time_stats": { + "time_estimate": 0, + "total_time_spent": 0, + "human_time_estimate": null, + "human_total_time_spent": null + } + } +] +``` + +### Scope: milestones + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/search?scope=milestones&search=release +``` + +Example response: + +```json +[ + { + "id": 44, + "iid": 1, + "project_id": 12, + "title": "next release", + "description": "Next release milestone", + "state": "active", + "created_at": "2018-02-06T12:43:39.271Z", + "updated_at": "2018-02-06T12:44:01.298Z", + "due_date": "2018-04-18", + "start_date": "2018-02-04" + } +] +``` + +### Scope: snippet_titles + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/search?scope=snippet_titles&search=sample +``` + +Example response: + +```json +[ + { + "id": 50, + "title": "Sample file", + "file_name": "file.rb", + "description": "Simple ruby file", + "author": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "updated_at": "2018-02-06T12:49:29.104Z", + "created_at": "2017-11-28T08:20:18.071Z", + "project_id": 9, + "web_url": "http://localhost:3000/root/jira-test/snippets/50" + } +] +``` + +### Scope: snippet_blobs + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/search?scope=snippet_blos&search=test +``` + +Example response: + +```json +[ + { + "id": 50, + "title": "Sample file", + "file_name": "file.rb", + "description": "Simple ruby file", + "author": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "updated_at": "2018-02-06T12:49:29.104Z", + "created_at": "2017-11-28T08:20:18.071Z", + "project_id": 9, + "web_url": "http://localhost:3000/root/jira-test/snippets/50" + } +] +``` + + +## Group Search API + +Search within the specified group. + +If a user is not a member of a group and the group is private, a `GET` request on that group will result to a `404` status code. + +``` +GET /groups/:id/-/search +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `scope` | string | yes | The scope to search in | +| `search` | string | yes | The search query | + +Search the expression within the specified scope. Currently these scopes are supported: projects, issues, merge_requests, milestones. + +The response depends on the requested scope. + +### Scope: projects + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/-/search?scope=projects&search=flight +``` + +Example response: + +```json +[ + { + "id": 6, + "description": "Nobis sed ipsam vero quod cupiditate veritatis hic.", + "name": "Flight", + "name_with_namespace": "Twitter / Flight", + "path": "flight", + "path_with_namespace": "twitter/flight", + "created_at": "2017-09-05T07:58:01.621Z", + "default_branch": "master", + "tag_list":[], + "ssh_url_to_repo": "ssh://jarka@localhost:2222/twitter/flight.git", + "http_url_to_repo": "http://localhost:3000/twitter/flight.git", + "web_url": "http://localhost:3000/twitter/flight", + "avatar_url": null, + "star_count": 0, + "forks_count": 0, + "last_activity_at": "2018-01-31T09:56:30.902Z" + } +] +``` + +### Scope: issues + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/-/search?scope=issues&search=file +``` + +Example response: + +```json +[ + { + "id": 83, + "iid": 1, + "project_id": 12, + "title": "Add file", + "description": "Add first file", + "state": "opened", + "created_at": "2018-01-24T06:02:15.514Z", + "updated_at": "2018-02-06T12:36:23.263Z", + "closed_at": null, + "labels":[], + "milestone": null, + "assignees": [{ + "id": 20, + "name": "Ceola Deckow", + "username": "sammy.collier", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/c23d85a4f50e0ea76ab739156c639231?s=80&d=identicon", + "web_url": "http://localhost:3000/sammy.collier" + }], + "author": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "assignee": { + "id": 20, + "name": "Ceola Deckow", + "username": "sammy.collier", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/c23d85a4f50e0ea76ab739156c639231?s=80&d=identicon", + "web_url": "http://localhost:3000/sammy.collier" + }, + "user_notes_count": 0, + "upvotes": 0, + "downvotes": 0, + "due_date": null, + "confidential": false, + "discussion_locked": null, + "web_url": "http://localhost:3000/h5bp/7bp/subgroup-prj/issues/1", + "time_stats": { + "time_estimate": 0, + "total_time_spent": 0, + "human_time_estimate": null, + "human_total_time_spent": null + } + } +] +``` + +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. + +### Scope: merge_requests + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/-/search?scope=merge_requests&search=file +``` + +Example response: + +```json +[ + { + "id": 56, + "iid": 8, + "project_id": 6, + "title": "Add first file", + "description": "This is a test MR to add file", + "state": "opened", + "created_at": "2018-01-22T14:21:50.830Z", + "updated_at": "2018-02-06T12:40:33.295Z", + "target_branch": "master", + "source_branch": "jaja-test", + "upvotes": 0, + "downvotes": 0, + "author": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "assignee": { + "id": 5, + "name": "Jacquelyn Kutch", + "username": "abigail", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/3138c66095ee4bd11a508c2f7f7772da?s=80&d=identicon", + "web_url": "http://localhost:3000/abigail" + }, + "source_project_id": 6, + "target_project_id": 6, + "labels": [ + "ruby", + "tests" + ], + "work_in_progress": false, + "milestone": { + "id": 13, + "iid": 3, + "project_id": 6, + "title": "v2.0", + "description": "Qui aut qui eos dolor beatae itaque tempore molestiae.", + "state": "active", + "created_at": "2017-09-05T07:58:29.099Z", + "updated_at": "2017-09-05T07:58:29.099Z", + "due_date": null, + "start_date": null + }, + "merge_when_pipeline_succeeds": false, + "merge_status": "can_be_merged", + "sha": "78765a2d5e0a43585945c58e61ba2f822e4d090b", + "merge_commit_sha": null, + "user_notes_count": 0, + "discussion_locked": null, + "should_remove_source_branch": null, + "force_remove_source_branch": true, + "web_url": "http://localhost:3000/twitter/flight/merge_requests/8", + "time_stats": { + "time_estimate": 0, + "total_time_spent": 0, + "human_time_estimate": null, + "human_total_time_spent": null + } + } +] +``` + +### Scope: milestones + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/-/search?scope=milestones&search=release +``` + +Example response: + +```json +[ + { + "id": 44, + "iid": 1, + "project_id": 12, + "title": "next release", + "description": "Next release milestone", + "state": "active", + "created_at": "2018-02-06T12:43:39.271Z", + "updated_at": "2018-02-06T12:44:01.298Z", + "due_date": "2018-04-18", + "start_date": "2018-02-04" + } +] +``` + +## Project Search API + +Search within the specified project. + +If a user is not a member of a project and the project is private, a `GET` request on that project will result to a `404` status code. + +``` +GET /projects/:id/-/search +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `scope` | string | yes | The scope to search in | +| `search` | string | yes | The search query | + +Search the expression within the specified scope. Currently these scopes are supported: issues, merge_requests, milestones, notes, wiki_blobs, commits, blobs. + +The response depends on the requested scope. + + +### Scope: issues + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/12/-/search?scope=issues&search=file +``` + +Example response: + +```json +[ + { + "id": 83, + "iid": 1, + "project_id": 12, + "title": "Add file", + "description": "Add first file", + "state": "opened", + "created_at": "2018-01-24T06:02:15.514Z", + "updated_at": "2018-02-06T12:36:23.263Z", + "closed_at": null, + "labels":[], + "milestone": null, + "assignees": [{ + "id": 20, + "name": "Ceola Deckow", + "username": "sammy.collier", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/c23d85a4f50e0ea76ab739156c639231?s=80&d=identicon", + "web_url": "http://localhost:3000/sammy.collier" + }], + "author": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "assignee": { + "id": 20, + "name": "Ceola Deckow", + "username": "sammy.collier", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/c23d85a4f50e0ea76ab739156c639231?s=80&d=identicon", + "web_url": "http://localhost:3000/sammy.collier" + }, + "user_notes_count": 0, + "upvotes": 0, + "downvotes": 0, + "due_date": null, + "confidential": false, + "discussion_locked": null, + "web_url": "http://localhost:3000/h5bp/7bp/subgroup-prj/issues/1", + "time_stats": { + "time_estimate": 0, + "total_time_spent": 0, + "human_time_estimate": null, + "human_total_time_spent": null + } + } +] +``` + +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. + +### Scope: merge_requests + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=merge_requests&search=file +``` + +Example response: + +```json +[ + { + "id": 56, + "iid": 8, + "project_id": 6, + "title": "Add first file", + "description": "This is a test MR to add file", + "state": "opened", + "created_at": "2018-01-22T14:21:50.830Z", + "updated_at": "2018-02-06T12:40:33.295Z", + "target_branch": "master", + "source_branch": "jaja-test", + "upvotes": 0, + "downvotes": 0, + "author": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "assignee": { + "id": 5, + "name": "Jacquelyn Kutch", + "username": "abigail", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/3138c66095ee4bd11a508c2f7f7772da?s=80&d=identicon", + "web_url": "http://localhost:3000/abigail" + }, + "source_project_id": 6, + "target_project_id": 6, + "labels": [ + "ruby", + "tests" + ], + "work_in_progress": false, + "milestone": { + "id": 13, + "iid": 3, + "project_id": 6, + "title": "v2.0", + "description": "Qui aut qui eos dolor beatae itaque tempore molestiae.", + "state": "active", + "created_at": "2017-09-05T07:58:29.099Z", + "updated_at": "2017-09-05T07:58:29.099Z", + "due_date": null, + "start_date": null + }, + "merge_when_pipeline_succeeds": false, + "merge_status": "can_be_merged", + "sha": "78765a2d5e0a43585945c58e61ba2f822e4d090b", + "merge_commit_sha": null, + "user_notes_count": 0, + "discussion_locked": null, + "should_remove_source_branch": null, + "force_remove_source_branch": true, + "web_url": "http://localhost:3000/twitter/flight/merge_requests/8", + "time_stats": { + "time_estimate": 0, + "total_time_spent": 0, + "human_time_estimate": null, + "human_total_time_spent": null + } + } +] +``` + +### Scope: milestones + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/12/-/search?scope=milestones&search=release +``` + +Example response: + +```json +[ + { + "id": 44, + "iid": 1, + "project_id": 12, + "title": "next release", + "description": "Next release milestone", + "state": "active", + "created_at": "2018-02-06T12:43:39.271Z", + "updated_at": "2018-02-06T12:44:01.298Z", + "due_date": "2018-04-18", + "start_date": "2018-02-04" + } +] +``` + +### Scope: notes + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=notes&search=maxime +``` + +Example response: + +```json +[ + { + "id": 191, + "body": "Harum maxime consequuntur et et deleniti assumenda facilis.", + "attachment": null, + "author": { + "id": 23, + "name": "User 1", + "username": "user1", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/111d68d06e2d317b5a59c2c6c5bad808?s=80&d=identicon", + "web_url": "http://localhost:3000/user1" + }, + "created_at": "2017-09-05T08:01:32.068Z", + "updated_at": "2017-09-05T08:01:32.068Z", + "system": false, + "noteable_id": 22, + "noteable_type": "Issue", + "noteable_iid": 2 + } +] +``` + +### Scope: wiki_blobs + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=wiki_blobs&search=bye +``` + +Example response: + +```json + +[ + { + "basename": "home", + "data": "hello\n\nand bye\n\nend", + "filename": "home.md", + "id": null, + "ref": "master", + "startline": 5 + } +] +``` + +### Scope: commits + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=commits&search=bye +``` + +Example response: + +```json + +[ + { + "id": "4109c2d872d5fdb1ed057400d103766aaea97f98", + "short_id": "4109c2d8", + "title": "goodbye $.browser", + "created_at": "2013-02-18T22:02:54.000Z", + "parent_ids": [ + "59d05353ab575bcc2aa958fe1782e93297de64c9" + ], + "message": "goodbye $.browser\n", + "author_name": "angus croll", + "author_email": "anguscroll@gmail.com", + "authored_date": "2013-02-18T22:02:54.000Z", + "committer_name": "angus croll", + "committer_email": "anguscroll@gmail.com", + "committed_date": "2013-02-18T22:02:54.000Z" + } +] +``` + +### Scope: blobs + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=blobs&search=installation +``` + +Example response: + +```json + +[ + { + "basename": "README", + "data": "```\n\n## Installation\n\nQuick start using the [pre-built", + "filename": "README.md", + "id": null, + "ref": "master", + "startline": 46 + } +] +``` + +[ce-41763]: https://gitlab.com/gitlab-org/gitlab-ce/issues/41763 diff --git a/doc/ci/examples/README.md b/doc/ci/examples/README.md index 0109e77935a..9f6b0c54990 100644 --- a/doc/ci/examples/README.md +++ b/doc/ci/examples/README.md @@ -43,7 +43,7 @@ There's also a collection of repositories with [example projects](https://gitlab ### Static Application Security Testing (SAST) -- **(EEU)** [Scan your code for vulnerabilities](https://docs.gitlab.com/ee/ci/examples/sast.html) +- **(Ultimate)** [Scan your code for vulnerabilities](https://docs.gitlab.com/ee/ci/examples/sast.html) - [Scan your Docker images for vulnerabilities](sast_docker.md) ### Dynamic Application Security Testing (DAST) diff --git a/doc/ci/examples/browser_performance.md b/doc/ci/examples/browser_performance.md index a7945d05cd0..7bd0514d406 100644 --- a/doc/ci/examples/browser_performance.md +++ b/doc/ci/examples/browser_performance.md @@ -22,7 +22,7 @@ Once you set up the Runner, add a new job to `.gitlab-ci.yml`, called `performan This will create a `performance` job in your CI/CD pipeline and will run Sitespeed.io against the webpage you define. The full HTML Sitespeed.io report will be saved as an artifact, and if you have Pages enabled it can be viewed directly in your browser. For further customization options of Sitespeed.io, including the ability to provide a list of URLs to test, please consult their [documentation](https://www.sitespeed.io/documentation/sitespeed.io/configuration/). -For GitLab [Enterprise Edition Premium](https://about.gitlab.com/gitlab-ee/) users, a performance score can be automatically +For [GitLab Premium](https://about.gitlab.com/products/) users, a performance score can be automatically extracted and shown right in the merge request widget. Learn more about [Browser Performance Testing](https://docs.gitlab.com/ee/user/project/merge_requests/browser_performance_testing.html). ## Performance testing on Review Apps diff --git a/doc/ci/examples/code_climate.md b/doc/ci/examples/code_climate.md index f919ed3c797..d7df53494ed 100644 --- a/doc/ci/examples/code_climate.md +++ b/doc/ci/examples/code_climate.md @@ -25,10 +25,10 @@ codequality: This will create a `codequality` job in your CI pipeline and will allow you to download and analyze the report artifact in JSON format. -For GitLab [Enterprise Edition Starter][ee] users, this information can be automatically +For [GitLab Starter][ee] users, this information can be automatically extracted and shown right in the merge request widget. [Learn more on code quality diffs in merge requests](https://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.html). [cli]: https://github.com/codeclimate/codeclimate [dind]: ../docker/using_docker_build.md#use-docker-in-docker-executor -[ee]: https://about.gitlab.com/gitlab-ee/ +[ee]: https://about.gitlab.com/products/ diff --git a/doc/ci/examples/dast.md b/doc/ci/examples/dast.md index 7bf647bbb8b..96de0f5ff5c 100644 --- a/doc/ci/examples/dast.md +++ b/doc/ci/examples/dast.md @@ -31,10 +31,10 @@ own) and finally write the results in the `gl-dast-report.json` file. You can then download and analyze the report artifact in JSON format. TIP: **Tip:** -Starting with [GitLab Enterprise Edition Ultimate][ee] 10.4, this information will +Starting with [GitLab Ultimate][ee] 10.4, this information will be automatically extracted and shown right in the merge request widget. To do so, the CI job must be named `dast` and the artifact path must be `gl-dast-report.json`. [Learn more about DAST results shown in merge requests](https://docs.gitlab.com/ee/user/project/merge_requests/dast.html). -[ee]: https://about.gitlab.com/gitlab-ee/ +[ee]: https://about.gitlab.com/products/ diff --git a/doc/ci/examples/sast_docker.md b/doc/ci/examples/sast_docker.md index d99cfe93afa..57a9c4bcfc1 100644 --- a/doc/ci/examples/sast_docker.md +++ b/doc/ci/examples/sast_docker.md @@ -46,10 +46,10 @@ them in a [YAML file](https://github.com/arminc/clair-scanner/blob/master/README in our case its named `clair-whitelist.yml`. TIP: **Tip:** -Starting with [GitLab Enterprise Edition Ultimate][ee] 10.4, this information will +Starting with [GitLab Ultimate][ee] 10.4, this information will be automatically extracted and shown right in the merge request widget. To do so, the CI/CD job must be named `sast:container` and the artifact path must be `gl-sast-container-report.json`. [Learn more on application security testing results shown in merge requests](https://docs.gitlab.com/ee/user/project/merge_requests/sast_docker.html). -[ee]: https://about.gitlab.com/gitlab-ee/ +[ee]: https://about.gitlab.com/products/ diff --git a/doc/ci/triggers/README.md b/doc/ci/triggers/README.md index 56a16f77e7f..47a576fdf5f 100644 --- a/doc/ci/triggers/README.md +++ b/doc/ci/triggers/README.md @@ -219,7 +219,7 @@ removed with one of the future versions of GitLab. You are advised to [ee-2017]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/2017 [ci-229]: https://gitlab.com/gitlab-org/gitlab-ci/merge_requests/229 -[ee]: https://about.gitlab.com/gitlab-ee/ +[ee]: https://about.gitlab.com/products/ [variables]: ../variables/README.md [predef]: ../variables/README.md#predefined-variables-environment-variables [registry]: ../../user/project/container_registry.md diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 598a7515b01..f30a85b114e 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -447,7 +447,7 @@ export CI_REGISTRY_PASSWORD="longalfanumstring" ``` [ce-13784]: https://gitlab.com/gitlab-org/gitlab-ce/issues/13784 "Simple protection of CI secret variables" -[eep]: https://about.gitlab.com/gitlab-ee/ "Available only in GitLab Enterprise Edition Premium" +[eep]: https://about.gitlab.com/products/ "Available only in GitLab Premium" [envs]: ../environments.md [protected branches]: ../../user/project/protected_branches.md [protected tags]: ../../user/project/protected_tags.md diff --git a/doc/ci/variables/img/secret_variables.png b/doc/ci/variables/img/secret_variables.png Binary files differindex f70935069d9..3c1aa361dc2 100644 --- a/doc/ci/variables/img/secret_variables.png +++ b/doc/ci/variables/img/secret_variables.png diff --git a/doc/development/i18n/externalization.md b/doc/development/i18n/externalization.md index f493ad4ae66..f4542932295 100644 --- a/doc/development/i18n/externalization.md +++ b/doc/development/i18n/externalization.md @@ -110,6 +110,8 @@ You can mark that content for translation with: In JavaScript we added the `__()` (double underscore parenthesis) function for translations. +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 diff --git a/doc/gitlab-basics/create-project.md b/doc/gitlab-basics/create-project.md index e18711f3392..7b87039da84 100644 --- a/doc/gitlab-basics/create-project.md +++ b/doc/gitlab-basics/create-project.md @@ -33,5 +33,40 @@ 1. Click **Create project**. +## Push to create a new project + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/26388) in GitLab 10.5. + +When you create a new repo locally, instead of going to GitLab to manually +create a new project and then push the repo, you can directly push it to +GitLab to create the new project, all without leaving your terminal. If you have access to that +namespace, we will automatically create a new project under that GitLab namespace with its +visibility set to private by default (you can later change it in the UI). + +This can be done by using either SSH or HTTP: + +``` +## Git push using SSH +git push git@gitlab.example.com:namespace/nonexistent-project.git + +## Git push using HTTP +git push https://gitlab.example.com/namespace/nonexistent-project.git +``` + +Once the push finishes successfully, a remote message will indicate +the command to set the remote and the URL to the new project: + +``` +remote: +remote: The private project namespace/nonexistent-project was created. +remote: +remote: To configure the remote, run: +remote: git remote add origin https://gitlab.example.com/namespace/nonexistent-project.git +remote: +remote: To view the project, visit: +remote: https://gitlab.example.com/namespace/nonexistent-project +remote: +``` + [import it]: ../workflow/importing/README.md [reserved]: ../user/reserved_names.md diff --git a/doc/install/kubernetes/gitlab_chart.md b/doc/install/kubernetes/gitlab_chart.md index 96968c1e3ab..84eeacac3fd 100644 --- a/doc/install/kubernetes/gitlab_chart.md +++ b/doc/install/kubernetes/gitlab_chart.md @@ -1,17 +1,17 @@ # GitLab Helm Chart -> **Note**: -* This chart is deprecated, and is being replaced by the [cloud native GitLab chart](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md). For more information on available charts, please see our [overview](index.md#chart-overview). -* These charts have been tested on Google Kubernetes Engine and Azure Container Service. Other Kubernetes installations may work as well, if not please [open an issue](https://gitlab.com/charts/charts.gitlab.io/issues). +> **Note:** +* This chart has been tested on Google Kubernetes Engine and Azure Container Service. + +**This chart is deprecated.** For small installations on Kubernetes today, we recommend the beta [`gitlab-omnibus` Helm chart](gitlab_omnibus.md). +A new [cloud native GitLab chart](index.md#cloud-native-gitlab-chart) is in development with increased scalability and resilience, among other benefits. The cloud native chart will replace both the `gitlab` and `gitlab-omnibus` charts when available later this year. -For more information on available GitLab Helm Charts, please see our [overview](index.md#chart-overview). +Due to the significant architectural changes, migrating will require backing up data out of this instance and restoring it into the new deployment. For more information on available GitLab Helm Charts, please see our [overview](index.md#chart-overview). ## Introduction The `gitlab` Helm chart deploys just GitLab into your Kubernetes cluster, and offers extensive configuration options. This chart requires advanced knowledge of Kubernetes to successfully use. We **strongly recommend** the [gitlab-omnibus](gitlab_omnibus.md) chart. -This chart is deprecated, and will be replaced by the [cloud native GitLab chart](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md). Due to the difficulty in supporting upgrades, migrating will require exporting data out of this instance and importing it into the new deployment. - This chart includes the following: - Deployment using the [gitlab-ce](https://hub.docker.com/r/gitlab/gitlab-ce) or [gitlab-ee](https://hub.docker.com/r/gitlab/gitlab-ee) container image diff --git a/doc/install/kubernetes/gitlab_omnibus.md b/doc/install/kubernetes/gitlab_omnibus.md index 5a5f8d67ff5..9c5258c2cdf 100644 --- a/doc/install/kubernetes/gitlab_omnibus.md +++ b/doc/install/kubernetes/gitlab_omnibus.md @@ -1,17 +1,18 @@ # GitLab-Omnibus Helm Chart -> **Note:** -* This Helm chart is in beta, and will be deprecated by the [cloud native GitLab chart](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md). -* These charts have been tested on Google Kubernetes Engine and Azure Container Service. Other Kubernetes installations may work as well, if not please [open an issue](https://gitlab.com/charts/charts.gitlab.io/issues). +> **Note:**. +* This chart has been tested on Google Kubernetes Engine and Azure Container Service. -This work is based partially on: https://github.com/lwolf/kubernetes-gitlab/. GitLab would like to thank Sergey Nuzhdin for his work. +**[This chart is beta](#limitations), and is the best way to install GitLab on Kubernetes today.** A new [cloud native GitLab chart](index.md#cloud-native-gitlab-chart) is in development with increased scalability and resilience, among other benefits. Once available, the cloud native chart will be the recommended installation method for Kubernetes, and this chart will be deprecated. For more information on available GitLab Helm Charts, please see our [overview](index.md#chart-overview). +This work is based partially on: https://github.com/lwolf/kubernetes-gitlab/. GitLab would like to thank Sergey Nuzhdin for his work. + ## Introduction This chart provides an easy way to get started with GitLab, provisioning an installation with nearly all functionality enabled. SSL is automatically provisioned via [Let's Encrypt](https://letsencrypt.org/). -This Helm chart is in beta, and will be deprecated by the [cloud native GitLab chart](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md) once available. Due to the difficulty in supporting upgrades, migrating will require exporting data out of this instance and importing it into the new deployment. +This Helm chart is in beta, and is suited for small to medium deployments. It will be deprecated by the [cloud native GitLab chart](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md) once available. Due to the significant architectural changes, migrating will require backing up data out of this instance and importing it into the new deployment. The deployment includes: diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index 0fab752afad..01bd925bd6f 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -198,13 +198,13 @@ static analysis and other code checks on the current code. The report is created, and is uploaded as an artifact which you can later download and check out. -In GitLab Enterprise Edition Starter, differences between the source and +In GitLab Starter, differences between the source and target branches are also [shown in the merge request widget](https://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.html). ### Auto SAST -> Introduced in [GitLab Enterprise Edition Ultimate][ee] 10.3. +> Introduced in [GitLab Ultimate][ee] 10.3. Static Application Security Testing (SAST) uses the [gl-sast Docker image](https://gitlab.com/gitlab-org/gl-sast) to run static @@ -212,7 +212,7 @@ analysis on the current code and checks for potential security issues. Once the report is created, it's uploaded as an artifact which you can later download and check out. -In GitLab Enterprise Edition Ultimate, any security warnings are also +In GitLab Ultimate, any security warnings are also [shown in the merge request widget](https://docs.gitlab.com/ee/user/project/merge_requests/sast.html). ### Auto SAST for Docker images @@ -225,7 +225,7 @@ Docker image and checks for potential security issues. Once the report is created, it's uploaded as an artifact which you can later download and check out. -In GitLab Enterprise Edition Ultimate, any security warnings are also +In GitLab Ultimate, any security warnings are also [shown in the merge request widget](https://docs.gitlab.com/ee/user/project/merge_requests/sast_docker.html). ### Auto Review Apps @@ -256,7 +256,7 @@ be deleted. ### Auto DAST -> Introduced in [GitLab Enterprise Edition Ultimate][ee] 10.4. +> Introduced in [GitLab Ultimate][ee] 10.4. Dynamic Application Security Testing (DAST) uses the popular open source tool [OWASP ZAProxy](https://github.com/zaproxy/zaproxy) @@ -264,12 +264,12 @@ to perform an analysis on the current code and checks for potential security issues. Once the report is created, it's uploaded as an artifact which you can later download and check out. -In GitLab Enterprise Edition Ultimate, any security warnings are also +In GitLab Ultimate, any security warnings are also [shown in the merge request widget](https://docs.gitlab.com/ee/user/project/merge_requests/dast.html). ### Auto Browser Performance Testing -> Introduced in [GitLab Enterprise Edition Premium][ee] 10.4. +> Introduced in [GitLab Premium][ee] 10.4. Auto Browser Performance Testing utilizes the [Sitespeed.io container](https://hub.docker.com/r/sitespeedio/sitespeed.io/) to measure the performance of a web page. A JSON report is created and uploaded as an artifact, which includes the overall performance score for each page. By default, the root page of Review and Production environments will be tested. If you would like to add additional URL's to test, simply add the paths to a file named `.gitlab-urls.txt` in the root directory, one per line. For example: @@ -279,7 +279,7 @@ Auto Browser Performance Testing utilizes the [Sitespeed.io container](https://h /direction ``` -In GitLab Enterprise Edition Premium, performance differences between the source and target branches are [shown in the merge request widget](https://docs.gitlab.com/ee/user/project/merge_requests/browser_performance_testing.html). +In GitLab Premium, performance differences between the source and target branches are [shown in the merge request widget](https://docs.gitlab.com/ee/user/project/merge_requests/browser_performance_testing.html). ### Auto Deploy @@ -593,4 +593,4 @@ curl --data "value=true" --header "PRIVATE-TOKEN: personal_access_token" https:/ [postgresql]: https://www.postgresql.org/ [Auto DevOps template]: https://gitlab.com/gitlab-org/gitlab-ci-yml/blob/master/Auto-DevOps.gitlab-ci.yml [GitLab Omnibus Helm Chart]: ../../install/kubernetes/gitlab_omnibus.md -[ee]: https://about.gitlab.com/gitlab-ee/ +[ee]: https://about.gitlab.com/products/ diff --git a/doc/topics/autodevops/quick_start_guide.md b/doc/topics/autodevops/quick_start_guide.md index 4858735ee86..15567715c98 100644 --- a/doc/topics/autodevops/quick_start_guide.md +++ b/doc/topics/autodevops/quick_start_guide.md @@ -102,6 +102,11 @@ running: kubectl get svc ruby-app-nginx-ingress-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip}' ``` +NOTE: **Note:** +If your ingress controller has been installed in a different way, you can find +how to get the external IP address in the +[Cluster documentation](../../user/project/clusters/index.md#getting-the-external-ip-address). + Use this IP address to configure your DNS. This part heavily depends on your preferences and domain provider. But in case you are not sure, just create an A record with a wildcard host like `*.<your-domain>`. diff --git a/doc/update/mysql_to_postgresql.md b/doc/update/mysql_to_postgresql.md index fff47180099..44e9f6c5516 100644 --- a/doc/update/mysql_to_postgresql.md +++ b/doc/update/mysql_to_postgresql.md @@ -1,13 +1,15 @@ --- -last_updated: 2017-10-05 +last_updated: 2018-02-07 --- # Migrating from MySQL to PostgreSQL -> **Note:** This guide assumes you have a working Omnibus GitLab instance with +> **Note:** This guide assumes you have a working GitLab instance with > MySQL and want to migrate to bundled PostgreSQL database. -## Prerequisites +## Omnibus installation + +### Prerequisites First, we'll need to enable the bundled PostgreSQL database with up-to-date schema. Next, we'll use [pgloader](http://pgloader.io) to migrate the data @@ -19,7 +21,7 @@ Here's what you'll need to have installed: - Omnibus GitLab - MySQL -## Enable bundled PostgreSQL database +### Enable bundled PostgreSQL database 1. Stop GitLab: @@ -65,7 +67,7 @@ Here's what you'll need to have installed: After these steps, you'll have a fresh PostgreSQL database with up-to-date schema. -## Migrate data from MySQL to PostgreSQL +### Migrate data from MySQL to PostgreSQL Now, you can use pgloader to migrate the data from MySQL to PostgreSQL: @@ -104,122 +106,9 @@ the following: ----------------------------------------------- --------- --------- --------- -------------- public.abuse_reports 0 0 0 0.490s public.appearances 0 0 0 0.488s - public.approvals 0 0 0 0.273s - public.application_settings 1 1 0 0.266s - public.approvers 0 0 0 0.339s - public.approver_groups 0 0 0 0.357s - public.audit_events 1 1 0 0.410s - public.award_emoji 0 0 0 0.441s - public.boards 0 0 0 0.505s - public.broadcast_messages 0 0 0 0.498s - public.chat_names 0 0 0 0.576s - public.chat_teams 0 0 0 0.617s - public.ci_builds 0 0 0 0.611s - public.ci_group_variables 0 0 0 0.620s - public.ci_pipelines 0 0 0 0.599s - public.ci_pipeline_schedules 0 0 0 0.622s - public.ci_pipeline_schedule_variables 0 0 0 0.573s - public.ci_pipeline_variables 0 0 0 0.594s - public.ci_runners 0 0 0 0.533s - public.ci_runner_projects 0 0 0 0.584s - public.ci_sources_pipelines 0 0 0 0.564s - public.ci_stages 0 0 0 0.595s - public.ci_triggers 0 0 0 0.569s - public.ci_trigger_requests 0 0 0 0.596s - public.ci_variables 0 0 0 0.565s - public.container_repositories 0 0 0 0.605s - public.conversational_development_index_metrics 0 0 0 0.571s - public.deployments 0 0 0 0.607s - public.emails 0 0 0 0.602s - public.deploy_keys_projects 0 0 0 0.557s - public.events 160 160 0 0.677s - public.environments 0 0 0 0.567s - public.features 0 0 0 0.639s - public.events_for_migration 160 160 0 0.582s - public.feature_gates 0 0 0 0.579s - public.forked_project_links 0 0 0 0.660s - public.geo_nodes 0 0 0 0.686s - public.geo_event_log 0 0 0 0.626s - public.geo_repositories_changed_events 0 0 0 0.677s - public.geo_node_namespace_links 0 0 0 0.618s - public.geo_repository_renamed_events 0 0 0 0.696s - public.gpg_keys 0 0 0 0.704s - public.geo_repository_deleted_events 0 0 0 0.638s - public.historical_data 0 0 0 0.729s - public.geo_repository_updated_events 0 0 0 0.634s - public.index_statuses 0 0 0 0.746s - public.gpg_signatures 0 0 0 0.667s - public.issue_assignees 80 80 0 0.769s - public.identities 0 0 0 0.655s - public.issue_metrics 80 80 0 0.781s - public.issues 80 80 0 0.720s - public.labels 0 0 0 0.795s - public.issue_links 0 0 0 0.707s - public.label_priorities 0 0 0 0.793s - public.keys 0 0 0 0.734s - public.lfs_objects 0 0 0 0.812s - public.label_links 0 0 0 0.725s - public.licenses 0 0 0 0.813s - public.ldap_group_links 0 0 0 0.751s - public.members 52 52 0 0.830s - public.lfs_objects_projects 0 0 0 0.738s - public.merge_requests_closing_issues 0 0 0 0.825s - public.lists 0 0 0 0.769s - public.merge_request_diff_commits 0 0 0 0.840s - public.merge_request_metrics 0 0 0 0.837s - public.merge_requests 0 0 0 0.753s - public.merge_request_diffs 0 0 0 0.771s - public.namespaces 30 30 0 0.874s - public.merge_request_diff_files 0 0 0 0.775s - public.notes 0 0 0 0.849s - public.milestones 40 40 0 0.799s - public.oauth_access_grants 0 0 0 0.979s - public.namespace_statistics 0 0 0 0.797s - public.oauth_applications 0 0 0 0.899s - public.notification_settings 72 72 0 0.818s - public.oauth_access_tokens 0 0 0 0.807s - public.pages_domains 0 0 0 0.958s - public.oauth_openid_requests 0 0 0 0.832s - public.personal_access_tokens 0 0 0 0.965s - public.projects 8 8 0 0.987s - public.path_locks 0 0 0 0.925s - public.plans 0 0 0 0.923s - public.project_features 8 8 0 0.985s - public.project_authorizations 66 66 0 0.969s - public.project_import_data 8 8 0 1.002s - public.project_statistics 8 8 0 1.001s - public.project_group_links 0 0 0 0.949s - public.project_mirror_data 0 0 0 0.972s - public.protected_branch_merge_access_levels 0 0 0 1.017s - public.protected_branches 0 0 0 0.969s - public.protected_branch_push_access_levels 0 0 0 0.991s - public.protected_tags 0 0 0 1.009s - public.protected_tag_create_access_levels 0 0 0 0.985s - public.push_event_payloads 0 0 0 1.041s - public.push_rules 0 0 0 0.999s - public.redirect_routes 0 0 0 1.020s - public.remote_mirrors 0 0 0 1.034s - public.releases 0 0 0 0.993s - public.schema_migrations 896 896 0 1.057s - public.routes 38 38 0 1.021s - public.services 0 0 0 1.055s - public.sent_notifications 0 0 0 1.003s - public.slack_integrations 0 0 0 1.022s - public.spam_logs 0 0 0 1.024s - public.snippets 0 0 0 1.058s - public.subscriptions 0 0 0 1.069s - public.taggings 0 0 0 1.099s - public.timelogs 0 0 0 1.104s - public.system_note_metadata 0 0 0 1.038s - public.tags 0 0 0 1.034s - public.trending_projects 0 0 0 1.140s - public.uploads 0 0 0 1.129s - public.todos 80 80 0 1.085s - public.users_star_projects 0 0 0 1.153s - public.u2f_registrations 0 0 0 1.061s - public.web_hooks 0 0 0 1.179s - public.users 26 26 0 1.163s - public.user_agent_details 0 0 0 1.068s + . + . + . public.web_hook_logs 0 0 0 1.080s ----------------------------------------------- --------- --------- --------- -------------- COPY Threads Completion 4 4 0 2.008s @@ -240,9 +129,9 @@ the following: Now, you can verify that everything worked by visiting GitLab. -## Troubleshooting +### Troubleshooting -### Permissions +#### Permissions Note that the PostgreSQL user that you use for the above MUST have **superuser** privileges. Otherwise, you may see a similar message to the following: @@ -256,7 +145,7 @@ debugger invoked on a CL-POSTGRES-ERROR:INSUFFICIENT-PRIVILEGE in thread QUERY: ALTER TABLE approver_groups DISABLE TRIGGER ALL; ``` -### Experiencing 500 errors after the migration +#### Experiencing 500 errors after the migration If you experience 500 errors after the migration, try to clear the cache: @@ -265,3 +154,130 @@ sudo gitlab-rake cache:clear ``` [reconfigure GitLab]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure + +## Source installation + +### Prerequisites + +#### Install PostgreSQL and create database + +See [installation guide](../install/installation.md#6-database). + +#### Install [pgloader](http://pgloader.io) 3.4.1+ + +Install directly from your distro: +``` bash +sudo apt-get install pgloader +``` + +If this version is too old, use PostgreSQL's repository: +``` bash +# add repository +sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' + +# add key +sudo apt-get install wget ca-certificates +wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - + +# install package +sudo apt-get update +sudo apt-get install pgloader +``` + +### Enable bundled PostgreSQL database + +1. Stop GitLab: + + ``` bash + sudo service gitlab stop + ``` + +1. Switch database from MySQL to PostgreSQL + + ``` bash + cd /home/git/gitlab + sudo -u git mv config/database.yml config/database.yml.bak + sudo -u git cp config/database.yml.postgresql config/database.yml + sudo -u git -H chmod o-rwx config/database.yml + ``` + +1. Run the following commands to prepare the schema: + + ``` bash + sudo -u git -H bundle exec rake db:create db:migrate RAILS_ENV=production + ``` + +After these steps, you'll have a fresh PostgreSQL database with up-to-date schema. + +### Migrate data from MySQL to PostgreSQL + +Now, you can use pgloader to migrate the data from MySQL to PostgreSQL: + +1. Save the following snippet in a `commands.load` file, and edit with your + MySQL `username`, `password` and `host`: + + ``` + LOAD DATABASE + FROM mysql://username:password@host/gitlabhq_production + INTO postgresql://postgres@unix://var/run/postgresql:/gitlabhq_production + + WITH include no drop, truncate, disable triggers, create no tables, + create no indexes, preserve index names, no foreign keys, + data only + + ALTER SCHEMA 'gitlabhq_production' RENAME TO 'public' + + ; + ``` + +1. Start the migration: + + ``` bash + sudo -u postgres pgloader commands.load + ``` + +1. Once the migration finishes, you should see a summary table that looks like +the following: + + + ``` + table name read imported errors total time + ----------------------------------------------- --------- --------- --------- -------------- + fetch meta data 119 119 0 0.388s + Truncate 119 119 0 1.134s + ----------------------------------------------- --------- --------- --------- -------------- + public.abuse_reports 0 0 0 0.490s + public.appearances 0 0 0 0.488s + . + . + . + public.web_hook_logs 0 0 0 1.080s + ----------------------------------------------- --------- --------- --------- -------------- + COPY Threads Completion 4 4 0 2.008s + Reset Sequences 113 113 0 0.304s + Install Comments 0 0 0 0.000s + ----------------------------------------------- --------- --------- --------- -------------- + Total import time 1894 1894 0 12.497s + ``` + + If there is no output for more than 30 minutes, it's possible pgloader encountered an error. See + the [troubleshooting guide](#Troubleshooting) for more details. + +1. Start GitLab: + + ``` bash + sudo service gitlab start + ``` + +Now, you can verify that everything worked by visiting GitLab. + +### Troubleshooting + +#### Experiencing 500 errors after the migration + +If you experience 500 errors after the migration, try to clear the cache: + +``` bash +sudo -u git -H bundle exec rake cache:clear RAILS_ENV=production +``` + diff --git a/doc/user/group/index.md b/doc/user/group/index.md index f78e5089886..88efddbfba8 100644 --- a/doc/user/group/index.md +++ b/doc/user/group/index.md @@ -245,20 +245,22 @@ To enable this feature, navigate to the group settings page. Select ![Checkbox for share with group lock](img/share_with_group_lock.png) -#### Member Lock (EES/EEP) +#### Member Lock -Available in [GitLab Enterprise Edition Starter](https://about.gitlab.com/gitlab-ee/), -with **Member Lock** it is possible to lock membership in project to the +> Available in [GitLab Starter](https://about.gitlab.com/products/) and +[GitLab.com Bronze](https://about.gitlab.com/gitlab-com/). + +With **Member Lock** it is possible to lock membership in project to the level of members in group. -Learn more about [Member Lock](https://docs.gitlab.com/ee/user/group/index.html#member-lock-ees-eep). +Learn more about [Member Lock](https://docs.gitlab.com/ee/user/group/index.html#member-lock). ### Advanced settings - **Projects**: view all projects within that group, add members to each project, access each project's settings, and remove any project from the same screen. - **Webhooks**: configure [webhooks](../project/integrations/webhooks.md) -and [push rules](https://docs.gitlab.com/ee/push_rules/push_rules.html#push-rules) to your group (Push Rules is available in [GitLab Enteprise Edition Starter](https://about.gitlab.com/products/).) +and [push rules](https://docs.gitlab.com/ee/push_rules/push_rules.html#push-rules) to your group (Push Rules is available in [GitLab Starter](https://about.gitlab.com/products/).) - **Audit Events**: view [Audit Events](https://docs.gitlab.com/ee/administration/audit_events.html#audit-events) -for the group (GitLab admins only, available in [GitLab Enterprise Edition Starter][ee]). +for the group (GitLab admins only, available in [GitLab Starter][ee]). - **Pipelines quota**: keep track of the [pipeline quota](../admin_area/settings/continuous_integration.md) for the group diff --git a/doc/user/permissions.md b/doc/user/permissions.md index 708d07fcec9..914a80bcd6a 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -117,14 +117,16 @@ and drag issues around. Read though the [documentation on Issue Boards permissions](project/issue_board.md#permissions) to learn more. -### File Locking permissions (EEP) +### File Locking permissions + +> Available in [GitLab Premium](https://about.gitlab.com/products/). The user that locks a file or directory is the only one that can edit and push their changes back to the repository where the locked objects are located. Read through the documentation on [permissions for File Locking](https://docs.gitlab.com/ee/user/project/file_lock.html#permissions-on-file-locking) to learn more. File Locking is available in -[GitLab Enterprise Edition Premium](https://about.gitlab.com/gitlab-ee/) only. +[GitLab Premium](https://about.gitlab.com/products/) only. ### Confidential Issues permissions @@ -251,12 +253,14 @@ for details about the pipelines security model. Since GitLab 8.15, LDAP user permissions can now be manually overridden by an admin user. Read through the documentation on [LDAP users permissions](https://docs.gitlab.com/ee/articles/how_to_configure_ldap_gitlab_ee/index.html#updating-user-permissions-new-feature) to learn more. -## Auditor users permissions (EEP) +## Auditor users permissions + +> Available in [GitLab Premium](https://about.gitlab.com/products/). An Auditor user should be able to access all projects and groups of a GitLab instance with the permissions described on the documentation on [auditor users permissions](https://docs.gitlab.com/ee/administration/auditor_users.html#permissions-and-restrictions-of-an-auditor-user). -Auditor users are available in [GitLab Enterprise Edition Premium](https://about.gitlab.com/gitlab-ee/) +Auditor users are available in [GitLab Premium](https://about.gitlab.com/products/) only. [^1]: On public and internal projects, all users are able to perform this action diff --git a/doc/user/profile/account/delete_account.md b/doc/user/profile/account/delete_account.md index e7596f5c577..910bd20f882 100644 --- a/doc/user/profile/account/delete_account.md +++ b/doc/user/profile/account/delete_account.md @@ -1,7 +1,7 @@ # Deleting a User Account - As a user, you can delete your own account by navigating to **Settings** > **Account** and selecting **Delete account** -- As an admin, you can delete a user account by navigating to the **Admin Area**, selecting the **Users** tab, selecting a user, and clicking on **Remove user** +- As an admin, you can delete a user account by navigating to the **Admin Area**, selecting the **Users** tab, selecting a user, and clicking on **Delete user** ## Associated Records diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md index e87b4403854..50a8e0d5ec5 100644 --- a/doc/user/project/clusters/index.md +++ b/doc/user/project/clusters/index.md @@ -134,6 +134,41 @@ added directly to your configured cluster. Those applications are needed for | [Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) | 10.2+ | Ingress can provide load balancing, SSL termination, and name-based virtual hosting. It acts as a web proxy for your applications and is useful if you want to use [Auto DevOps](../../../topics/autodevops/index.md) or deploy your own web apps. | | [Prometheus](https://prometheus.io/docs/introduction/overview/) | 10.4+ | Prometheus is an open-source monitoring and alerting system useful to supervise your deployed applications | +## Getting the external IP address + +NOTE: **Note:** +You need a load balancer installed in your cluster in order to obtain the +external IP address with the following procedure. It can be deployed using the +**Ingress** application described in the previous section. + +In order to publish your web application, you first need to find the external IP +address associated to your load balancer. + +If the cluster is on GKE, click on the **Google Kubernetes Engine** link in the +**Advanced settings**, or go directly to the +[Google Kubernetes Engine dashboard](https://console.cloud.google.com/kubernetes/) +and select the proper project and cluster. Then click on **Connect** and execute +the `gcloud` command in a local terminal or using the **Cloud Shell**. + +If the cluster is not on GKE, follow the specific instructions for your +Kubernetes provider to configure `kubectl` with the right credentials. + +If you installed the Ingress using the **Applications** section, run the following command: + +```bash +kubectl get svc --namespace=gitlab-managed-apps ingress-nginx-ingress-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip} ' +``` + +Otherwise, you can list the IP addresses of all load balancers: + +```bash +kubectl get svc --all-namespaces -o jsonpath='{range.items[?(@.status.loadBalancer.ingress)]}{.status.loadBalancer.ingress[*].ip} ' +``` + +The output is the external IP address of your cluster. This information can then +be used to set up DNS entries and forwarding rules that allow external access to +your deployed applications. + ## Setting the environment scope When adding more than one clusters, you need to differentiate them with an @@ -190,9 +225,9 @@ The result will then be: ## Multiple Kubernetes clusters -> Introduced in [GitLab Enterprise Edition Premium][ee] 10.3. +> Introduced in [GitLab Premium][ee] 10.3. -With GitLab EEP, you can associate more than one Kubernetes clusters to your +With GitLab Premium, you can associate more than one Kubernetes clusters to your project. That way you can have different clusters for different environments, like dev, staging, production, etc. @@ -249,9 +284,9 @@ and [add a cluster](#adding-a-cluster) again. Here's what you can do with GitLab if you enable the Kubernetes integration. -### Deploy Boards (EEP) +### Deploy Boards -> Available in [GitLab Enterprise Edition Premium][ee]. +> Available in [GitLab Premium][ee]. GitLab's Deploy Boards offer a consolidated view of the current health and status of each CI [environment](../../../ci/environments.md) running on Kubernetes, @@ -261,9 +296,9 @@ workflow they already use without any need to access Kubernetes. [> Read more about Deploy Boards](https://docs.gitlab.com/ee/user/project/deploy_boards.html) -### Canary Deployments (EEP) +### Canary Deployments -> Available in [GitLab Enterprise Edition Premium][ee]. +> Available in [GitLab Premium][ee]. Leverage [Kubernetes' Canary deployments](https://kubernetes.io/docs/concepts/cluster-administration/manage-deployment/#canary-deployments) and visualize your canary deployments right inside the Deploy Board, without @@ -303,4 +338,4 @@ the deployment variables above, ensuring any pods you create are labelled with `app=$CI_ENVIRONMENT_SLUG`. GitLab will do the rest! [permissions]: ../../permissions.md -[ee]: https://about.gitlab.com/gitlab-ee/ +[ee]: https://about.gitlab.com/products/ diff --git a/doc/user/project/index.md b/doc/user/project/index.md index 77eba8eda7c..175a8975ae1 100644 --- a/doc/user/project/index.md +++ b/doc/user/project/index.md @@ -17,7 +17,7 @@ When you create a project in GitLab, you'll have access to a large number of - [Issue tracker](issues/index.md): Discuss implementations with your team within issues - [Issue Boards](issue_board.md): Organize and prioritize your workflow - - [Multiple Issue Boards](https://docs.gitlab.com/ee/user/project/issue_board.html#multiple-issue-boards) (**EES/EEP**): Allow your teams to create their own workflows (Issue Boards) for the same project + - [Multiple Issue Boards](https://docs.gitlab.com/ee/user/project/issue_board.html#multiple-issue-boards) (**Starter/Premium**): Allow your teams to create their own workflows (Issue Boards) for the same project - [Repositories](repository/index.md): Host your code in a fully integrated platform - [Branches](repository/branches/index.md): use Git branching strategies to @@ -29,7 +29,7 @@ integrated platform - [Signing commits](gpg_signed_commits/index.md): use GPG to sign your commits - [Merge Requests](merge_requests/index.md): Apply your branching strategy and get reviewed by your team - - [Merge Request Approvals](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html) (**EES/EEP**): Ask for approval before + - [Merge Request Approvals](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html) (**Starter/Premium**): Ask for approval before implementing a change - [Fix merge conflicts from the UI](merge_requests/resolve_conflicts.md): Your Git diff tool right from GitLab's UI diff --git a/doc/user/project/integrations/kubernetes.md b/doc/user/project/integrations/kubernetes.md index 543baaa81e1..f502d1c9821 100644 --- a/doc/user/project/integrations/kubernetes.md +++ b/doc/user/project/integrations/kubernetes.md @@ -81,9 +81,9 @@ GitLab CI/CD build environment: Here's what you can do with GitLab if you enable the Kubernetes integration. -### Deploy Boards (EEP) +### Deploy Boards -> Available in [GitLab Enterprise Edition Premium][ee]. +> Available in [GitLab Premium][ee]. GitLab's Deploy Boards offer a consolidated view of the current health and status of each CI [environment](../../../ci/environments.md) running on Kubernetes, @@ -93,9 +93,9 @@ workflow they already use without any need to access Kubernetes. [> Read more about Deploy Boards](https://docs.gitlab.com/ee/user/project/deploy_boards.html) -### Canary Deployments (EEP) +### Canary Deployments -> Available in [GitLab Enterprise Edition Premium][ee]. +> Available in [GitLab Premium][ee]. Leverage [Kubernetes' Canary deployments](https://kubernetes.io/docs/concepts/cluster-administration/manage-deployment/#canary-deployments) and visualize your canary deployments right inside the Deploy Board, without @@ -134,4 +134,4 @@ containers. To use this integration, you should deploy to Kubernetes using the deployment variables above, ensuring any pods you create are labelled with `app=$CI_ENVIRONMENT_SLUG`. GitLab will do the rest! -[ee]: https://about.gitlab.com/gitlab-ee/ +[ee]: https://about.gitlab.com/products/ diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md index 8c2690ec3b2..bc6306927e1 100644 --- a/doc/user/project/issue_board.md +++ b/doc/user/project/issue_board.md @@ -34,7 +34,7 @@ and deploy from one single platform. Issue Boards help you to visualize and manage the entire process _in_ GitLab. With [Multiple Issue Boards](https://docs.gitlab.com/ee/user/project/issue_board.html#multiple-issue-boards), available -only in [GitLab Enterprise Edition](https://about.gitlab.com/gitlab-ee/), +only in [GitLab Ultimate](https://about.gitlab.com/products/), you go even further, as you can not only keep yourself and your project organized from a broader perspective with one Issue Board per project, but also allow your team members to organize their own workflow by creating diff --git a/doc/user/project/issues/index.md b/doc/user/project/issues/index.md index 3e81dcb78c6..88acd8edbe2 100644 --- a/doc/user/project/issues/index.md +++ b/doc/user/project/issues/index.md @@ -35,7 +35,7 @@ your project public, open to collaboration. ### Streamline collaboration With [Multiple Assignees for Issues](https://docs.gitlab.com/ee/user/project/issues/multiple_assignees_for_issues.html), -available in [GitLab Enterprise Edition Starter](https://about.gitlab.com/gitlab-ee/) +available in [GitLab Starter](https://about.gitlab.com/products/) you can streamline collaboration and allow shared responsibilities to be clearly displayed. All assignees are shown across your workflows and receive notifications (as they would as single assignees), simplifying communication and ownership. @@ -141,7 +141,7 @@ Find GitLab Issue Boards by navigating to your **Project's Dashboard** > **Issue Read through the documentation for [Issue Boards](../issue_board.md) to find out more about this feature. -With [GitLab Enterprise Edition Starter](https://about.gitlab.com/gitlab-ee/), you can also +With [GitLab Starter](https://about.gitlab.com/products/), you can also create various boards per project with [Multiple Issue Boards](https://docs.gitlab.com/ee/user/project/issue_board.html#multiple-issue-boards). ### External Issue Tracker diff --git a/doc/user/project/issues/issues_functionalities.md b/doc/user/project/issues/issues_functionalities.md index 66140f389af..0bef83d18e8 100644 --- a/doc/user/project/issues/issues_functionalities.md +++ b/doc/user/project/issues/issues_functionalities.md @@ -41,9 +41,10 @@ it's reassigned to someone else to take it from there. if a user is not member of that project, it can only be assigned to them if they created the issue themselves. -##### 3.1. Multiple Assignees (EES/EEP) +##### 3.1. Multiple Assignees -Multiple Assignees are only available in [GitLab Enterprise Edition](https://about.gitlab.com/gitlab-ee/). +> Available in [GitLab Starter](https://about.gitlab.com/products/) and +[GitLab.com Bronze](https://about.gitlab.com/gitlab-com/). Often multiple people likely work on the same issue together, which can especially be difficult to track in large teams @@ -88,9 +89,10 @@ but they are immediately available to all projects in the group. > **Tip:** if the label doesn't exist yet, when you click **Edit**, it opens a dropdown menu from which you can select **Create new label**. -#### 8. Weight (EES/EEP) +#### 8. Weight -Issue Weights are only available in [GitLab Enterprise Edition](https://about.gitlab.com/gitlab-ee/). +> Available in [GitLab Starter](https://about.gitlab.com/products/) and +[GitLab.com Bronze](https://about.gitlab.com/gitlab-com/). - Attribute a weight (in a 0 to 9 range) to that issue. Easy to complete should weight 1 and very hard to complete should weight 9. diff --git a/doc/user/project/merge_requests/index.md b/doc/user/project/merge_requests/index.md index 7037d7f5989..aa3266cb457 100644 --- a/doc/user/project/merge_requests/index.md +++ b/doc/user/project/merge_requests/index.md @@ -31,10 +31,10 @@ With GitLab merge requests, you can: 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 Enterprise Edition Premium) -- Request [approvals](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html) from your managers (available in GitLab Enterprise Edition 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 Enterprise Edition 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 Enterprise Edition Starter) +- 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) ## Use cases @@ -42,10 +42,10 @@ A. Consider you are a software developer working in a team: 1. You checkout a new branch, and submit your changes through a merge request 1. You gather feedback from your team -1. You work on the implementation optimizing code with [Code Quality reports](https://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.html) (available in GitLab Enterprise Edition Starter) +1. You work on the implementation optimizing code with [Code Quality reports](https://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.html) (available in GitLab Starter) 1. You build and test your changes with GitLab CI/CD 1. You request the approval from your manager -1. Your manager pushes a commit with his final review, [approves the merge request](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html), and set it to [merge when pipeline succeeds](#merge-when-pipeline-succeeds) (Merge Request Approvals are available in GitLab Enterprise Edition Starter) +1. Your manager pushes a commit with his final review, [approves the merge request](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html), and set it to [merge when pipeline succeeds](#merge-when-pipeline-succeeds) (Merge Request Approvals are available in GitLab Starter) 1. Your changes get deployed to production with [manual actions](../../../ci/yaml/README.md#manual-actions) for GitLab CI/CD 1. Your implementations were successfully shipped to your customer @@ -55,8 +55,8 @@ B. Consider you're a web developer writing a webpage for your company's: 1. You gather feedback from your reviewers 1. Your changes are previewed with [Review Apps](../../../ci/review_apps/index.md) 1. You request your web designers for their implementation -1. You request the [approval](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html) from your manager (available in GitLab Enterprise Edition Starter) -1. Once approved, your merge request is [squashed and merged](https://docs.gitlab.com/ee/user/project/merge_requests/squash_and_merge.html), and [deployed to staging with GitLab Pages](https://about.gitlab.com/2016/08/26/ci-deployment-and-environments/) (Squash and Merge is available in GitLab Enterprise Edition Starter) +1. You request the [approval](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html) from your manager (available in GitLab Starter) +1. Once approved, your merge request is [squashed and merged](https://docs.gitlab.com/ee/user/project/merge_requests/squash_and_merge.html), and [deployed to staging with GitLab Pages](https://about.gitlab.com/2016/08/26/ci-deployment-and-environments/) (Squash and Merge is available in GitLab Starter) 1. Your production team [cherry picks](#cherry-pick-changes) the merge commit into production ## Merge requests per project @@ -287,4 +287,4 @@ git checkout origin/merge-requests/1 ``` [protected branches]: ../protected_branches.md -[ee]: https://about.gitlab.com/gitlab-ee/ "GitLab Enterprise Edition" +[ee]: https://about.gitlab.com/products/ "GitLab Enterprise Edition" diff --git a/doc/user/project/pages/introduction.md b/doc/user/project/pages/introduction.md index f52f66f518a..0b5076b8c5d 100644 --- a/doc/user/project/pages/introduction.md +++ b/doc/user/project/pages/introduction.md @@ -316,6 +316,47 @@ or various static site generators. Contributions are very welcome. Visit the GitLab Pages group for a full list of example projects: <https://gitlab.com/groups/pages>. +### Serving compressed assets + +Most modern browsers support downloading files in a compressed format. This +speeds up downloads by reducing the size of files. + +Before serving an uncompressed file, Pages will check whether the same file +exists with a `.gz` extension. If it does, and the browser supports receiving +compressed files, it will serve that version instead of the uncompressed one. + +To take advantage of this feature, the artifact you upload to the Pages should +have this structure: + +``` +public/ +├─┬ index.html +│ └ index.html.gz +│ +├── css/ +│ └─┬ main.css +│ └ main.css.gz +│ +└── js/ + └─┬ main.js + └ main.js.gz +``` + +This can be achieved by including a `script:` command like this in your +`.gitlab-ci.yml` pages job: + +```yaml +pages: + # Other directives + script: + - # build the public/ directory first + - find public -type f -iregex '.*\.\(htm\|html\|txt\|text\|js\|css\)$' -execdir gzip -f --keep {} \; +``` + +By pre-compressing the files and including both versions in the artifact, Pages +can serve requests for both compressed and uncompressed content without +needing to compress files on-demand. + ### Add a custom domain to your Pages website For a complete guide on Pages domains, read through the article diff --git a/doc/user/project/repository/index.md b/doc/user/project/repository/index.md index 9501db88f57..ce081cedd71 100644 --- a/doc/user/project/repository/index.md +++ b/doc/user/project/repository/index.md @@ -66,9 +66,9 @@ your implementation with your team. You can live preview changes submitted to a new branch with [Review Apps](../../../ci/review_apps/index.md). -With [GitLab Enterprise Edition](https://about.gitlab.com/gitlab-ee/) +With [GitLab Enterprise Edition](https://about.gitlab.com/products/) subscriptions, you can also request -[approval](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html#merge-request-approvals) from your managers. +[approval](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html) from your managers. To create, delete, and [branches](branches/index.md) via GitLab's UI: @@ -147,12 +147,14 @@ Select branches to compare and view the changes inline: Find it under your project's **Repository > Compare**. -## Locked files (EEP) +## Locked files + +> Available in [GitLab Premium](https://about.gitlab.com/products/). Lock your files to prevent any conflicting changes. [File Locking](https://docs.gitlab.com/ee/user/project/file_lock.html) is available only in -[GitLab Enterprise Edition Premium](https://about.gitlab.com/gitlab-ee/). +[GitLab Premium](https://about.gitlab.com/products/). ## Repository's API diff --git a/doc/user/project/settings/index.md b/doc/user/project/settings/index.md index f01fa5b1860..888dd0e143a 100644 --- a/doc/user/project/settings/index.md +++ b/doc/user/project/settings/index.md @@ -34,7 +34,7 @@ Set up your project's merge request settings: - Set up the merge request method (merge commit, [fast-forward merge](../merge_requests/fast_forward_merge.html)). - Merge request [description templates](../description_templates.md#description-templates). -- Enable [merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html#merge-request-approvals), _available in [GitLab Enterprise Edition Starter](https://about.gitlab.com/gitlab-ee/)_. +- Enable [merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html#merge-request-approvals), _available in [GitLab Starter](https://about.gitlab.com/products/)_. - Enable [merge only of pipeline succeeds](../merge_requests/merge_when_pipeline_succeeds.md). - Enable [merge only when all discussions are resolved](../../discussions/index.md#only-allow-merge-requests-to-be-merged-if-all-discussions-are-resolved). @@ -42,7 +42,7 @@ Set up your project's merge request settings: ### Service Desk -Enable [Service Desk](https://docs.gitlab.com/ee/user/project/service_desk.html) for your project to offer customer support. Service Desk is available in [GitLab Enterprise Edition Premium](https://about.gitlab.com/gitlab-ee/). +Enable [Service Desk](https://docs.gitlab.com/ee/user/project/service_desk.html) for your project to offer customer support. Service Desk is available in [GitLab Premium](https://about.gitlab.com/products/). ### Export project diff --git a/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md b/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md index ce7895780c3..8fff3d591fe 100644 --- a/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md +++ b/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md @@ -83,6 +83,72 @@ that are on the remote repository, eg. from branch `master`: git lfs fetch master ``` +## File Locking + +The first thing to do before using File Locking is to tell Git LFS which +kind of files are lockable. The following command will store PNG files +in LFS and flag them as lockable: + +```bash +git lfs track "*.png" --lockable +``` + +After executing the above command a file named `.gitattributes` will be +created or updated with the following content: + +```bash +*.png filter=lfs diff=lfs merge=lfs -text lockable +``` + +You can also register a file type as lockable without using LFS +(In order to be able to lock/unlock a file you need a remote server that implements the LFS File Locking API), +in order to do that you can edit the `.gitattributes` file manually: + +```bash +*.pdf lockable +``` + +After a file type has been registered as lockable, Git LFS will make +them readonly on the file system automatically. This means you will +need to lock the file before editing it. + +### Managing Locked Files + +Once you're ready to edit your file you need to lock it first: + +```bash +git lfs lock images/banner.png +Locked images/banner.png +``` + +This will register the file as locked in your name on the server: + +```bash +git lfs locks +images/banner.png joe ID:123 +``` + +Once you have pushed your changes, you can unlock the file so others can +also edit it: + +```bash +git lfs unlock images/banner.png +``` + +You can also unlock by id: + +```bash +git lfs unlock --id=123 +``` + +If for some reason you need to unlock a file that was not locked by you, +you can use the `--force` flag as long as you have a `master` access on +the project: + +```bash +git lfs unlock --id=123 --force +``` + ## Troubleshooting ### error: Repository or object not found diff --git a/lib/api/api.rb b/lib/api/api.rb index f3f64244589..e953f3d2eca 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -146,6 +146,7 @@ module API mount ::API::Repositories mount ::API::Runner mount ::API::Runners + mount ::API::Search mount ::API::Services mount ::API::Settings mount ::API::SidekiqMetrics diff --git a/lib/api/entities.rb b/lib/api/entities.rb index e13463ec66b..7838de13c56 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -314,24 +314,20 @@ module API end end - class ProjectSnippet < Grape::Entity + class Snippet < Grape::Entity expose :id, :title, :file_name, :description expose :author, using: Entities::UserBasic expose :updated_at, :created_at - - expose :web_url do |snippet, options| + expose :project_id + expose :web_url do |snippet| Gitlab::UrlBuilder.build(snippet) end end - class PersonalSnippet < Grape::Entity - expose :id, :title, :file_name, :description - expose :author, using: Entities::UserBasic - expose :updated_at, :created_at + class ProjectSnippet < Snippet + end - expose :web_url do |snippet| - Gitlab::UrlBuilder.build(snippet) - end + class PersonalSnippet < Snippet expose :raw_url do |snippet| Gitlab::UrlBuilder.build(snippet) + "/raw" end @@ -1168,5 +1164,14 @@ module API class ApplicationWithSecret < Application expose :secret end + + class Blob < Grape::Entity + expose :basename + expose :data + expose :filename + expose :id + expose :ref + expose :startline + end end end diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb index eb67de81a0d..cd59da6fc70 100644 --- a/lib/api/helpers/internal_helpers.rb +++ b/lib/api/helpers/internal_helpers.rb @@ -60,8 +60,20 @@ module API false end + def project_path + project&.path || project_path_match[:project_path] + end + + def namespace_path + project&.namespace&.full_path || project_path_match[:namespace_path] + end + private + def project_path_match + @project_path_match ||= params[:project].match(Gitlab::PathRegex.full_project_git_path_regex) || {} + end + # rubocop:disable Gitlab/ModuleWithInstanceVariables def set_project if params[:gl_repository] diff --git a/lib/api/helpers/pagination.rb b/lib/api/helpers/pagination.rb index bb70370ba77..09805049169 100644 --- a/lib/api/helpers/pagination.rb +++ b/lib/api/helpers/pagination.rb @@ -12,13 +12,16 @@ module API private def add_pagination_headers(paginated_data) - header 'X-Total', paginated_data.total_count.to_s - header 'X-Total-Pages', total_pages(paginated_data).to_s header 'X-Per-Page', paginated_data.limit_value.to_s header 'X-Page', paginated_data.current_page.to_s header 'X-Next-Page', paginated_data.next_page.to_s header 'X-Prev-Page', paginated_data.prev_page.to_s header 'Link', pagination_links(paginated_data) + + return if data_without_counts?(paginated_data) + + header 'X-Total', paginated_data.total_count.to_s + header 'X-Total-Pages', total_pages(paginated_data).to_s end def pagination_links(paginated_data) @@ -37,8 +40,10 @@ module API request_params[:page] = 1 links << %(<#{request_url}?#{request_params.to_query}>; rel="first") - request_params[:page] = total_pages(paginated_data) - links << %(<#{request_url}?#{request_params.to_query}>; rel="last") + unless data_without_counts?(paginated_data) + request_params[:page] = total_pages(paginated_data) + links << %(<#{request_url}?#{request_params.to_query}>; rel="last") + end links.join(', ') end @@ -55,6 +60,10 @@ module API relation end + + def data_without_counts?(paginated_data) + paginated_data.is_a?(Kaminari::PaginatableWithoutCount) + end end end end diff --git a/lib/api/helpers/runner.rb b/lib/api/helpers/runner.rb index 3d0d1287407..fbe30192a16 100644 --- a/lib/api/helpers/runner.rb +++ b/lib/api/helpers/runner.rb @@ -3,7 +3,6 @@ module API module Runner JOB_TOKEN_HEADER = 'HTTP_JOB_TOKEN'.freeze JOB_TOKEN_PARAM = :token - UPDATE_RUNNER_EVERY = 10 * 60 def runner_registration_token_valid? ActiveSupport::SecurityUtils.variable_size_secure_compare(params[:token], @@ -18,30 +17,14 @@ module API def authenticate_runner! forbidden! unless current_runner + + current_runner.update_cached_info(get_runner_version_from_params) end def current_runner @runner ||= ::Ci::Runner.find_by_token(params[:token].to_s) end - def update_runner_info - return unless update_runner? - - current_runner.contacted_at = Time.now - current_runner.assign_attributes(get_runner_version_from_params) - current_runner.save if current_runner.changed? - end - - def update_runner? - # Use a random threshold to prevent beating DB updates. - # It generates a distribution between [40m, 80m]. - # - contacted_at_max_age = UPDATE_RUNNER_EVERY + Random.rand(UPDATE_RUNNER_EVERY) - - current_runner.contacted_at.nil? || - (Time.now - current_runner.contacted_at) >= contacted_at_max_age - end - def validate_job!(job) not_found! unless job diff --git a/lib/api/internal.rb b/lib/api/internal.rb index 063f0d6599c..9285fb90cdc 100644 --- a/lib/api/internal.rb +++ b/lib/api/internal.rb @@ -42,11 +42,14 @@ module API end access_checker_klass = wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess - access_checker = access_checker_klass - .new(actor, project, protocol, authentication_abilities: ssh_authentication_abilities, redirected_path: redirected_path) + access_checker = access_checker_klass.new(actor, project, + protocol, authentication_abilities: ssh_authentication_abilities, + namespace_path: namespace_path, project_path: project_path, + redirected_path: redirected_path) begin access_checker.check(params[:action], params[:changes]) + @project ||= access_checker.project rescue Gitlab::GitAccess::UnauthorizedError, Gitlab::GitAccess::NotFoundError => e return { status: false, message: e.message } end @@ -207,8 +210,11 @@ module API # A user is not guaranteed to be returned; an orphaned write deploy # key could be used if user - redirect_message = Gitlab::Checks::ProjectMoved.fetch_redirect_message(user.id, project.id) + redirect_message = Gitlab::Checks::ProjectMoved.fetch_message(user.id, project.id) + project_created_message = Gitlab::Checks::ProjectCreated.fetch_message(user.id, project.id) + output[:redirected_message] = redirect_message if redirect_message + output[:project_created_message] = project_created_message if project_created_message end output diff --git a/lib/api/runner.rb b/lib/api/runner.rb index 1f80646a2ea..5469cba69a6 100644 --- a/lib/api/runner.rb +++ b/lib/api/runner.rb @@ -78,7 +78,6 @@ module API post '/request' do authenticate_runner! no_content! unless current_runner.active? - update_runner_info if current_runner.runner_queue_value_latest?(params[:last_update]) header 'X-GitLab-Last-Update', params[:last_update] diff --git a/lib/api/search.rb b/lib/api/search.rb new file mode 100644 index 00000000000..9f08fd96a3b --- /dev/null +++ b/lib/api/search.rb @@ -0,0 +1,115 @@ +module API + class Search < Grape::API + include PaginationParams + + before { authenticate! } + + helpers do + SCOPE_ENTITY = { + merge_requests: Entities::MergeRequestBasic, + issues: Entities::IssueBasic, + projects: Entities::BasicProjectDetails, + milestones: Entities::Milestone, + notes: Entities::Note, + commits: Entities::Commit, + blobs: Entities::Blob, + wiki_blobs: Entities::Blob, + snippet_titles: Entities::Snippet, + snippet_blobs: Entities::Snippet + }.freeze + + def search(additional_params = {}) + search_params = { + scope: params[:scope], + search: params[:search], + snippets: snippets?, + page: params[:page], + per_page: params[:per_page] + }.merge(additional_params) + + results = SearchService.new(current_user, search_params).search_objects + + process_results(results) + end + + def process_results(results) + case params[:scope] + when 'wiki_blobs' + paginate(results).map { |blob| Gitlab::ProjectSearchResults.parse_search_result(blob) } + when 'blobs' + paginate(results).map { |blob| blob[1] } + else + paginate(results) + end + end + + def snippets? + %w(snippet_blobs snippet_titles).include?(params[:scope]).to_s + end + + def entity + SCOPE_ENTITY[params[:scope].to_sym] + end + end + + resource :search do + desc 'Search on GitLab' do + detail 'This feature was introduced in GitLab 10.5.' + end + params do + requires :search, type: String, desc: 'The expression it should be searched for' + requires :scope, + type: String, + desc: 'The scope of search, available scopes: + projects, issues, merge_requests, milestones, snippet_titles, snippet_blobs', + values: %w(projects issues merge_requests milestones snippet_titles snippet_blobs) + use :pagination + end + get do + present search, with: entity + end + end + + resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + desc 'Search on GitLab' do + detail 'This feature was introduced in GitLab 10.5.' + end + params do + requires :id, type: String, desc: 'The ID of a group' + requires :search, type: String, desc: 'The expression it should be searched for' + requires :scope, + type: String, + desc: 'The scope of search, available scopes: + projects, issues, merge_requests, milestones', + values: %w(projects issues merge_requests milestones) + use :pagination + end + get ':id/-/search' do + find_group!(params[:id]) + + present search(group_id: params[:id]), with: entity + end + end + + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + desc 'Search on GitLab' do + detail 'This feature was introduced in GitLab 10.5.' + end + params do + requires :id, type: String, desc: 'The ID of a project' + requires :search, type: String, desc: 'The expression it should be searched for' + requires :scope, + type: String, + desc: 'The scope of search, available scopes: + issues, merge_requests, milestones, notes, wiki_blobs, commits, blobs', + values: %w(issues merge_requests milestones notes wiki_blobs commits blobs) + use :pagination + end + get ':id/-/search' do + find_project!(params[:id]) + + present search(project_id: params[:id]), with: entity + end + end + end +end diff --git a/lib/api/v3/projects.rb b/lib/api/v3/projects.rb index c856ba99f09..7d8b1f369fe 100644 --- a/lib/api/v3/projects.rb +++ b/lib/api/v3/projects.rb @@ -174,7 +174,7 @@ module API use :pagination end get "/search/:query", requirements: { query: %r{[^/]+} } do - search_service = Search::GlobalService.new(current_user, search: params[:query]).execute + search_service = ::Search::GlobalService.new(current_user, search: params[:query]).execute projects = search_service.objects('projects', params[:page], false) projects = projects.reorder(params[:order_by] => params[:sort]) diff --git a/lib/constraints/user_url_constrainer.rb b/lib/constraints/user_url_constrainer.rb index b7633aa7cbb..3b3ed1c6ddb 100644 --- a/lib/constraints/user_url_constrainer.rb +++ b/lib/constraints/user_url_constrainer.rb @@ -2,7 +2,7 @@ class UserUrlConstrainer def matches?(request) full_path = request.params[:username] - return false unless UserPathValidator.valid_path?(full_path) + return false unless NamespacePathValidator.valid_path?(full_path) User.find_by_full_path(full_path, follow_redirects: request.get?).present? end diff --git a/lib/gitlab/checks/change_access.rb b/lib/gitlab/checks/change_access.rb index 945d70e7a24..d75e73dac10 100644 --- a/lib/gitlab/checks/change_access.rb +++ b/lib/gitlab/checks/change_access.rb @@ -31,13 +31,14 @@ module Gitlab @protocol = protocol end - def exec + def exec(skip_commits_check: false) return true if skip_authorization push_checks branch_checks tag_checks lfs_objects_exist_check + commits_check unless skip_commits_check true end @@ -117,6 +118,24 @@ module Gitlab end end + def commits_check + return if deletion? || newrev.nil? + + # n+1: https://gitlab.com/gitlab-org/gitlab-ee/issues/3593 + ::Gitlab::GitalyClient.allow_n_plus_1_calls do + commits.each do |commit| + commit_check.validate(commit, validations_for_commit(commit)) + end + end + + commit_check.validate_file_paths + end + + # Method overwritten in EE to inject custom validations + def validations_for_commit(_) + [] + end + private def updated_from_web? @@ -150,6 +169,14 @@ module Gitlab raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:lfs_objects_missing] end end + + def commit_check + @commit_check ||= Gitlab::Checks::CommitCheck.new(project, user_access.user, newrev, oldrev) + end + + def commits + project.repository.new_commits(newrev) + end end end end diff --git a/lib/gitlab/checks/commit_check.rb b/lib/gitlab/checks/commit_check.rb new file mode 100644 index 00000000000..ae0cd142378 --- /dev/null +++ b/lib/gitlab/checks/commit_check.rb @@ -0,0 +1,61 @@ +module Gitlab + module Checks + class CommitCheck + include Gitlab::Utils::StrongMemoize + + attr_reader :project, :user, :newrev, :oldrev + + def initialize(project, user, newrev, oldrev) + @project = project + @user = user + @newrev = user + @oldrev = user + @file_paths = [] + end + + def validate(commit, validations) + return if validations.empty? && path_validations.empty? + + commit.raw_deltas.each do |diff| + @file_paths << (diff.new_path || diff.old_path) + + validations.each do |validation| + if error = validation.call(diff) + raise ::Gitlab::GitAccess::UnauthorizedError, error + end + end + end + end + + def validate_file_paths + path_validations.each do |validation| + if error = validation.call(@file_paths) + raise ::Gitlab::GitAccess::UnauthorizedError, error + end + end + end + + private + + def validate_lfs_file_locks? + strong_memoize(:validate_lfs_file_locks) do + project.lfs_enabled? && project.lfs_file_locks.any? && newrev && oldrev + end + end + + def lfs_file_locks_validation + lambda do |paths| + lfs_lock = project.lfs_file_locks.where(path: paths).where.not(user_id: user.id).first + + if lfs_lock + return "The path '#{lfs_lock.path}' is locked in Git LFS by #{lfs_lock.user.name}" + end + end + end + + def path_validations + validate_lfs_file_locks? ? [lfs_file_locks_validation] : [] + end + end + end +end diff --git a/lib/gitlab/checks/post_push_message.rb b/lib/gitlab/checks/post_push_message.rb new file mode 100644 index 00000000000..473c0385b34 --- /dev/null +++ b/lib/gitlab/checks/post_push_message.rb @@ -0,0 +1,46 @@ +module Gitlab + module Checks + class PostPushMessage + def initialize(project, user, protocol) + @project = project + @user = user + @protocol = protocol + end + + def self.fetch_message(user_id, project_id) + key = message_key(user_id, project_id) + + Gitlab::Redis::SharedState.with do |redis| + message = redis.get(key) + redis.del(key) + message + end + end + + def add_message + return unless user.present? && project.present? + + Gitlab::Redis::SharedState.with do |redis| + key = self.class.message_key(user.id, project.id) + redis.setex(key, 5.minutes, message) + end + end + + def message + raise NotImplementedError + end + + protected + + attr_reader :project, :user, :protocol + + def self.message_key(user_id, project_id) + raise NotImplementedError + end + + def url_to_repo + protocol == 'ssh' ? project.ssh_url_to_repo : project.http_url_to_repo + end + end + end +end diff --git a/lib/gitlab/checks/project_created.rb b/lib/gitlab/checks/project_created.rb new file mode 100644 index 00000000000..cec270d6a58 --- /dev/null +++ b/lib/gitlab/checks/project_created.rb @@ -0,0 +1,31 @@ +module Gitlab + module Checks + class ProjectCreated < PostPushMessage + PROJECT_CREATED = "project_created".freeze + + def message + <<~MESSAGE + + The private project #{project.full_path} was successfully created. + + To configure the remote, run: + git remote add origin #{url_to_repo} + + To view the project, visit: + #{project_url} + + MESSAGE + end + + private + + def self.message_key(user_id, project_id) + "#{PROJECT_CREATED}:#{user_id}:#{project_id}" + end + + def project_url + Gitlab::Routing.url_helpers.project_url(project) + end + end + end +end diff --git a/lib/gitlab/checks/project_moved.rb b/lib/gitlab/checks/project_moved.rb index dfb2f4d4054..3263790a876 100644 --- a/lib/gitlab/checks/project_moved.rb +++ b/lib/gitlab/checks/project_moved.rb @@ -1,38 +1,16 @@ module Gitlab module Checks - class ProjectMoved + class ProjectMoved < PostPushMessage REDIRECT_NAMESPACE = "redirect_namespace".freeze - def initialize(project, user, redirected_path, protocol) - @project = project - @user = user + def initialize(project, user, protocol, redirected_path) @redirected_path = redirected_path - @protocol = protocol - end - - def self.fetch_redirect_message(user_id, project_id) - redirect_key = redirect_message_key(user_id, project_id) - Gitlab::Redis::SharedState.with do |redis| - message = redis.get(redirect_key) - redis.del(redirect_key) - message - end - end - - def add_redirect_message - # Don't bother with sending a redirect message for anonymous clones - # because they never see it via the `/internal/post_receive` endpoint - return unless user.present? && project.present? - - Gitlab::Redis::SharedState.with do |redis| - key = self.class.redirect_message_key(user.id, project.id) - redis.setex(key, 5.minutes, redirect_message) - end + super(project, user, protocol) end - def redirect_message(rejected: false) - <<~MESSAGE.strip_heredoc + def message(rejected: false) + <<~MESSAGE Project '#{redirected_path}' was moved to '#{project.full_path}'. Please update your Git remote: @@ -47,17 +25,17 @@ module Gitlab private - attr_reader :project, :redirected_path, :protocol, :user + attr_reader :redirected_path - def self.redirect_message_key(user_id, project_id) + def self.message_key(user_id, project_id) "#{REDIRECT_NAMESPACE}:#{user_id}:#{project_id}" end def remote_url_message(rejected) if rejected - "git remote set-url origin #{url} and try again." + "git remote set-url origin #{url_to_repo} and try again." else - "git remote set-url origin #{url}" + "git remote set-url origin #{url_to_repo}" end end diff --git a/lib/gitlab/ci/config/loader.rb b/lib/gitlab/ci/config/loader.rb index e7d9f6a7761..141d2714cb6 100644 --- a/lib/gitlab/ci/config/loader.rb +++ b/lib/gitlab/ci/config/loader.rb @@ -6,6 +6,8 @@ module Gitlab def initialize(config) @config = YAML.safe_load(config, [Symbol], [], true) + rescue Psych::Exception => e + raise FormatError, e.message end def valid? diff --git a/lib/gitlab/ci/yaml_processor.rb b/lib/gitlab/ci/yaml_processor.rb index 0bd78b03448..a7285ac8f9d 100644 --- a/lib/gitlab/ci/yaml_processor.rb +++ b/lib/gitlab/ci/yaml_processor.rb @@ -85,7 +85,7 @@ module Gitlab begin Gitlab::Ci::YamlProcessor.new(content) nil - rescue ValidationError, Psych::SyntaxError => e + rescue ValidationError => e e.message end end diff --git a/lib/gitlab/git/blob.rb b/lib/gitlab/git/blob.rb index 4828301dbb9..b2fca2c16de 100644 --- a/lib/gitlab/git/blob.rb +++ b/lib/gitlab/git/blob.rb @@ -53,11 +53,7 @@ module Gitlab def batch(repository, blob_references, blob_size_limit: MAX_DATA_DISPLAY_SIZE) Gitlab::GitalyClient.migrate(:list_blobs_by_sha_path) do |is_enabled| if is_enabled - Gitlab::GitalyClient.allow_n_plus_1_calls do - blob_references.map do |sha, path| - find_by_gitaly(repository, sha, path, limit: blob_size_limit) - end - end + repository.gitaly_blob_client.get_blobs(blob_references, blob_size_limit).to_a else blob_references.map do |sha, path| find_by_rugged(repository, sha, path, limit: blob_size_limit) diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index 768617e2cae..d95561fe1b2 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -402,15 +402,6 @@ module Gitlab end end - # Get a collection of Rugged::Reference objects for this commit. - # - # Ex. - # commit.ref(repo) - # - def refs(repo) - repo.refs_hash[id] - end - # Get ref names collection # # Ex. @@ -418,7 +409,7 @@ module Gitlab # def ref_names(repo) refs(repo).map do |ref| - ref.name.sub(%r{^refs/(heads|remotes|tags)/}, "") + ref.sub(%r{^refs/(heads|remotes|tags)/}, "") end end @@ -553,6 +544,15 @@ module Gitlab date: Google::Protobuf::Timestamp.new(seconds: author_or_committer[:time].to_i) ) end + + # Get a collection of Gitlab::Git::Ref objects for this commit. + # + # Ex. + # commit.ref(repo) + # + def refs(repo) + repo.refs_hash[id] + end end end end diff --git a/lib/gitlab/git/hook.rb b/lib/gitlab/git/hook.rb index e29a1f7afa1..24f027d8da4 100644 --- a/lib/gitlab/git/hook.rb +++ b/lib/gitlab/git/hook.rb @@ -82,14 +82,20 @@ module Gitlab end def call_update_hook(gl_id, gl_username, oldrev, newrev, ref) - Dir.chdir(repo_path) do - env = { - 'GL_ID' => gl_id, - 'GL_USERNAME' => gl_username - } - stdout, stderr, status = Open3.capture3(env, path, ref, oldrev, newrev) - [status.success?, (stderr.presence || stdout).gsub(/\R/, "<br>").html_safe] - end + env = { + 'GL_ID' => gl_id, + 'GL_USERNAME' => gl_username, + 'PWD' => repo_path + } + + options = { + chdir: repo_path + } + + args = [ref, oldrev, newrev] + + stdout, stderr, status = Open3.capture3(env, path, *args, options) + [status.success?, (stderr.presence || stdout).gsub(/\R/, "<br>").html_safe] end def retrieve_error_message(stderr, stdout) diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index d7510061def..6761fb0937a 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -631,21 +631,18 @@ module Gitlab end end - # Get refs hash which key is SHA1 - # and value is a Rugged::Reference + # Get refs hash which key is is the commit id + # and value is a Gitlab::Git::Tag or Gitlab::Git::Branch + # Note that both inherit from Gitlab::Git::Ref def refs_hash - # Initialize only when first call - if @refs_hash.nil? - @refs_hash = Hash.new { |h, k| h[k] = [] } - - rugged.references.each do |r| - # Symbolic/remote references may not have an OID; skip over them - target_oid = r.target.try(:oid) - if target_oid - sha = rev_parse_target(target_oid).oid - @refs_hash[sha] << r - end - end + return @refs_hash if @refs_hash + + @refs_hash = Hash.new { |h, k| h[k] = [] } + + (tags + branches).each do |ref| + next unless ref.target && ref.name + + @refs_hash[ref.dereferenced_target.id] << ref.name end @refs_hash diff --git a/lib/gitlab/git/wiki.rb b/lib/gitlab/git/wiki.rb index 39040d56971..ac12271a87e 100644 --- a/lib/gitlab/git/wiki.rb +++ b/lib/gitlab/git/wiki.rb @@ -25,9 +25,8 @@ module Gitlab @repository.exists? end - # Disabled because of https://gitlab.com/gitlab-org/gitaly/merge_requests/539 def write_page(name, format, content, commit_details) - @repository.gitaly_migrate(:wiki_write_page, status: Gitlab::GitalyClient::MigrationStatus::DISABLED) do |is_enabled| + @repository.gitaly_migrate(:wiki_write_page) do |is_enabled| if is_enabled gitaly_write_page(name, format, content, commit_details) gollum_wiki.clear_cache @@ -48,9 +47,8 @@ module Gitlab end end - # Disable because of https://gitlab.com/gitlab-org/gitlab-ce/issues/42094 def update_page(page_path, title, format, content, commit_details) - @repository.gitaly_migrate(:wiki_update_page, status: Gitlab::GitalyClient::MigrationStatus::DISABLED) do |is_enabled| + @repository.gitaly_migrate(:wiki_update_page) do |is_enabled| if is_enabled gitaly_update_page(page_path, title, format, content, commit_details) gollum_wiki.clear_cache diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb index 56f6febe86d..8ec3386184a 100644 --- a/lib/gitlab/git_access.rb +++ b/lib/gitlab/git_access.rb @@ -2,15 +2,19 @@ # class return an instance of `GitlabAccessStatus` module Gitlab class GitAccess + include Gitlab::Utils::StrongMemoize + UnauthorizedError = Class.new(StandardError) NotFoundError = Class.new(StandardError) + ProjectCreationError = Class.new(StandardError) ProjectMovedError = Class.new(NotFoundError) ERROR_MESSAGES = { upload: 'You are not allowed to upload code for this project.', download: 'You are not allowed to download code from this project.', - deploy_key_upload: - 'This deploy key does not have write access to this project.', + auth_upload: 'You are not allowed to upload code.', + auth_download: 'You are not allowed to download code.', + deploy_key_upload: 'This deploy key does not have write access to this project.', no_repo: 'A repository for this project does not exist yet.', project_not_found: 'The project you were looking for could not be found.', account_blocked: 'Your account has been blocked.', @@ -25,24 +29,31 @@ module Gitlab PUSH_COMMANDS = %w{ git-receive-pack }.freeze ALL_COMMANDS = DOWNLOAD_COMMANDS + PUSH_COMMANDS - attr_reader :actor, :project, :protocol, :authentication_abilities, :redirected_path + attr_reader :actor, :project, :protocol, :authentication_abilities, :namespace_path, :project_path, :redirected_path - def initialize(actor, project, protocol, authentication_abilities:, redirected_path: nil) + def initialize(actor, project, protocol, authentication_abilities:, namespace_path: nil, project_path: nil, redirected_path: nil) @actor = actor @project = project @protocol = protocol - @redirected_path = redirected_path @authentication_abilities = authentication_abilities + @namespace_path = namespace_path + @project_path = project_path + @redirected_path = redirected_path end def check(cmd, changes) check_protocol! check_valid_actor! check_active_user! - check_project_accessibility! - check_project_moved! + check_authentication_abilities!(cmd) check_command_disabled!(cmd) check_command_existence!(cmd) + check_db_accessibility!(cmd) + + ensure_project_on_push!(cmd, changes) + + check_project_accessibility! + check_project_moved! check_repository_existence! case cmd @@ -95,6 +106,19 @@ module Gitlab end end + def check_authentication_abilities!(cmd) + case cmd + when *DOWNLOAD_COMMANDS + unless authentication_abilities.include?(:download_code) || authentication_abilities.include?(:build_download_code) + raise UnauthorizedError, ERROR_MESSAGES[:auth_download] + end + when *PUSH_COMMANDS + unless authentication_abilities.include?(:push_code) + raise UnauthorizedError, ERROR_MESSAGES[:auth_upload] + end + end + end + def check_project_accessibility! if project.blank? || !can_read_project? raise NotFoundError, ERROR_MESSAGES[:project_not_found] @@ -104,12 +128,12 @@ module Gitlab def check_project_moved! return if redirected_path.nil? - project_moved = Checks::ProjectMoved.new(project, user, redirected_path, protocol) + project_moved = Checks::ProjectMoved.new(project, user, protocol, redirected_path) if project_moved.permanent_redirect? - project_moved.add_redirect_message + project_moved.add_message else - raise ProjectMovedError, project_moved.redirect_message(rejected: true) + raise ProjectMovedError, project_moved.message(rejected: true) end end @@ -139,6 +163,40 @@ module Gitlab end end + def check_db_accessibility!(cmd) + return unless receive_pack?(cmd) + + if Gitlab::Database.read_only? + raise UnauthorizedError, push_to_read_only_message + end + end + + def ensure_project_on_push!(cmd, changes) + return if project || deploy_key? + return unless receive_pack?(cmd) && changes == '_any' && authentication_abilities.include?(:push_code) + + namespace = Namespace.find_by_full_path(namespace_path) + + return unless user&.can?(:create_projects, namespace) + + project_params = { + path: project_path, + namespace_id: namespace.id, + visibility_level: Gitlab::VisibilityLevel::PRIVATE + } + + project = Projects::CreateService.new(user, project_params).execute + + unless project.saved? + raise ProjectCreationError, "Could not create project: #{project.errors.full_messages.join(', ')}" + end + + @project = project + user_access.project = @project + + Checks::ProjectCreated.new(project, user, protocol).add_message + end + def check_repository_existence! unless project.repository.exists? raise UnauthorizedError, ERROR_MESSAGES[:no_repo] @@ -146,9 +204,8 @@ module Gitlab end def check_download_access! - return if deploy_key? - - passed = user_can_download_code? || + passed = deploy_key? || + user_can_download_code? || build_can_download_code? || guest_can_download_code? @@ -162,35 +219,21 @@ module Gitlab raise UnauthorizedError, ERROR_MESSAGES[:read_only] end - if Gitlab::Database.read_only? - raise UnauthorizedError, push_to_read_only_message - end - if deploy_key - check_deploy_key_push_access! + unless deploy_key.can_push_to?(project) + raise UnauthorizedError, ERROR_MESSAGES[:deploy_key_upload] + end elsif user - check_user_push_access! + # User access is verified in check_change_access! else raise UnauthorizedError, ERROR_MESSAGES[:upload] end - return if changes.blank? # Allow access. + return if changes.blank? # Allow access this is needed for EE. check_change_access!(changes) end - def check_user_push_access! - unless authentication_abilities.include?(:push_code) - raise UnauthorizedError, ERROR_MESSAGES[:upload] - end - end - - def check_deploy_key_push_access! - unless deploy_key.can_push_to?(project) - raise UnauthorizedError, ERROR_MESSAGES[:deploy_key_upload] - end - end - def check_change_access!(changes) changes_list = Gitlab::ChangesList.new(changes) diff --git a/lib/gitlab/gitaly_client/blob_service.rb b/lib/gitlab/gitaly_client/blob_service.rb index d70a1a7665e..dfa0fa43b0f 100644 --- a/lib/gitlab/gitaly_client/blob_service.rb +++ b/lib/gitlab/gitaly_client/blob_service.rb @@ -1,6 +1,8 @@ module Gitlab module GitalyClient class BlobService + include Gitlab::EncodingHelper + def initialize(repository) @gitaly_repo = repository.gitaly_repository end @@ -54,6 +56,30 @@ module Gitlab end end end + + def get_blobs(revision_paths, limit = -1) + return [] if revision_paths.empty? + + revision_paths.map! do |rev, path| + Gitaly::GetBlobsRequest::RevisionPath.new(revision: rev, path: encode_binary(path)) + end + + request = Gitaly::GetBlobsRequest.new( + repository: @gitaly_repo, + revision_paths: revision_paths, + limit: limit + ) + + response = GitalyClient.call( + @gitaly_repo.storage_name, + :blob_service, + :get_blobs, + request, + timeout: GitalyClient.default_timeout + ) + + GitalyClient::BlobsStitcher.new(response) + end end end end diff --git a/lib/gitlab/gitaly_client/blobs_stitcher.rb b/lib/gitlab/gitaly_client/blobs_stitcher.rb new file mode 100644 index 00000000000..5ca592ff812 --- /dev/null +++ b/lib/gitlab/gitaly_client/blobs_stitcher.rb @@ -0,0 +1,47 @@ +module Gitlab + module GitalyClient + class BlobsStitcher + include Enumerable + + def initialize(rpc_response) + @rpc_response = rpc_response + end + + def each + current_blob_data = nil + + @rpc_response.each do |msg| + begin + if msg.oid.blank? && msg.data.blank? + next + elsif msg.oid.present? + yield new_blob(current_blob_data) if current_blob_data + + current_blob_data = msg.to_h.slice(:oid, :path, :size, :revision, :mode) + current_blob_data[:data] = msg.data.dup + else + current_blob_data[:data] << msg.data + end + end + end + + yield new_blob(current_blob_data) if current_blob_data + end + + private + + def new_blob(blob_data) + Gitlab::Git::Blob.new( + id: blob_data[:oid], + mode: blob_data[:mode].to_s(8), + name: File.basename(blob_data[:path]), + path: blob_data[:path], + size: blob_data[:size], + commit_id: blob_data[:revision], + data: blob_data[:data], + binary: Gitlab::Git::Blob.binary?(blob_data[:data]) + ) + end + end + end +end diff --git a/lib/gitlab/gitaly_client/commit_service.rb b/lib/gitlab/gitaly_client/commit_service.rb index 5767f06b0ce..269a048cf5d 100644 --- a/lib/gitlab/gitaly_client/commit_service.rb +++ b/lib/gitlab/gitaly_client/commit_service.rb @@ -222,14 +222,25 @@ module Gitlab end def find_commit(revision) - request = Gitaly::FindCommitRequest.new( - repository: @gitaly_repo, - revision: encode_binary(revision) - ) - - response = GitalyClient.call(@repository.storage, :commit_service, :find_commit, request, timeout: GitalyClient.medium_timeout) - - response.commit + if RequestStore.active? + # We don't use RequeStstore.fetch(key) { ... } directly because `revision` + # can be a branch name, so we can't use it as a key as it could point + # to another commit later on (happens a lot in tests). + key = { + storage: @gitaly_repo.storage_name, + relative_path: @gitaly_repo.relative_path, + commit_id: revision + } + return RequestStore[key] if RequestStore.exist?(key) + + commit = call_find_commit(revision) + return unless commit + + key[:commit_id] = commit.id + RequestStore[key] = commit + else + call_find_commit(revision) + end end def patch(revision) @@ -346,6 +357,17 @@ module Gitlab def encode_repeated(a) Google::Protobuf::RepeatedField.new(:bytes, a.map { |s| encode_binary(s) } ) end + + def call_find_commit(revision) + request = Gitaly::FindCommitRequest.new( + repository: @gitaly_repo, + revision: encode_binary(revision) + ) + + response = GitalyClient.call(@repository.storage, :commit_service, :find_commit, request, timeout: GitalyClient.medium_timeout) + + response.commit + end end end end diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index 2daed10f678..9f404003125 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -27,6 +27,8 @@ project_tree: - :releases - project_members: - :user + - lfs_file_locks: + - :user - merge_requests: - notes: - :author diff --git a/lib/gitlab/o_auth/user.rb b/lib/gitlab/o_auth/user.rb index e40a001d20c..a3e1c66c19f 100644 --- a/lib/gitlab/o_auth/user.rb +++ b/lib/gitlab/o_auth/user.rb @@ -178,7 +178,7 @@ module Gitlab valid_username = ::Namespace.clean_path(username) uniquify = Uniquify.new - valid_username = uniquify.string(valid_username) { |s| !UserPathValidator.valid_path?(s) } + valid_username = uniquify.string(valid_username) { |s| !NamespacePathValidator.valid_path?(s) } name = auth_hash.name name = valid_username if name.strip.empty? diff --git a/lib/gitlab/path_regex.rb b/lib/gitlab/path_regex.rb index 7e5dfd33502..4dc38aae61e 100644 --- a/lib/gitlab/path_regex.rb +++ b/lib/gitlab/path_regex.rb @@ -171,24 +171,16 @@ module Gitlab @project_git_route_regex ||= /#{project_route_regex}\.git/.freeze end - def root_namespace_path_regex - @root_namespace_path_regex ||= %r{\A#{root_namespace_route_regex}/\z} - end - def full_namespace_path_regex @full_namespace_path_regex ||= %r{\A#{full_namespace_route_regex}/\z} end - def project_path_regex - @project_path_regex ||= %r{\A#{project_route_regex}/\z} - end - def full_project_path_regex @full_project_path_regex ||= %r{\A#{full_namespace_route_regex}/#{project_route_regex}/\z} end - def full_namespace_format_regex - @namespace_format_regex ||= /A#{FULL_NAMESPACE_FORMAT_REGEX}\z/.freeze + def full_project_git_path_regex + @full_project_git_path_regex ||= %r{\A\/?(?<namespace_path>#{full_namespace_route_regex})\/(?<project_path>#{project_route_regex})\.git\z} end def namespace_format_regex diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb index 4823f703ba4..9e2fa07a205 100644 --- a/lib/gitlab/project_search_results.rb +++ b/lib/gitlab/project_search_results.rb @@ -2,11 +2,12 @@ module Gitlab class ProjectSearchResults < SearchResults attr_reader :project, :repository_ref - def initialize(current_user, project, query, repository_ref = nil) + def initialize(current_user, project, query, repository_ref = nil, per_page: 20) @current_user = current_user @project = project @repository_ref = repository_ref.presence || project.default_branch @query = query + @per_page = per_page end def objects(scope, page = nil) diff --git a/lib/gitlab/prometheus/queries/additional_metrics_deployment_query.rb b/lib/gitlab/prometheus/queries/additional_metrics_deployment_query.rb index 69d055c901c..294a6ae34ca 100644 --- a/lib/gitlab/prometheus/queries/additional_metrics_deployment_query.rb +++ b/lib/gitlab/prometheus/queries/additional_metrics_deployment_query.rb @@ -4,7 +4,7 @@ module Gitlab class AdditionalMetricsDeploymentQuery < BaseQuery include QueryAdditionalMetrics - def query(deployment_id) + def query(environment_id, deployment_id) Deployment.find_by(id: deployment_id).try do |deployment| query_metrics( common_query_context( diff --git a/lib/gitlab/prometheus/queries/deployment_query.rb b/lib/gitlab/prometheus/queries/deployment_query.rb index 170f483540e..6e6da593178 100644 --- a/lib/gitlab/prometheus/queries/deployment_query.rb +++ b/lib/gitlab/prometheus/queries/deployment_query.rb @@ -2,7 +2,7 @@ module Gitlab module Prometheus module Queries class DeploymentQuery < BaseQuery - def query(deployment_id) + def query(environment_id, deployment_id) Deployment.find_by(id: deployment_id).try do |deployment| environment_slug = deployment.environment.slug diff --git a/lib/gitlab/prometheus_client.rb b/lib/gitlab/prometheus_client.rb index aa94614bf18..10527972663 100644 --- a/lib/gitlab/prometheus_client.rb +++ b/lib/gitlab/prometheus_client.rb @@ -3,10 +3,10 @@ module Gitlab # Helper methods to interact with Prometheus network services & resources class PrometheusClient - attr_reader :api_url + attr_reader :rest_client, :headers - def initialize(api_url:) - @api_url = api_url + def initialize(rest_client) + @rest_client = rest_client end def ping @@ -40,37 +40,40 @@ module Gitlab private def json_api_get(type, args = {}) - get(join_api_url(type, args)) + path = ['api', 'v1', type].join('/') + get(path, args) + rescue JSON::ParserError + raise PrometheusError, 'Parsing response failed' rescue Errno::ECONNREFUSED raise PrometheusError, 'Connection refused' end - def join_api_url(type, args = {}) - url = URI.parse(api_url) - rescue URI::Error - raise PrometheusError, "Invalid API URL: #{api_url}" - else - url.path = [url.path.sub(%r{/+\z}, ''), 'api', 'v1', type].join('/') - url.query = args.to_query - - url.to_s - end - - def get(url) - handle_response(HTTParty.get(url)) + def get(path, args) + response = rest_client[path].get(params: args) + handle_response(response) rescue SocketError - raise PrometheusError, "Can't connect to #{url}" + raise PrometheusError, "Can't connect to #{rest_client.url}" rescue OpenSSL::SSL::SSLError - raise PrometheusError, "#{url} contains invalid SSL data" - rescue HTTParty::Error + raise PrometheusError, "#{rest_client.url} contains invalid SSL data" + rescue RestClient::ExceptionWithResponse => ex + handle_exception_response(ex.response) + rescue RestClient::Exception raise PrometheusError, "Network connection error" end def handle_response(response) - if response.code == 200 && response['status'] == 'success' - response['data'] || {} - elsif response.code == 400 - raise PrometheusError, response['error'] || 'Bad data received' + json_data = JSON.parse(response.body) + if response.code == 200 && json_data['status'] == 'success' + json_data['data'] || {} + else + raise PrometheusError, "#{response.code} - #{response.body}" + end + end + + def handle_exception_response(response) + if response.code == 400 + json_data = JSON.parse(response.body) + raise PrometheusError, json_data['error'] || 'Bad data received' else raise PrometheusError, "#{response.code} - #{response.body}" end diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index 7362514167f..5ad219179f3 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -10,6 +10,7 @@ module Gitlab @ref = opts.fetch(:ref, nil) @startline = opts.fetch(:startline, nil) @data = opts.fetch(:data, nil) + @per_page = opts.fetch(:per_page, 20) end def path @@ -21,7 +22,7 @@ module Gitlab end end - attr_reader :current_user, :query + attr_reader :current_user, :query, :per_page # Limit search results by passed projects # It allows us to search only for projects user has access to @@ -33,11 +34,12 @@ module Gitlab # query attr_reader :default_project_filter - def initialize(current_user, limit_projects, query, default_project_filter: false) + def initialize(current_user, limit_projects, query, default_project_filter: false, per_page: 20) @current_user = current_user @limit_projects = limit_projects || Project.all @query = query @default_project_filter = default_project_filter + @per_page = per_page end def objects(scope, page = nil, without_count = true) @@ -153,10 +155,6 @@ module Gitlab 'projects' end - def per_page - 20 - end - def project_ids_relation limit_projects.select(:id).reorder(nil) end diff --git a/lib/gitlab/sidekiq_middleware/memory_killer.rb b/lib/gitlab/sidekiq_middleware/memory_killer.rb index 2bfb7caefd9..b89ae2505c9 100644 --- a/lib/gitlab/sidekiq_middleware/memory_killer.rb +++ b/lib/gitlab/sidekiq_middleware/memory_killer.rb @@ -45,7 +45,7 @@ module Gitlab private def get_rss - output, status = Gitlab::Popen.popen(%W(ps -o rss= -p #{pid})) + output, status = Gitlab::Popen.popen(%W(ps -o rss= -p #{pid}), Rails.root.to_s) return 0 unless status.zero? output.to_i diff --git a/lib/gitlab/user_access.rb b/lib/gitlab/user_access.rb index f357488ac61..15eb1c41213 100644 --- a/lib/gitlab/user_access.rb +++ b/lib/gitlab/user_access.rb @@ -6,7 +6,8 @@ module Gitlab [user&.id, project&.id] end - attr_reader :user, :project + attr_reader :user + attr_accessor :project def initialize(user, project: nil) @user = user diff --git a/locale/bg/gitlab.po b/locale/bg/gitlab.po index 8432914d6a7..81ce8ec506e 100644 --- a/locale/bg/gitlab.po +++ b/locale/bg/gitlab.po @@ -2018,7 +2018,7 @@ msgstr "" msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/" msgstr "" -msgid "PrometheusService|Prometheus monitoring" +msgid "PrometheusService|Time-series monitoring service" msgstr "" msgid "PrometheusService|View environments" diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index db7f41c5476..5b33ed0a628 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -2018,7 +2018,7 @@ msgstr "" msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/" msgstr "" -msgid "PrometheusService|Prometheus monitoring" +msgid "PrometheusService|Time-series monitoring service" msgstr "" msgid "PrometheusService|View environments" diff --git a/locale/eo/gitlab.po b/locale/eo/gitlab.po index be7cfa6e4b5..54906417e75 100644 --- a/locale/eo/gitlab.po +++ b/locale/eo/gitlab.po @@ -2018,7 +2018,7 @@ msgstr "" msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/" msgstr "" -msgid "PrometheusService|Prometheus monitoring" +msgid "PrometheusService|Time-series monitoring service" msgstr "" msgid "PrometheusService|View environments" diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po index 44ad3d4633a..750dde61a03 100644 --- a/locale/es/gitlab.po +++ b/locale/es/gitlab.po @@ -2018,7 +2018,7 @@ msgstr "" msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/" msgstr "" -msgid "PrometheusService|Prometheus monitoring" +msgid "PrometheusService|Time-series monitoring service" msgstr "" msgid "PrometheusService|View environments" diff --git a/locale/fr/gitlab.po b/locale/fr/gitlab.po index ace6a5d2f66..2a713917684 100644 --- a/locale/fr/gitlab.po +++ b/locale/fr/gitlab.po @@ -2018,7 +2018,7 @@ msgstr "" msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/" msgstr "" -msgid "PrometheusService|Prometheus monitoring" +msgid "PrometheusService|Time-series monitoring service" msgstr "" msgid "PrometheusService|View environments" diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d5cb5400d4f..fadc17a659d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2018-02-06 10:02+0100\n" -"PO-Revision-Date: 2018-02-06 10:02+0100\n" +"POT-Creation-Date: 2018-02-07 11:38-0600\n" +"PO-Revision-Date: 2018-02-07 11:38-0600\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -129,6 +129,9 @@ msgstr "" msgid "Add new directory" msgstr "" +msgid "Add todo" +msgstr "" + msgid "AdminArea|Stop all jobs" msgstr "" @@ -228,6 +231,21 @@ msgstr "" msgid "Artifacts" msgstr "" +msgid "Assign custom color like #FF0000" +msgstr "" + +msgid "Assign labels" +msgstr "" + +msgid "Assign milestone" +msgstr "" + +msgid "Assign to" +msgstr "" + +msgid "Assignee" +msgstr "" + msgid "Attach a file by drag & drop or %{upload_link}" msgstr "" @@ -243,6 +261,9 @@ msgstr "" msgid "Author" msgstr "" +msgid "Authors: %{authors}" +msgstr "" + msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly." msgstr "" @@ -276,6 +297,12 @@ msgstr "" msgid "Avatar will be removed. Are you sure?" msgstr "" +msgid "Average per day: %{average}" +msgstr "" + +msgid "Begin with the selected commit" +msgstr "" + msgid "Branch" msgid_plural "Branches" msgstr[0] "" @@ -524,15 +551,24 @@ msgstr "" msgid "CiVariables|Remove variable row" msgstr "" +msgid "CiVariable|* (All environments)" +msgstr "" + msgid "CiVariable|All environments" msgstr "" +msgid "CiVariable|Error occured while saving variables" +msgstr "" + msgid "CiVariable|Protected" msgstr "" msgid "CiVariable|Toggle protected" msgstr "" +msgid "CiVariable|Validation failed" +msgstr "" + msgid "CircuitBreakerApiLink|circuitbreaker api" msgstr "" @@ -695,9 +731,15 @@ msgstr "" msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters" msgstr "" +msgid "ClusterIntegration|Manage" +msgstr "" + msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}" msgstr "" +msgid "ClusterIntegration|More information" +msgstr "" + msgid "ClusterIntegration|Note:" msgstr "" @@ -826,6 +868,9 @@ msgstr "" msgid "Commit message" msgstr "" +msgid "Commit statistics for %{ref} %{start_time} - %{end_time}" +msgstr "" + msgid "CommitBoxTitle|Commit" msgstr "" @@ -838,6 +883,15 @@ msgstr "" msgid "Commits feed" msgstr "" +msgid "Commits per day hour (UTC)" +msgstr "" + +msgid "Commits per day of month" +msgstr "" + +msgid "Commits per weekday" +msgstr "" + msgid "Commits|An error occurred while fetching merge requests data." msgstr "" @@ -877,6 +931,9 @@ msgstr "" msgid "CompareBranches|There isn't anything to compare." msgstr "" +msgid "Confidentiality" +msgstr "" + msgid "Container Registry" msgstr "" @@ -949,6 +1006,12 @@ msgstr "" msgid "Copy commit SHA to clipboard" msgstr "" +msgid "Copy reference to clipboard" +msgstr "" + +msgid "Create" +msgstr "" + msgid "Create New Directory" msgstr "" @@ -964,6 +1027,9 @@ msgstr "" msgid "Create file" msgstr "" +msgid "Create lists from labels. Issues with that label appear in that list." +msgstr "" + msgid "Create merge request" msgstr "" @@ -976,6 +1042,9 @@ msgstr "" msgid "Create new file" msgstr "" +msgid "Create new label" +msgstr "" + msgid "Create new..." msgstr "" @@ -1098,6 +1167,9 @@ msgstr "" msgid "DownloadSource|Download" msgstr "" +msgid "Due date" +msgstr "" + msgid "Edit" msgstr "" @@ -1277,6 +1349,9 @@ msgstr "" msgid "Generate a default set of labels" msgstr "" +msgid "Git revision" +msgstr "" + msgid "Git storage health information has been reset" msgstr "" @@ -1512,6 +1587,9 @@ msgstr "" msgid "LastPushEvent|at" msgstr "" +msgid "Learn more" +msgstr "" + msgid "Learn more in the" msgstr "" @@ -1533,18 +1611,30 @@ msgstr "" msgid "Lock" msgstr "" +msgid "Lock %{issuableDisplayName}" +msgstr "" + +msgid "Lock this %{issuableDisplayName}? Only <strong>project members</strong> will be able to comment." +msgstr "" + msgid "Locked" msgstr "" msgid "Login" msgstr "" +msgid "Manage labels" +msgstr "" + msgid "Mar" msgstr "" msgid "March" msgstr "" +msgid "Mark done" +msgstr "" + msgid "Maximum git storage failures" msgstr "" @@ -1575,6 +1665,9 @@ msgstr "" msgid "Messages" msgstr "" +msgid "Milestone" +msgstr "" + msgid "Milestones|Delete milestone" msgstr "" @@ -1596,6 +1689,15 @@ msgstr "" msgid "More information is available|here" msgstr "" +msgid "Move" +msgstr "" + +msgid "Move issue" +msgstr "" + +msgid "Name new label" +msgstr "" + msgid "New Issue" msgid_plural "New Issues" msgstr[0] "" @@ -1649,9 +1751,18 @@ msgstr "" msgid "New tag" msgstr "" +msgid "No assignee" +msgstr "" + msgid "No connection could be made to a Gitaly Server, please check your logs!" msgstr "" +msgid "No due date" +msgstr "" + +msgid "No estimate or time spent" +msgstr "" + msgid "No file chosen" msgstr "" @@ -1667,9 +1778,15 @@ msgstr "" msgid "None" msgstr "" +msgid "Not allowed to merge" +msgstr "" + msgid "Not available" msgstr "" +msgid "Not confidential" +msgstr "" + msgid "Not enough data" msgstr "" @@ -1937,6 +2054,9 @@ msgstr "" msgid "Profiles|your account" msgstr "" +msgid "Programming languages used in this repository" +msgstr "" + msgid "Project '%{project_name}' is in the process of being deleted." msgstr "" @@ -2051,7 +2171,7 @@ msgstr "" msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/" msgstr "" -msgid "PrometheusService|Prometheus monitoring" +msgid "PrometheusService|Time-series monitoring service" msgstr "" msgid "PrometheusService|View environments" @@ -2069,6 +2189,9 @@ msgstr "" msgid "Push events" msgstr "" +msgid "Quick actions can be used in the issues description and comment boxes." +msgstr "" + msgid "Read more" msgstr "" @@ -2081,6 +2204,9 @@ msgstr "" msgid "RefSwitcher|Tags" msgstr "" +msgid "Reference:" +msgstr "" + msgid "Register / Sign In" msgstr "" @@ -2146,6 +2272,9 @@ msgstr "" msgid "Save pipeline schedule" msgstr "" +msgid "Save variables" +msgstr "" + msgid "Schedule a new pipeline" msgstr "" @@ -2158,18 +2287,33 @@ msgstr "" msgid "Search branches and tags" msgstr "" +msgid "Search milestones" +msgstr "" + +msgid "Search project" +msgstr "" + +msgid "Search users" +msgstr "" + msgid "Seconds before reseting failure information" msgstr "" msgid "Seconds to wait for a storage access attempt" msgstr "" +msgid "Secret variables" +msgstr "" + msgid "Select Archive Format" msgstr "" msgid "Select a timezone" msgstr "" +msgid "Select assignee" +msgstr "" + msgid "Select branch/tag" msgstr "" @@ -2226,6 +2370,9 @@ msgstr "" msgid "Something went wrong on our end." msgstr "" +msgid "Something went wrong trying to change the confidentiality of this issue" +msgstr "" + msgid "Something went wrong when toggling the button" msgstr "" @@ -2558,6 +2705,9 @@ msgstr "" msgid "This is the author's first Merge Request to this project." msgstr "" +msgid "This issue is confidential" +msgstr "" + msgid "This issue is confidential and locked." msgstr "" @@ -2603,9 +2753,21 @@ msgstr "" msgid "Time between merge request creation and merge/close" msgstr "" +msgid "Time tracking" +msgstr "" + msgid "Time until first merge request" msgstr "" +msgid "TimeTrackingEstimated|Est" +msgstr "" + +msgid "TimeTracking|Estimated:" +msgstr "" + +msgid "TimeTracking|Spent" +msgstr "" + msgid "Timeago|%s days ago" msgstr "" @@ -2742,6 +2904,12 @@ msgstr[1] "" msgid "Time|s" msgstr "" +msgid "Todo" +msgstr "" + +msgid "Toggle sidebar" +msgstr "" + msgid "ToggleButton|Toggle Status: OFF" msgstr "" @@ -2757,6 +2925,12 @@ msgstr "" msgid "Total test time for all commits/merges" msgstr "" +msgid "Total: %{total}" +msgstr "" + +msgid "Track time with quick actions" +msgstr "" + msgid "Trigger this manual action" msgstr "" @@ -2769,6 +2943,9 @@ msgstr "" msgid "Unlock" msgstr "" +msgid "Unlock this %{issuableDisplayName}? <strong>Everyone</strong> will be able to comment." +msgstr "" + msgid "Unlocked" msgstr "" @@ -2796,9 +2973,15 @@ msgstr "" msgid "Use your global notification setting" msgstr "" +msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want." +msgstr "" + msgid "View file @ " msgstr "" +msgid "View labels" +msgstr "" + msgid "View open merge request" msgstr "" @@ -2958,6 +3141,9 @@ msgstr "" msgid "You can also star a label to make it a priority label." msgstr "" +msgid "You can move around the graph by using the arrow keys." +msgstr "" + msgid "You can only add files when you are on a branch" msgstr "" @@ -3018,14 +3204,26 @@ msgstr "" msgid "Your projects" msgstr "" +msgid "assign yourself" +msgstr "" + msgid "branch name" msgstr "" +msgid "confidentiality|You are going to turn off the confidentiality. This means <strong>everyone</strong> will be able to see and leave a comment on this issue." +msgstr "" + +msgid "confidentiality|You are going to turn on the confidentiality. This means that only team members with <strong>at least Reporter access</strong> are able to see and leave comments on the issue." +msgstr "" + msgid "day" msgid_plural "days" msgstr[0] "" msgstr[1] "" +msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command." +msgstr "" + msgid "merge request" msgid_plural "merge requests" msgstr[0] "" @@ -3180,9 +3378,15 @@ msgstr "" msgid "personal access token" msgstr "" +msgid "remove due date" +msgstr "" + msgid "source" msgstr "" +msgid "spendCommand|%{slash_command} will update the sum of the time spent." +msgstr "" + msgid "username" msgstr "" diff --git a/locale/it/gitlab.po b/locale/it/gitlab.po index 52bbc28ac10..8b237dfe450 100644 --- a/locale/it/gitlab.po +++ b/locale/it/gitlab.po @@ -2018,7 +2018,7 @@ msgstr "Nessuna metrica è stata monitorata. Per iniziare a monitorare, rilascia msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/" msgstr "" -msgid "PrometheusService|Prometheus monitoring" +msgid "PrometheusService|Time-series monitoring service" msgstr "" msgid "PrometheusService|View environments" diff --git a/locale/ja/gitlab.po b/locale/ja/gitlab.po index 1314bad87fe..700d70cad3f 100644 --- a/locale/ja/gitlab.po +++ b/locale/ja/gitlab.po @@ -2004,7 +2004,7 @@ msgstr "" msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/" msgstr "" -msgid "PrometheusService|Prometheus monitoring" +msgid "PrometheusService|Time-series monitoring service" msgstr "" msgid "PrometheusService|View environments" diff --git a/locale/ko/gitlab.po b/locale/ko/gitlab.po index 9ec3d395c15..aab1650a0bc 100644 --- a/locale/ko/gitlab.po +++ b/locale/ko/gitlab.po @@ -2004,7 +2004,7 @@ msgstr "" msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/" msgstr "" -msgid "PrometheusService|Prometheus monitoring" +msgid "PrometheusService|Time-series monitoring service" msgstr "" msgid "PrometheusService|View environments" diff --git a/locale/nl_NL/gitlab.po b/locale/nl_NL/gitlab.po index 0abb727037c..eca1923fd2c 100644 --- a/locale/nl_NL/gitlab.po +++ b/locale/nl_NL/gitlab.po @@ -2018,7 +2018,7 @@ msgstr "" msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/" msgstr "" -msgid "PrometheusService|Prometheus monitoring" +msgid "PrometheusService|Time-series monitoring service" msgstr "" msgid "PrometheusService|View environments" diff --git a/locale/pl_PL/gitlab.po b/locale/pl_PL/gitlab.po index 5b65c42097e..a9059b0a73b 100644 --- a/locale/pl_PL/gitlab.po +++ b/locale/pl_PL/gitlab.po @@ -2032,7 +2032,7 @@ msgstr "" msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/" msgstr "" -msgid "PrometheusService|Prometheus monitoring" +msgid "PrometheusService|Time-series monitoring service" msgstr "" msgid "PrometheusService|View environments" diff --git a/locale/pt_BR/gitlab.po b/locale/pt_BR/gitlab.po index 9fe1cc3c11a..24753f2b140 100644 --- a/locale/pt_BR/gitlab.po +++ b/locale/pt_BR/gitlab.po @@ -2018,7 +2018,7 @@ msgstr "Nenhuma métrica está sendo monitorada. Para inicar o monitoramento, fa msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/" msgstr "URL da API base do Prometheus. como http://prometheus.example.com/" -msgid "PrometheusService|Prometheus monitoring" +msgid "PrometheusService|Time-series monitoring service" msgstr "Monitoramento com Prometheus" msgid "PrometheusService|View environments" diff --git a/locale/ru/gitlab.po b/locale/ru/gitlab.po index 898d55e7d4e..1b3b65325ac 100644 --- a/locale/ru/gitlab.po +++ b/locale/ru/gitlab.po @@ -2032,7 +2032,7 @@ msgstr "" msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/" msgstr "" -msgid "PrometheusService|Prometheus monitoring" +msgid "PrometheusService|Time-series monitoring service" msgstr "" msgid "PrometheusService|View environments" diff --git a/locale/uk/gitlab.po b/locale/uk/gitlab.po index fc62776a7a4..0f20f0c9ceb 100644 --- a/locale/uk/gitlab.po +++ b/locale/uk/gitlab.po @@ -2032,7 +2032,7 @@ msgstr "Жодні метрики не відслідковуються. Для msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/" msgstr "Базова адреса Prometheus API, наприклад http://prometheus.example.com/" -msgid "PrometheusService|Prometheus monitoring" +msgid "PrometheusService|Time-series monitoring service" msgstr "Моніторинг Prometheus" msgid "PrometheusService|View environments" diff --git a/locale/zh_CN/gitlab.po b/locale/zh_CN/gitlab.po index f0a5453f224..9c0d8dd5ddc 100644 --- a/locale/zh_CN/gitlab.po +++ b/locale/zh_CN/gitlab.po @@ -2004,7 +2004,7 @@ msgstr "没有监测指标。要开始监测,请部署到环境中。" msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/" msgstr "Prometheus API 地址,例如 http://prometheus.example.com/" -msgid "PrometheusService|Prometheus monitoring" +msgid "PrometheusService|Time-series monitoring service" msgstr "Prometheus 监测" msgid "PrometheusService|View environments" diff --git a/locale/zh_HK/gitlab.po b/locale/zh_HK/gitlab.po index b368487ac71..99024ee527c 100644 --- a/locale/zh_HK/gitlab.po +++ b/locale/zh_HK/gitlab.po @@ -2004,7 +2004,7 @@ msgstr "" msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/" msgstr "" -msgid "PrometheusService|Prometheus monitoring" +msgid "PrometheusService|Time-series monitoring service" msgstr "" msgid "PrometheusService|View environments" diff --git a/locale/zh_TW/gitlab.po b/locale/zh_TW/gitlab.po index 76c1e598433..14bc24c0e08 100644 --- a/locale/zh_TW/gitlab.po +++ b/locale/zh_TW/gitlab.po @@ -2004,7 +2004,7 @@ msgstr "" msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/" msgstr "" -msgid "PrometheusService|Prometheus monitoring" +msgid "PrometheusService|Time-series monitoring service" msgstr "" msgid "PrometheusService|View environments" diff --git a/qa/README.md b/qa/README.md index b937dc4c7a0..3c1b61900d9 100644 --- a/qa/README.md +++ b/qa/README.md @@ -34,9 +34,6 @@ You can use GitLab QA to exercise tests on any live instance! For example, the following call would login to a local [GDK] instance and run all specs in `qa/specs/features`: -First, `cd` into the `$gdk/gitlab/qa` directory. -The `bin/qa` script expects you to be in the `qa` folder of the app. - ``` bin/qa Test::Instance http://localhost:3000 ``` @@ -64,6 +64,7 @@ module QA autoload :Instance, 'qa/scenario/test/instance' module Integration + autoload :LDAP, 'qa/scenario/test/integration/ldap' autoload :Mattermost, 'qa/scenario/test/integration/mattermost' end diff --git a/qa/qa/factory/resource/secret_variable.rb b/qa/qa/factory/resource/secret_variable.rb index 54ef4d8d964..af0fa8af2df 100644 --- a/qa/qa/factory/resource/secret_variable.rb +++ b/qa/qa/factory/resource/secret_variable.rb @@ -31,7 +31,7 @@ module QA page.fill_variable_key(key) page.fill_variable_value(value) - page.add_variable + page.save_variables end end end diff --git a/qa/qa/page/main/login.rb b/qa/qa/page/main/login.rb index 95880475ffa..0d1ffd9694a 100644 --- a/qa/qa/page/main/login.rb +++ b/qa/qa/page/main/login.rb @@ -14,12 +14,32 @@ module QA element :sign_in_button, 'submit "Sign in"' end + view 'app/views/devise/sessions/_new_ldap.html.haml' do + element :username_field, 'text_field_tag :username' + element :password_field, 'password_field_tag :password' + element :sign_in_button, 'submit_tag "Sign in"' + end + + view 'app/views/devise/shared/_tabs_ldap.html.haml' do + element :ldap_tab, "link_to server['label']" + element :standard_tab, "link_to 'Standard'" + end + def initialize wait(max: 500) do page.has_css?('.application') end end + def sign_in_using_ldap_credentials + click_link 'LDAP' + + fill_in :username, with: Runtime::User.name + fill_in :password, with: Runtime::User.password + + click_button 'Sign in' + end + def sign_in_using_credentials using_wait_time 0 do if page.has_content?('Change your password') @@ -28,6 +48,8 @@ module QA click_button 'Change your password' end + click_link 'Standard' if page.has_content?('LDAP') + fill_in :user_login, with: Runtime::User.name fill_in :user_password, with: Runtime::User.password click_button 'Sign in' diff --git a/qa/qa/page/project/settings/secret_variables.rb b/qa/qa/page/project/settings/secret_variables.rb index e3bfbfcf080..fea4acb389a 100644 --- a/qa/qa/page/project/settings/secret_variables.rb +++ b/qa/qa/page/project/settings/secret_variables.rb @@ -5,49 +5,40 @@ module QA class SecretVariables < Page::Base include Common - view 'app/views/ci/variables/_table.html.haml' do - element :variable_key, '.variable-key' - element :variable_value, '.variable-value' + view 'app/views/ci/variables/_variable_row.html.haml' do + element :variable_key, '.js-ci-variable-input-key' + element :variable_value, '.js-ci-variable-input-value' end view 'app/views/ci/variables/_index.html.haml' do - element :add_new_variable, 'btn_text: "Add new variable"' - end - - view 'app/assets/javascripts/behaviors/secret_values.js' do - element :reveal_value, 'Reveal value' - element :hide_value, 'Hide value' + element :save_variables, '.js-secret-variables-save-button' end def fill_variable_key(key) - fill_in 'variable_key', with: key + page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do + page.find('.js-ci-variable-input-key').set(key) + end end def fill_variable_value(value) - fill_in 'variable_value', with: value + page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do + page.find('.js-ci-variable-input-value').set(value) + end end - def add_variable - click_on 'Add new variable' + def save_variables + click_button('Save variables') end def variable_key - page.find('.variable-key').text - end - - def variable_value - reveal_value do - page.find('.variable-value').text + page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do + page.find('.js-ci-variable-input-key').value end end - private - - def reveal_value - click_button('Reveal value') - - yield.tap do - click_button('Hide value') + def variable_value + page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do + page.find('.js-ci-variable-input-value').value end end end diff --git a/qa/qa/scenario/test/instance.rb b/qa/qa/scenario/test/instance.rb index 993bbd723a3..0af9afd1ea4 100644 --- a/qa/qa/scenario/test/instance.rb +++ b/qa/qa/scenario/test/instance.rb @@ -22,7 +22,12 @@ module QA Specs::Runner.perform do |specs| specs.tty = true specs.tags = self.class.focus - specs.files = files.any? ? files : 'qa/specs/features' + specs.files = + if files.any? + files + else + File.expand_path('../../specs/features', __dir__) + end end end end diff --git a/qa/qa/scenario/test/integration/ldap.rb b/qa/qa/scenario/test/integration/ldap.rb new file mode 100644 index 00000000000..257ed81d9e1 --- /dev/null +++ b/qa/qa/scenario/test/integration/ldap.rb @@ -0,0 +1,11 @@ +module QA + module Scenario + module Test + module Integration + class LDAP < Test::Instance + tags :ldap + end + end + end + end +end diff --git a/qa/qa/specs/features/login/ldap_spec.rb b/qa/qa/specs/features/login/ldap_spec.rb new file mode 100644 index 00000000000..ac2bd5a3c39 --- /dev/null +++ b/qa/qa/specs/features/login/ldap_spec.rb @@ -0,0 +1,15 @@ +module QA + feature 'LDAP user login', :ldap do + scenario 'user logs in using LDAP credentials' do + Runtime::Browser.visit(:gitlab, Page::Main::Login) + Page::Main::Login.act { sign_in_using_ldap_credentials } + + # TODO, since `Signed in successfully` message was removed + # this is the only way to tell if user is signed in correctly. + # + Page::Menu::Main.perform do |menu| + expect(menu).to have_personal_area + end + end + end +end diff --git a/qa/qa/specs/runner.rb b/qa/qa/specs/runner.rb index 3f7b75df986..752e3e60b8c 100644 --- a/qa/qa/specs/runner.rb +++ b/qa/qa/specs/runner.rb @@ -8,7 +8,7 @@ module QA def initialize @tty = false @tags = [] - @files = ['qa/specs/features'] + @files = [File.expand_path('./features', __dir__)] end def perform diff --git a/qa/spec/scenario/test/instance_spec.rb b/qa/spec/scenario/test/instance_spec.rb index 1824db54c9b..bd09c28e924 100644 --- a/qa/spec/scenario/test/instance_spec.rb +++ b/qa/spec/scenario/test/instance_spec.rb @@ -29,7 +29,8 @@ describe QA::Scenario::Test::Instance do it 'should call runner with default arguments' do subject.perform("test") - expect(runner).to have_received(:files=).with('qa/specs/features') + expect(runner).to have_received(:files=) + .with(File.expand_path('../../../qa/specs/features', __dir__)) end end diff --git a/spec/controllers/groups/variables_controller_spec.rb b/spec/controllers/groups/variables_controller_spec.rb index 8ea98cd9e8f..39a36b92bb4 100644 --- a/spec/controllers/groups/variables_controller_spec.rb +++ b/spec/controllers/groups/variables_controller_spec.rb @@ -9,48 +9,27 @@ describe Groups::VariablesController do group.add_master(user) end - describe 'POST #create' do - context 'variable is valid' do - it 'shows a success flash message' do - post :create, group_id: group, variable: { key: "one", value: "two" } - - expect(flash[:notice]).to include 'Variable was successfully created.' - expect(response).to redirect_to(group_settings_ci_cd_path(group)) - end - end - - context 'variable is invalid' do - it 'renders show' do - post :create, group_id: group, variable: { key: "..one", value: "two" } + describe 'GET #show' do + let!(:variable) { create(:ci_group_variable, group: group) } - expect(response).to render_template("groups/variables/show") - end + subject do + get :show, group_id: group, format: :json end - end - - describe 'POST #update' do - let(:variable) { create(:ci_group_variable) } - context 'updating a variable with valid characters' do - before do - group.variables << variable - end - - it 'shows a success flash message' do - post :update, group_id: group, - id: variable.id, variable: { key: variable.key, value: 'two' } - - expect(flash[:notice]).to include 'Variable was successfully updated.' - expect(response).to redirect_to(group_variables_path(group)) - end + include_examples 'GET #show lists all variables' + end - it 'renders the action #show if the variable key is invalid' do - post :update, group_id: group, - id: variable.id, variable: { key: '?', value: variable.value } + describe 'PATCH #update' do + let!(:variable) { create(:ci_group_variable, group: group) } + let(:owner) { group } - expect(response).to have_gitlab_http_status(200) - expect(response).to render_template :show - end + subject do + patch :update, + group_id: group, + variables_attributes: variables_attributes, + format: :json end + + include_examples 'PATCH #update updates variables' end end diff --git a/spec/controllers/help_controller_spec.rb b/spec/controllers/help_controller_spec.rb index f75048f422c..21d59c62613 100644 --- a/spec/controllers/help_controller_spec.rb +++ b/spec/controllers/help_controller_spec.rb @@ -68,7 +68,7 @@ describe HelpController do context 'when requested file exists' do it 'renders the raw file' do get :show, - path: 'user/project/img/labels_filter', + path: 'user/project/img/labels_default', format: :png expect(response).to be_success expect(response.content_type).to eq 'image/png' diff --git a/spec/controllers/projects/variables_controller_spec.rb b/spec/controllers/projects/variables_controller_spec.rb index 9fde6544215..68019743be0 100644 --- a/spec/controllers/projects/variables_controller_spec.rb +++ b/spec/controllers/projects/variables_controller_spec.rb @@ -9,50 +9,28 @@ describe Projects::VariablesController do project.add_master(user) end - describe 'POST #create' do - context 'variable is valid' do - it 'shows a success flash message' do - post :create, namespace_id: project.namespace.to_param, project_id: project, - variable: { key: "one", value: "two" } - - expect(flash[:notice]).to include 'Variable was successfully created.' - expect(response).to redirect_to(project_settings_ci_cd_path(project)) - end - end - - context 'variable is invalid' do - it 'renders show' do - post :create, namespace_id: project.namespace.to_param, project_id: project, - variable: { key: "..one", value: "two" } + describe 'GET #show' do + let!(:variable) { create(:ci_variable, project: project) } - expect(response).to render_template("projects/variables/show") - end + subject do + get :show, namespace_id: project.namespace.to_param, project_id: project, format: :json end - end - - describe 'POST #update' do - let(:variable) { create(:ci_variable) } - context 'updating a variable with valid characters' do - before do - project.variables << variable - end - - it 'shows a success flash message' do - post :update, namespace_id: project.namespace.to_param, project_id: project, - id: variable.id, variable: { key: variable.key, value: 'two' } - - expect(flash[:notice]).to include 'Variable was successfully updated.' - expect(response).to redirect_to(project_variables_path(project)) - end + include_examples 'GET #show lists all variables' + end - it 'renders the action #show if the variable key is invalid' do - post :update, namespace_id: project.namespace.to_param, project_id: project, - id: variable.id, variable: { key: '?', value: variable.value } + describe 'PATCH #update' do + let!(:variable) { create(:ci_variable, project: project) } + let(:owner) { project } - expect(response).to have_gitlab_http_status(200) - expect(response).to render_template :show - end + subject do + patch :update, + namespace_id: project.namespace.to_param, + project_id: project, + variables_attributes: variables_attributes, + format: :json end + + include_examples 'PATCH #update updates variables' end end diff --git a/spec/factories/lfs_file_locks.rb b/spec/factories/lfs_file_locks.rb new file mode 100644 index 00000000000..b9d24f82b65 --- /dev/null +++ b/spec/factories/lfs_file_locks.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :lfs_file_lock do + user + project + path 'README.md' + end +end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 20976977f21..f92b307fee4 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -249,7 +249,8 @@ FactoryBot.define do project.create_prometheus_service( active: true, properties: { - api_url: 'https://prometheus.example.com' + api_url: 'https://prometheus.example.com/', + manual_configuration: true } ) end diff --git a/spec/factories/services.rb b/spec/factories/services.rb index 110ef33c6f7..0d4fd49bf3a 100644 --- a/spec/factories/services.rb +++ b/spec/factories/services.rb @@ -30,7 +30,8 @@ FactoryBot.define do project active true properties({ - api_url: 'https://prometheus.example.com/' + api_url: 'https://prometheus.example.com/', + manual_configuration: true }) end diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index cc0849d1cc6..39b213988f0 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -47,6 +47,16 @@ feature 'Admin updates settings' do expect(page).to have_content "Application settings saved successfully" end + scenario 'Change AutoDevOps settings' do + check 'Enabled Auto DevOps (Beta) for projects by default' + fill_in 'Auto devops domain', with: 'domain.com' + click_button 'Save' + + expect(Gitlab::CurrentSettings.auto_devops_enabled?).to be true + expect(Gitlab::CurrentSettings.auto_devops_domain).to eq('domain.com') + expect(page).to have_content "Application settings saved successfully" + end + scenario 'Change Slack Notifications Service template settings' do first(:link, 'Service Templates').click click_link 'Slack notifications' diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index a69b428d117..2307ba5985e 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -26,8 +26,8 @@ describe "Admin::Users" do expect(page).to have_content(user.email) expect(page).to have_content(user.name) expect(page).to have_link('Block', href: block_admin_user_path(user)) - expect(page).to have_link('Remove user', href: admin_user_path(user)) - expect(page).to have_link('Remove user and contributions', href: admin_user_path(user, hard_delete: true)) + expect(page).to have_button('Delete user') + expect(page).to have_button('Delete user and contributions') end describe 'Two-factor Authentication filters' do @@ -122,8 +122,8 @@ describe "Admin::Users" do expect(page).to have_content(user.email) expect(page).to have_content(user.name) expect(page).to have_link('Block user', href: block_admin_user_path(user)) - expect(page).to have_link('Remove user', href: admin_user_path(user)) - expect(page).to have_link('Remove user and contributions', href: admin_user_path(user, hard_delete: true)) + expect(page).to have_button('Delete user') + expect(page).to have_button('Delete user and contributions') end describe 'Impersonation' do diff --git a/spec/features/group_variables_spec.rb b/spec/features/group_variables_spec.rb index e9b375f4c94..f7863807572 100644 --- a/spec/features/group_variables_spec.rb +++ b/spec/features/group_variables_spec.rb @@ -3,76 +3,15 @@ require 'spec_helper' feature 'Group variables', :js do let(:user) { create(:user) } let(:group) { create(:group) } + let!(:variable) { create(:ci_group_variable, key: 'test_key', value: 'test value', group: group) } + let(:page_path) { group_settings_ci_cd_path(group) } background do group.add_master(user) gitlab_sign_in(user) - end - - context 'when user creates a new variable' do - background do - visit group_settings_ci_cd_path(group) - fill_in 'variable_key', with: 'AAA' - fill_in 'variable_value', with: 'AAA123' - find(:css, "#variable_protected").set(true) - click_on 'Add new variable' - end - - scenario 'user sees the created variable' do - page.within('.variables-table') do - expect(find(".variable-key")).to have_content('AAA') - expect(find(".variable-value")).to have_content('******') - expect(find(".variable-protected")).to have_content('Yes') - end - click_on 'Reveal value' - page.within('.variables-table') do - expect(find(".variable-value")).to have_content('AAA123') - end - end - end - - context 'when user edits a variable' do - background do - create(:ci_group_variable, key: 'AAA', value: 'AAA123', protected: true, - group: group) - - visit group_settings_ci_cd_path(group) - page.within('.variable-menu') do - click_on 'Update' - end - - fill_in 'variable_key', with: 'BBB' - fill_in 'variable_value', with: 'BBB123' - find(:css, "#variable_protected").set(false) - click_on 'Save variable' - end - - scenario 'user sees the updated variable' do - page.within('.variables-table') do - expect(find(".variable-key")).to have_content('BBB') - expect(find(".variable-value")).to have_content('******') - expect(find(".variable-protected")).to have_content('No') - end - end + visit page_path end - context 'when user deletes a variable' do - background do - create(:ci_group_variable, key: 'BBB', value: 'BBB123', protected: false, - group: group) - - visit group_settings_ci_cd_path(group) - - page.within('.variable-menu') do - page.accept_alert 'Are you sure?' do - click_on 'Remove' - end - end - end - - scenario 'user does not see the deleted variable' do - expect(page).to have_no_css('.variables-table') - end - end + it_behaves_like 'variable list' end diff --git a/spec/features/profiles/user_visits_profile_spec.rb b/spec/features/profiles/user_visits_profile_spec.rb index 6601d3039ed..a5d80439143 100644 --- a/spec/features/profiles/user_visits_profile_spec.rb +++ b/spec/features/profiles/user_visits_profile_spec.rb @@ -12,4 +12,13 @@ describe 'User visits their profile' do it 'shows correct menu item' do expect(page).to have_active_navigation('Profile') end + + describe 'profile settings', :js do + it 'saves updates' do + fill_in 'user_bio', with: 'bio' + click_button 'Update profile settings' + + expect(page).to have_content('Profile was successfully updated') + end + end end diff --git a/spec/features/project_variables_spec.rb b/spec/features/project_variables_spec.rb new file mode 100644 index 00000000000..0ba2224359a --- /dev/null +++ b/spec/features/project_variables_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe 'Project variables', :js do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:variable) { create(:ci_variable, key: 'test_key', value: 'test value') } + let(:page_path) { project_settings_ci_cd_path(project) } + + before do + sign_in(user) + project.add_master(user) + project.variables << variable + + visit page_path + end + + it_behaves_like 'variable list' +end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index 592c99fc64a..37a06b65481 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -109,7 +109,8 @@ describe 'Pipelines', :js do context 'when canceling' do before do - accept_confirm { find('.js-pipelines-cancel-button').click } + find('.js-pipelines-cancel-button').click + find('.js-primary-button').click wait_for_requests end @@ -140,6 +141,7 @@ describe 'Pipelines', :js do context 'when retrying' do before do find('.js-pipelines-retry-button').click + find('.js-primary-button').click wait_for_requests end @@ -238,7 +240,8 @@ describe 'Pipelines', :js do context 'when canceling' do before do - accept_alert { find('.js-pipelines-cancel-button').click } + find('.js-pipelines-cancel-button').click + find('.js-primary-button').click end it 'indicates that pipeline was canceled' do diff --git a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb b/spec/features/projects/wiki/user_updates_wiki_page_spec.rb index 4d2a08afecc..ef1bb712846 100644 --- a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_updates_wiki_page_spec.rb @@ -1,226 +1,234 @@ require 'spec_helper' -# Remove skip_gitaly_mock flag when gitaly_update_page implements moving pages -describe 'User updates wiki page', :skip_gitaly_mock do - let(:user) { create(:user) } - - before do - project.add_master(user) - sign_in(user) - end - - context 'when wiki is empty' do +describe 'User updates wiki page' do + shared_examples 'wiki page user update' do + let(:user) { create(:user) } before do - visit(project_wikis_path(project)) + project.add_master(user) + sign_in(user) end - context 'in a user namespace' do - let(:project) { create(:project, namespace: user.namespace) } - - it 'redirects back to the home edit page' do - page.within(:css, '.wiki-form .form-actions') do - click_on('Cancel') - end - - expect(current_path).to eq project_wiki_path(project, :home) + context 'when wiki is empty' do + before do + visit(project_wikis_path(project)) end - it 'updates a page that has a path', :js do - click_on('New page') + context 'in a user namespace' do + let(:project) { create(:project, namespace: user.namespace) } - page.within('#modal-new-wiki') do - fill_in(:new_wiki_path, with: 'one/two/three-test') - click_on('Create page') - end + it 'redirects back to the home edit page' do + page.within(:css, '.wiki-form .form-actions') do + click_on('Cancel') + end - page.within '.wiki-form' do - fill_in(:wiki_content, with: 'wiki content') - click_on('Create page') + expect(current_path).to eq project_wiki_path(project, :home) end - expect(current_path).to include('one/two/three-test') - expect(find('.wiki-pages')).to have_content('Three') + it 'updates a page that has a path', :js do + click_on('New page') - first(:link, text: 'Three').click + page.within('#modal-new-wiki') do + fill_in(:new_wiki_path, with: 'one/two/three-test') + click_on('Create page') + end - expect(find('.nav-text')).to have_content('Three') + page.within '.wiki-form' do + fill_in(:wiki_content, with: 'wiki content') + click_on('Create page') + end - click_on('Edit') + expect(current_path).to include('one/two/three-test') + expect(find('.wiki-pages')).to have_content('Three') - expect(current_path).to include('one/two/three-test') - expect(page).to have_content('Edit Page') + first(:link, text: 'Three').click - fill_in('Content', with: 'Updated Wiki Content') - click_on('Save changes') + expect(find('.nav-text')).to have_content('Three') - expect(page).to have_content('Updated Wiki Content') - end - end - end + click_on('Edit') - context 'when wiki is not empty' do - let(:project_wiki) { create(:project_wiki, project: project, user: project.creator) } - let!(:wiki_page) { create(:wiki_page, wiki: project_wiki, attrs: { title: 'home', content: 'Home page' }) } + expect(current_path).to include('one/two/three-test') + expect(page).to have_content('Edit Page') - before do - visit(project_wikis_path(project)) + fill_in('Content', with: 'Updated Wiki Content') + click_on('Save changes') + + expect(page).to have_content('Updated Wiki Content') + end + end end - context 'in a user namespace' do - let(:project) { create(:project, namespace: user.namespace) } + context 'when wiki is not empty' do + let(:project_wiki) { create(:project_wiki, project: project, user: project.creator) } + let!(:wiki_page) { create(:wiki_page, wiki: project_wiki, attrs: { title: 'home', content: 'Home page' }) } - it 'updates a page' do - click_link('Edit') + before do + visit(project_wikis_path(project)) + end - # Commit message field should have correct value. - expect(page).to have_field('wiki[message]', with: 'Update home') + context 'in a user namespace' do + let(:project) { create(:project, namespace: user.namespace) } - fill_in(:wiki_content, with: 'My awesome wiki!') - click_button('Save changes') + it 'updates a page' do + click_link('Edit') - expect(page).to have_content('Home') - expect(page).to have_content("Last edited by #{user.name}") - expect(page).to have_content('My awesome wiki!') - end + # Commit message field should have correct value. + expect(page).to have_field('wiki[message]', with: 'Update home') - it 'shows a validation error message' do - click_link('Edit') + fill_in(:wiki_content, with: 'My awesome wiki!') + click_button('Save changes') - fill_in(:wiki_content, with: '') - click_button('Save changes') + expect(page).to have_content('Home') + expect(page).to have_content("Last edited by #{user.name}") + expect(page).to have_content('My awesome wiki!') + end - expect(page).to have_selector('.wiki-form') - expect(page).to have_content('Edit Page') - expect(page).to have_content('The form contains the following error:') - expect(page).to have_content("Content can't be blank") - expect(find('textarea#wiki_content').value).to eq('') - end + it 'shows a validation error message' do + click_link('Edit') - it 'shows the autocompletion dropdown', :js do - click_link('Edit') + fill_in(:wiki_content, with: '') + click_button('Save changes') - find('#wiki_content').native.send_keys('') - fill_in(:wiki_content, with: '@') + expect(page).to have_selector('.wiki-form') + expect(page).to have_content('Edit Page') + expect(page).to have_content('The form contains the following error:') + expect(page).to have_content("Content can't be blank") + expect(find('textarea#wiki_content').value).to eq('') + end - expect(page).to have_selector('.atwho-view') - end + it 'shows the autocompletion dropdown', :js do + click_link('Edit') - it 'shows the error message' do - click_link('Edit') + find('#wiki_content').native.send_keys('') + fill_in(:wiki_content, with: '@') - wiki_page.update(content: 'Update') + expect(page).to have_selector('.atwho-view') + end - click_button('Save changes') + it 'shows the error message' do + click_link('Edit') - expect(page).to have_content('Someone edited the page the same time you did.') - end + wiki_page.update(content: 'Update') - it 'updates a page' do - click_on('Edit') - fill_in('Content', with: 'Updated Wiki Content') - click_on('Save changes') + click_button('Save changes') - expect(page).to have_content('Updated Wiki Content') - end + expect(page).to have_content('Someone edited the page the same time you did.') + end - it 'cancels edititng of a page' do - click_on('Edit') + it 'updates a page' do + click_on('Edit') + fill_in('Content', with: 'Updated Wiki Content') + click_on('Save changes') - page.within(:css, '.wiki-form .form-actions') do - click_on('Cancel') + expect(page).to have_content('Updated Wiki Content') end - expect(current_path).to eq(project_wiki_path(project, wiki_page)) + it 'cancels edititng of a page' do + click_on('Edit') + + page.within(:css, '.wiki-form .form-actions') do + click_on('Cancel') + end + + expect(current_path).to eq(project_wiki_path(project, wiki_page)) + end end - end - context 'in a group namespace' do - let(:project) { create(:project, namespace: create(:group, :public)) } + context 'in a group namespace' do + let(:project) { create(:project, namespace: create(:group, :public)) } - it 'updates a page' do - click_link('Edit') + it 'updates a page' do + click_link('Edit') - # Commit message field should have correct value. - expect(page).to have_field('wiki[message]', with: 'Update home') + # Commit message field should have correct value. + expect(page).to have_field('wiki[message]', with: 'Update home') - fill_in(:wiki_content, with: 'My awesome wiki!') + fill_in(:wiki_content, with: 'My awesome wiki!') - click_button('Save changes') + click_button('Save changes') - expect(page).to have_content('Home') - expect(page).to have_content("Last edited by #{user.name}") - expect(page).to have_content('My awesome wiki!') + expect(page).to have_content('Home') + expect(page).to have_content("Last edited by #{user.name}") + expect(page).to have_content('My awesome wiki!') + end end end - end - context 'when the page is in a subdir' do - let!(:project) { create(:project, namespace: user.namespace) } - let(:project_wiki) { create(:project_wiki, project: project, user: project.creator) } - let(:page_name) { 'page_name' } - let(:page_dir) { "foo/bar/#{page_name}" } - let!(:wiki_page) { create(:wiki_page, wiki: project_wiki, attrs: { title: page_dir, content: 'Home page' }) } + context 'when the page is in a subdir' do + let!(:project) { create(:project, namespace: user.namespace) } + let(:project_wiki) { create(:project_wiki, project: project, user: project.creator) } + let(:page_name) { 'page_name' } + let(:page_dir) { "foo/bar/#{page_name}" } + let!(:wiki_page) { create(:wiki_page, wiki: project_wiki, attrs: { title: page_dir, content: 'Home page' }) } - before do - visit(project_wiki_edit_path(project, wiki_page)) - end + before do + visit(project_wiki_edit_path(project, wiki_page)) + end - it 'moves the page to the root folder' do - fill_in(:wiki_title, with: "/#{page_name}") + it 'moves the page to the root folder', :skip_gitaly_mock do + fill_in(:wiki_title, with: "/#{page_name}") - click_button('Save changes') + click_button('Save changes') - expect(current_path).to eq(project_wiki_path(project, page_name)) - end + expect(current_path).to eq(project_wiki_path(project, page_name)) + end - it 'moves the page to other dir' do - new_page_dir = "foo1/bar1/#{page_name}" + it 'moves the page to other dir' do + new_page_dir = "foo1/bar1/#{page_name}" - fill_in(:wiki_title, with: new_page_dir) + fill_in(:wiki_title, with: new_page_dir) - click_button('Save changes') + click_button('Save changes') - expect(current_path).to eq(project_wiki_path(project, new_page_dir)) - end + expect(current_path).to eq(project_wiki_path(project, new_page_dir)) + end - it 'remains in the same place if title has not changed' do - original_path = project_wiki_path(project, wiki_page) + it 'remains in the same place if title has not changed' do + original_path = project_wiki_path(project, wiki_page) - fill_in(:wiki_title, with: page_name) + fill_in(:wiki_title, with: page_name) - click_button('Save changes') + click_button('Save changes') - expect(current_path).to eq(original_path) - end + expect(current_path).to eq(original_path) + end - it 'can be moved to a different dir with a different name' do - new_page_dir = "foo1/bar1/new_page_name" + it 'can be moved to a different dir with a different name' do + new_page_dir = "foo1/bar1/new_page_name" - fill_in(:wiki_title, with: new_page_dir) + fill_in(:wiki_title, with: new_page_dir) - click_button('Save changes') + click_button('Save changes') - expect(current_path).to eq(project_wiki_path(project, new_page_dir)) - end + expect(current_path).to eq(project_wiki_path(project, new_page_dir)) + end - it 'can be renamed and moved to the root folder' do - new_name = 'new_page_name' + it 'can be renamed and moved to the root folder' do + new_name = 'new_page_name' - fill_in(:wiki_title, with: "/#{new_name}") + fill_in(:wiki_title, with: "/#{new_name}") - click_button('Save changes') + click_button('Save changes') - expect(current_path).to eq(project_wiki_path(project, new_name)) - end + expect(current_path).to eq(project_wiki_path(project, new_name)) + end - it 'squishes the title before creating the page' do - new_page_dir = " foo1 / bar1 / #{page_name} " + it 'squishes the title before creating the page' do + new_page_dir = " foo1 / bar1 / #{page_name} " - fill_in(:wiki_title, with: new_page_dir) + fill_in(:wiki_title, with: new_page_dir) - click_button('Save changes') + click_button('Save changes') - expect(current_path).to eq(project_wiki_path(project, "foo1/bar1/#{page_name}")) + expect(current_path).to eq(project_wiki_path(project, "foo1/bar1/#{page_name}")) + end end end + + context 'when Gitaly is enabled' do + it_behaves_like 'wiki page user update' + end + + context 'when Gitaly is disabled', :skip_gitaly_mock do + it_behaves_like 'wiki page user update' + end end diff --git a/spec/features/projects/wiki/user_views_wiki_page_spec.rb b/spec/features/projects/wiki/user_views_wiki_page_spec.rb index e37436838fd..306e382119a 100644 --- a/spec/features/projects/wiki/user_views_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_views_wiki_page_spec.rb @@ -1,146 +1,155 @@ require 'spec_helper' -# Remove skip_gitaly_mock flag when gitaly_update_page implements moving pages -describe 'User views a wiki page', :skip_gitaly_mock do - let(:user) { create(:user) } - let(:project) { create(:project, namespace: user.namespace) } - let(:wiki_page) do - create(:wiki_page, - wiki: project.wiki, - attrs: { title: 'home', content: 'Look at this [image](image.jpg)\n\n ![alt text](image.jpg)' }) - end - - before do - project.add_master(user) - sign_in(user) - end +describe 'User views a wiki page' do + shared_examples 'wiki page user view' do + let(:user) { create(:user) } + let(:project) { create(:project, namespace: user.namespace) } + let(:wiki_page) do + create(:wiki_page, + wiki: project.wiki, + attrs: { title: 'home', content: 'Look at this [image](image.jpg)\n\n ![alt text](image.jpg)' }) + end - context 'when wiki is empty' do before do - visit(project_wikis_path(project)) + project.add_master(user) + sign_in(user) + end - click_on('New page') + context 'when wiki is empty' do + before do + visit(project_wikis_path(project)) - page.within('#modal-new-wiki') do - fill_in(:new_wiki_path, with: 'one/two/three-test') - click_on('Create page') - end + click_on('New page') - page.within('.wiki-form') do - fill_in(:wiki_content, with: 'wiki content') - click_on('Create page') + page.within('#modal-new-wiki') do + fill_in(:new_wiki_path, with: 'one/two/three-test') + click_on('Create page') + end + + page.within('.wiki-form') do + fill_in(:wiki_content, with: 'wiki content') + click_on('Create page') + end end - end - it 'shows the history of a page that has a path', :js do - expect(current_path).to include('one/two/three-test') + it 'shows the history of a page that has a path', :js do + expect(current_path).to include('one/two/three-test') - first(:link, text: 'Three').click - click_on('Page history') + first(:link, text: 'Three').click + click_on('Page history') - expect(current_path).to include('one/two/three-test') + expect(current_path).to include('one/two/three-test') - page.within(:css, '.nav-text') do - expect(page).to have_content('History') + page.within(:css, '.nav-text') do + expect(page).to have_content('History') + end end - end - it 'shows an old version of a page', :js do - expect(current_path).to include('one/two/three-test') - expect(find('.wiki-pages')).to have_content('Three') + it 'shows an old version of a page', :js do + expect(current_path).to include('one/two/three-test') + expect(find('.wiki-pages')).to have_content('Three') - first(:link, text: 'Three').click + first(:link, text: 'Three').click - expect(find('.nav-text')).to have_content('Three') + expect(find('.nav-text')).to have_content('Three') - click_on('Edit') + click_on('Edit') - expect(current_path).to include('one/two/three-test') - expect(page).to have_content('Edit Page') + expect(current_path).to include('one/two/three-test') + expect(page).to have_content('Edit Page') - fill_in('Content', with: 'Updated Wiki Content') + fill_in('Content', with: 'Updated Wiki Content') - click_on('Save changes') - click_on('Page history') + click_on('Save changes') + click_on('Page history') - page.within(:css, '.nav-text') do - expect(page).to have_content('History') - end + page.within(:css, '.nav-text') do + expect(page).to have_content('History') + end - find('a[href*="?version_id"]') + find('a[href*="?version_id"]') + end end - end - context 'when a page does not have history' do - before do - visit(project_wiki_path(project, wiki_page)) - end + context 'when a page does not have history' do + before do + visit(project_wiki_path(project, wiki_page)) + end - it 'shows all the pages' do - expect(page).to have_content(user.name) - expect(find('.wiki-pages')).to have_content(wiki_page.title.capitalize) - end + it 'shows all the pages' do + expect(page).to have_content(user.name) + expect(find('.wiki-pages')).to have_content(wiki_page.title.capitalize) + end - it 'shows a file stored in a page' do - gollum_file_double = double('Gollum::File', - mime_type: 'image/jpeg', - name: 'images/image.jpg', - path: 'images/image.jpg', - raw_data: '') - wiki_file = Gitlab::Git::WikiFile.new(gollum_file_double) + it 'shows a file stored in a page' do + gollum_file_double = double('Gollum::File', + mime_type: 'image/jpeg', + name: 'images/image.jpg', + path: 'images/image.jpg', + raw_data: '') + wiki_file = Gitlab::Git::WikiFile.new(gollum_file_double) - allow(wiki_file).to receive(:mime_type).and_return('image/jpeg') - allow_any_instance_of(ProjectWiki).to receive(:find_file).with('image.jpg', nil).and_return(wiki_file) + allow(wiki_file).to receive(:mime_type).and_return('image/jpeg') + allow_any_instance_of(ProjectWiki).to receive(:find_file).with('image.jpg', nil).and_return(wiki_file) - expect(page).to have_xpath('//img[@data-src="image.jpg"]') - expect(page).to have_link('image', href: "#{project.wiki.wiki_base_path}/image.jpg") + expect(page).to have_xpath('//img[@data-src="image.jpg"]') + expect(page).to have_link('image', href: "#{project.wiki.wiki_base_path}/image.jpg") - click_on('image') + click_on('image') - expect(current_path).to match('wikis/image.jpg') - expect(page).not_to have_xpath('/html') # Page should render the image which means there is no html involved - end + expect(current_path).to match('wikis/image.jpg') + expect(page).not_to have_xpath('/html') # Page should render the image which means there is no html involved + end - it 'shows the creation page if file does not exist' do - expect(page).to have_link('image', href: "#{project.wiki.wiki_base_path}/image.jpg") + it 'shows the creation page if file does not exist' do + expect(page).to have_link('image', href: "#{project.wiki.wiki_base_path}/image.jpg") - click_on('image') + click_on('image') - expect(current_path).to match('wikis/image.jpg') - expect(page).to have_content('New Wiki Page') - expect(page).to have_content('Create page') + expect(current_path).to match('wikis/image.jpg') + expect(page).to have_content('New Wiki Page') + expect(page).to have_content('Create page') + end end - end - context 'when a page has history' do - before do - wiki_page.update(message: 'updated home', content: 'updated [some link](other-page)') - end + context 'when a page has history' do + before do + wiki_page.update(message: 'updated home', content: 'updated [some link](other-page)') + end - it 'shows the page history' do - visit(project_wiki_path(project, wiki_page)) + it 'shows the page history' do + visit(project_wiki_path(project, wiki_page)) - expect(page).to have_selector('a.btn', text: 'Edit') + expect(page).to have_selector('a.btn', text: 'Edit') - click_on('Page history') + click_on('Page history') - expect(page).to have_content(user.name) - expect(page).to have_content("#{user.username} created page: home") - expect(page).to have_content('updated home') + expect(page).to have_content(user.name) + expect(page).to have_content("#{user.username} created page: home") + expect(page).to have_content('updated home') + end + + it 'does not show the "Edit" button' do + visit(project_wiki_path(project, wiki_page, version_id: wiki_page.versions.last.id)) + + expect(page).not_to have_selector('a.btn', text: 'Edit') + end end - it 'does not show the "Edit" button' do - visit(project_wiki_path(project, wiki_page, version_id: wiki_page.versions.last.id)) + it 'opens a default wiki page', :js do + visit(project_path(project)) - expect(page).not_to have_selector('a.btn', text: 'Edit') + find('.shortcuts-wiki').click + + expect(page).to have_content('Home · Create Page') end end - it 'opens a default wiki page', :js do - visit(project_path(project)) - - find('.shortcuts-wiki').click + context 'when Gitaly is enabled' do + it_behaves_like 'wiki page user view' + end - expect(page).to have_content('Home · Create Page') + context 'when Gitaly is disabled', :skip_gitaly_mock do + it_behaves_like 'wiki page user view' end end diff --git a/spec/features/variables_spec.rb b/spec/features/variables_spec.rb deleted file mode 100644 index 79ca2b4bb4a..00000000000 --- a/spec/features/variables_spec.rb +++ /dev/null @@ -1,145 +0,0 @@ -require 'spec_helper' - -describe 'Project variables', :js do - let(:user) { create(:user) } - let(:project) { create(:project) } - let(:variable) { create(:ci_variable, key: 'test_key', value: 'test value') } - - before do - sign_in(user) - project.add_master(user) - project.variables << variable - - visit project_settings_ci_cd_path(project) - end - - it 'shows list of variables' do - page.within('.variables-table') do - expect(page).to have_content(variable.key) - end - end - - it 'adds new secret variable' do - fill_in('variable_key', with: 'key') - fill_in('variable_value', with: 'key value') - click_button('Add new variable') - - expect(page).to have_content('Variable was successfully created.') - page.within('.variables-table') do - expect(page).to have_content('key') - expect(page).to have_content('No') - end - end - - it 'adds empty variable' do - fill_in('variable_key', with: 'new_key') - fill_in('variable_value', with: '') - click_button('Add new variable') - - expect(page).to have_content('Variable was successfully created.') - page.within('.variables-table') do - expect(page).to have_content('new_key') - end - end - - it 'adds new protected variable' do - fill_in('variable_key', with: 'key') - fill_in('variable_value', with: 'value') - check('Protected') - click_button('Add new variable') - - expect(page).to have_content('Variable was successfully created.') - page.within('.variables-table') do - expect(page).to have_content('key') - expect(page).to have_content('Yes') - end - end - - it 'reveals and hides new variable' do - fill_in('variable_key', with: 'key') - fill_in('variable_value', with: 'key value') - click_button('Add new variable') - - page.within('.variables-table') do - expect(page).to have_content('key') - expect(page).to have_content('******') - end - - click_button('Reveal values') - - page.within('.variables-table') do - expect(page).to have_content('key') - expect(page).to have_content('key value') - end - - click_button('Hide values') - - page.within('.variables-table') do - expect(page).to have_content('key') - expect(page).to have_content('******') - end - end - - it 'deletes variable' do - page.within('.variables-table') do - accept_confirm { click_on 'Remove' } - end - - expect(page).not_to have_selector('variables-table') - end - - it 'edits variable' do - page.within('.variables-table') do - click_on 'Update' - end - - expect(page).to have_content('Update variable') - fill_in('variable_key', with: 'key') - fill_in('variable_value', with: 'key value') - click_button('Save variable') - - expect(page).to have_content('Variable was successfully updated.') - expect(project.variables(true).first.value).to eq('key value') - end - - it 'edits variable with empty value' do - page.within('.variables-table') do - click_on 'Update' - end - - expect(page).to have_content('Update variable') - fill_in('variable_value', with: '') - click_button('Save variable') - - expect(page).to have_content('Variable was successfully updated.') - expect(project.variables(true).first.value).to eq('') - end - - it 'edits variable to be protected' do - page.within('.variables-table') do - click_on 'Update' - end - - expect(page).to have_content('Update variable') - check('Protected') - click_button('Save variable') - - expect(page).to have_content('Variable was successfully updated.') - expect(project.variables(true).first).to be_protected - end - - it 'edits variable to be unprotected' do - project.variables.first.update(protected: true) - - page.within('.variables-table') do - click_on 'Update' - end - - expect(page).to have_content('Update variable') - uncheck('Protected') - click_button('Save variable') - - expect(page).to have_content('Variable was successfully updated.') - expect(project.variables(true).first).not_to be_protected - end -end diff --git a/spec/fixtures/api/schemas/public_api/v4/blobs.json b/spec/fixtures/api/schemas/public_api/v4/blobs.json new file mode 100644 index 00000000000..9cb1eae3762 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/blobs.json @@ -0,0 +1,18 @@ +{ + "type": "array", + "items": { + "type": "object", + "properties" : { + "basename": { "type": "string" }, + "data": { "type": "string" }, + "filename": { "type": ["string"] }, + "id": { "type": ["string", "null"] }, + "ref": { "type": "string" }, + "startline": { "type": "integer" } + }, + "required": [ + "basename", "data", "filename", "id", "ref", "startline" + ], + "additionalProperties": false + } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/issue.json b/spec/fixtures/api/schemas/public_api/v4/issue.json new file mode 100644 index 00000000000..147f53239e0 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/issue.json @@ -0,0 +1,96 @@ +{ + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "project_id": { "type": "integer" }, + "title": { "type": "string" }, + "description": { "type": ["string", "null"] }, + "state": { "type": "string" }, + "discussion_locked": { "type": ["boolean", "null"] }, + "closed_at": { "type": "date" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "milestone": { + "type": ["object", "null"], + "properties": { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "project_id": { "type": ["integer", "null"] }, + "group_id": { "type": ["integer", "null"] }, + "title": { "type": "string" }, + "description": { "type": ["string", "null"] }, + "state": { "type": "string" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "due_date": { "type": "date" }, + "start_date": { "type": "date" } + }, + "additionalProperties": false + }, + "assignees": { + "type": "array", + "items": { + "type": ["object", "null"], + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + } + }, + "assignee": { + "type": ["object", "null"], + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + }, + "author": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + }, + "user_notes_count": { "type": "integer" }, + "upvotes": { "type": "integer" }, + "downvotes": { "type": "integer" }, + "due_date": { "type": ["date", "null"] }, + "confidential": { "type": "boolean" }, + "web_url": { "type": "uri" }, + "time_stats": { + "time_estimate": { "type": "integer" }, + "total_time_spent": { "type": "integer" }, + "human_time_estimate": { "type": ["string", "null"] }, + "human_total_time_spent": { "type": ["string", "null"] } + } + }, + "required": [ + "id", "iid", "project_id", "title", "description", + "state", "created_at", "updated_at", "labels", + "milestone", "assignees", "author", "user_notes_count", + "upvotes", "downvotes", "due_date", "confidential", + "web_url" + ] +} diff --git a/spec/fixtures/api/schemas/public_api/v4/issues.json b/spec/fixtures/api/schemas/public_api/v4/issues.json index 5c08dbc3b96..c76806705e8 100644 --- a/spec/fixtures/api/schemas/public_api/v4/issues.json +++ b/spec/fixtures/api/schemas/public_api/v4/issues.json @@ -3,98 +3,7 @@ "items": { "type": "object", "properties" : { - "id": { "type": "integer" }, - "iid": { "type": "integer" }, - "project_id": { "type": "integer" }, - "title": { "type": "string" }, - "description": { "type": ["string", "null"] }, - "state": { "type": "string" }, - "discussion_locked": { "type": ["boolean", "null"] }, - "closed_at": { "type": "date" }, - "created_at": { "type": "date" }, - "updated_at": { "type": "date" }, - "labels": { - "type": "array", - "items": { - "type": "string" - } - }, - "milestone": { - "type": "object", - "properties": { - "id": { "type": "integer" }, - "iid": { "type": "integer" }, - "project_id": { "type": ["integer", "null"] }, - "group_id": { "type": ["integer", "null"] }, - "title": { "type": "string" }, - "description": { "type": ["string", "null"] }, - "state": { "type": "string" }, - "created_at": { "type": "date" }, - "updated_at": { "type": "date" }, - "due_date": { "type": "date" }, - "start_date": { "type": "date" } - }, - "additionalProperties": false - }, - "assignees": { - "type": "array", - "items": { - "type": ["object", "null"], - "properties": { - "name": { "type": "string" }, - "username": { "type": "string" }, - "id": { "type": "integer" }, - "state": { "type": "string" }, - "avatar_url": { "type": "uri" }, - "web_url": { "type": "uri" } - }, - "additionalProperties": false - } - }, - "assignee": { - "type": ["object", "null"], - "properties": { - "name": { "type": "string" }, - "username": { "type": "string" }, - "id": { "type": "integer" }, - "state": { "type": "string" }, - "avatar_url": { "type": "uri" }, - "web_url": { "type": "uri" } - }, - "additionalProperties": false - }, - "author": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "username": { "type": "string" }, - "id": { "type": "integer" }, - "state": { "type": "string" }, - "avatar_url": { "type": "uri" }, - "web_url": { "type": "uri" } - }, - "additionalProperties": false - }, - "user_notes_count": { "type": "integer" }, - "upvotes": { "type": "integer" }, - "downvotes": { "type": "integer" }, - "due_date": { "type": ["date", "null"] }, - "confidential": { "type": "boolean" }, - "web_url": { "type": "uri" }, - "time_stats": { - "time_estimate": { "type": "integer" }, - "total_time_spent": { "type": "integer" }, - "human_time_estimate": { "type": ["string", "null"] }, - "human_total_time_spent": { "type": ["string", "null"] } - } - }, - "required": [ - "id", "iid", "project_id", "title", "description", - "state", "created_at", "updated_at", "labels", - "milestone", "assignees", "author", "user_notes_count", - "upvotes", "downvotes", "due_date", "confidential", - "web_url" - ], - "additionalProperties": false + "$ref": "./issue.json" + } } } diff --git a/spec/fixtures/api/schemas/public_api/v4/merge_requests.json b/spec/fixtures/api/schemas/public_api/v4/merge_requests.json index 034509091a5..e86176e5316 100644 --- a/spec/fixtures/api/schemas/public_api/v4/merge_requests.json +++ b/spec/fixtures/api/schemas/public_api/v4/merge_requests.json @@ -28,7 +28,7 @@ "additionalProperties": false }, "assignee": { - "type": "object", + "type": ["object", "null"], "properties": { "name": { "type": "string" }, "username": { "type": "string" }, diff --git a/spec/fixtures/api/schemas/public_api/v4/milestones.json b/spec/fixtures/api/schemas/public_api/v4/milestones.json new file mode 100644 index 00000000000..c3c42b6ee60 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/milestones.json @@ -0,0 +1,24 @@ +{ + "type": "array", + "items": { + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "project_id": { "type": ["integer", "null"] }, + "group_id": { "type": ["integer", "null"] }, + "title": { "type": "string" }, + "description": { "type": ["string", "null"] }, + "state": { "type": "string" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "start_date": { "type": "date" }, + "due_date": { "type": "date" } + }, + "required": [ + "id", "iid", "title", "description", "state", + "state", "created_at", "updated_at", "start_date", "due_date" + ], + "additionalProperties": false + } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/notes.json b/spec/fixtures/api/schemas/public_api/v4/notes.json new file mode 100644 index 00000000000..6525f7c2c80 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/notes.json @@ -0,0 +1,34 @@ +{ + "type": "array", + "items": { + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "body": { "type": "string" }, + "attachment": { "type": ["string", "null"] }, + "author": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "system": { "type": "boolean" }, + "noteable_id": { "type": "integer" }, + "noteable_iid": { "type": "integer" }, + "noteable_type": { "type": "string" } + }, + "required": [ + "id", "body", "attachment", "author", "created_at", "updated_at", + "system", "noteable_id", "noteable_type" + ], + "additionalProperties": false + } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/projects.json b/spec/fixtures/api/schemas/public_api/v4/projects.json new file mode 100644 index 00000000000..d89eeea89a5 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/projects.json @@ -0,0 +1,36 @@ +{ + "type": "array", + "items": { + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "name_with_namespace": { "type": "string" }, + "description": { "type": ["string", "null"] }, + "path": { "type": "string" }, + "path_with_namespace": { "type": "string" }, + "created_at": { "type": "date" }, + "default_branch": { "type": ["string", "null"] }, + "tag_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "ssh_url_to_repo": { "type": "string" }, + "http_url_to_repo": { "type": "string" }, + "web_url": { "type": "string" }, + "avatar_url": { "type": ["string", "null"] }, + "star_count": { "type": "integer" }, + "forks_count": { "type": "integer" }, + "last_activity_at": { "type": "date" } + }, + "required": [ + "id", "name", "name_with_namespace", "description", "path", + "path_with_namespace", "created_at", "default_branch", "tag_list", + "ssh_url_to_repo", "http_url_to_repo", "web_url", "avatar_url", + "star_count", "last_activity_at" + ], + "additionalProperties": false + } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/snippets.json b/spec/fixtures/api/schemas/public_api/v4/snippets.json new file mode 100644 index 00000000000..e37e9704649 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/snippets.json @@ -0,0 +1,33 @@ +{ + "type": "array", + "items": { + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "project_id": { "type": ["integer", "null"] }, + "title": { "type": "string" }, + "file_name": { "type": ["string", "null"] }, + "description": { "type": ["string", "null"] }, + "web_url": { "type": "string" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "author": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + } + }, + "required": [ + "id", "title", "file_name", "description", "web_url", + "created_at", "updated_at", "author" + ], + "additionalProperties": false + } +} diff --git a/spec/fixtures/api/schemas/variable.json b/spec/fixtures/api/schemas/variable.json new file mode 100644 index 00000000000..78977118b0a --- /dev/null +++ b/spec/fixtures/api/schemas/variable.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "required": [ + "id", + "key", + "value", + "protected" + ], + "properties": { + "id": { "type": "integer" }, + "key": { "type": "string" }, + "value": { "type": "string" }, + "protected": { "type": "boolean" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/variables.json b/spec/fixtures/api/schemas/variables.json new file mode 100644 index 00000000000..8002f39a7b8 --- /dev/null +++ b/spec/fixtures/api/schemas/variables.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "required": ["variables"], + "properties": { + "variables": { + "type": "array", + "items": { "$ref": "variable.json" } + } + }, + "additionalProperties": false +} diff --git a/spec/helpers/auto_devops_helper_spec.rb b/spec/helpers/auto_devops_helper_spec.rb index 5e272af6073..1950c2b129b 100644 --- a/spec/helpers/auto_devops_helper_spec.rb +++ b/spec/helpers/auto_devops_helper_spec.rb @@ -82,4 +82,39 @@ describe AutoDevopsHelper do it { is_expected.to eq(false) } end end + + describe '.auto_devops_warning_message' do + subject { helper.auto_devops_warning_message(project) } + + context 'when the service is missing' do + before do + allow(helper).to receive(:missing_auto_devops_service?).and_return(true) + end + + context 'when the domain is missing' do + before do + allow(helper).to receive(:missing_auto_devops_domain?).and_return(true) + end + + it { is_expected.to match(/Auto Review Apps and Auto Deploy need a domain name and a .* to work correctly./) } + end + + context 'when the domain is not missing' do + before do + allow(helper).to receive(:missing_auto_devops_domain?).and_return(false) + end + + it { is_expected.to match(/Auto Review Apps and Auto Deploy need a .* to work correctly./) } + end + end + + context 'when the domain is missing' do + before do + allow(helper).to receive(:missing_auto_devops_service?).and_return(false) + allow(helper).to receive(:missing_auto_devops_domain?).and_return(true) + end + + it { is_expected.to eq('Auto Review Apps and Auto Deploy need a domain name to work correctly.') } + end + end end diff --git a/spec/helpers/graph_helper_spec.rb b/spec/helpers/graph_helper_spec.rb index 400635abdde..1f8a38dc697 100644 --- a/spec/helpers/graph_helper_spec.rb +++ b/spec/helpers/graph_helper_spec.rb @@ -7,10 +7,10 @@ describe GraphHelper do let(:graph) { Network::Graph.new(project, 'master', commit, '') } it 'filters our refs used by GitLab' do - allow(commit).to receive(:ref_names).and_return(['refs/merge-requests/abc', 'master', 'refs/tmp/xyz']) self.instance_variable_set(:@graph, graph) - refs = get_refs(project.repository, commit) - expect(refs).to eq('master') + refs = refs(project.repository, commit) + + expect(refs).to match('master') end end end diff --git a/spec/javascripts/boards/board_list_spec.js b/spec/javascripts/boards/board_list_spec.js index a5fcb10b9dd..03df6c06691 100644 --- a/spec/javascripts/boards/board_list_spec.js +++ b/spec/javascripts/boards/board_list_spec.js @@ -5,7 +5,7 @@ import Vue from 'vue'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import Sortable from 'vendor/Sortable'; -import BoardList from '~/boards/components/board_list'; +import BoardList from '~/boards/components/board_list.vue'; import eventHub from '~/boards/eventhub'; import '~/boards/mixins/sortable_default_options'; import '~/boards/models/issue'; diff --git a/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js b/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js new file mode 100644 index 00000000000..5b9cdceee71 --- /dev/null +++ b/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js @@ -0,0 +1,189 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import AjaxFormVariableList from '~/ci_variable_list/ajax_variable_list'; + +const VARIABLE_PATCH_ENDPOINT = 'http://test.host/frontend-fixtures/builds-project/variables'; + +describe('AjaxFormVariableList', () => { + preloadFixtures('projects/ci_cd_settings.html.raw'); + preloadFixtures('projects/ci_cd_settings_with_variables.html.raw'); + + let container; + let saveButton; + let errorBox; + + let mock; + let ajaxVariableList; + + beforeEach(() => { + loadFixtures('projects/ci_cd_settings.html.raw'); + container = document.querySelector('.js-ci-variable-list-section'); + + mock = new MockAdapter(axios); + + const ajaxVariableListEl = document.querySelector('.js-ci-variable-list-section'); + saveButton = ajaxVariableListEl.querySelector('.js-secret-variables-save-button'); + errorBox = container.querySelector('.js-ci-variable-error-box'); + ajaxVariableList = new AjaxFormVariableList({ + container, + formField: 'variables', + saveButton, + errorBox, + saveEndpoint: container.dataset.saveEndpoint, + }); + + spyOn(ajaxVariableList, 'updateRowsWithPersistedVariables').and.callThrough(); + spyOn(ajaxVariableList.variableList, 'toggleEnableRow').and.callThrough(); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('onSaveClicked', () => { + it('shows loading spinner while waiting for the request', (done) => { + const loadingIcon = saveButton.querySelector('.js-secret-variables-save-loading-icon'); + + mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => { + expect(loadingIcon.classList.contains('hide')).toEqual(false); + + return [200, {}]; + }); + + expect(loadingIcon.classList.contains('hide')).toEqual(true); + + ajaxVariableList.onSaveClicked() + .then(() => { + expect(loadingIcon.classList.contains('hide')).toEqual(true); + }) + .then(done) + .catch(done.fail); + }); + + it('calls `updateRowsWithPersistedVariables` with the persisted variables', (done) => { + const variablesResponse = [{ id: 1, key: 'foo', value: 'bar' }]; + mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200, { + variables: variablesResponse, + }); + + ajaxVariableList.onSaveClicked() + .then(() => { + expect(ajaxVariableList.updateRowsWithPersistedVariables) + .toHaveBeenCalledWith(variablesResponse); + }) + .then(done) + .catch(done.fail); + }); + + it('hides any previous error box', (done) => { + mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200); + + expect(errorBox.classList.contains('hide')).toEqual(true); + + ajaxVariableList.onSaveClicked() + .then(() => { + expect(errorBox.classList.contains('hide')).toEqual(true); + }) + .then(done) + .catch(done.fail); + }); + + it('disables remove buttons while waiting for the request', (done) => { + mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => { + expect(ajaxVariableList.variableList.toggleEnableRow).toHaveBeenCalledWith(false); + + return [200, {}]; + }); + + ajaxVariableList.onSaveClicked() + .then(() => { + expect(ajaxVariableList.variableList.toggleEnableRow).toHaveBeenCalledWith(true); + }) + .then(done) + .catch(done.fail); + }); + + it('shows error box with validation errors', (done) => { + const validationError = 'some validation error'; + mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(400, [ + validationError, + ]); + + expect(errorBox.classList.contains('hide')).toEqual(true); + + ajaxVariableList.onSaveClicked() + .then(() => { + expect(errorBox.classList.contains('hide')).toEqual(false); + expect(errorBox.textContent.trim().replace(/\n+\s+/m, ' ')).toEqual(`Validation failed ${validationError}`); + }) + .then(done) + .catch(done.fail); + }); + + it('shows flash message when request fails', (done) => { + mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(500); + + expect(errorBox.classList.contains('hide')).toEqual(true); + + ajaxVariableList.onSaveClicked() + .then(() => { + expect(errorBox.classList.contains('hide')).toEqual(true); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updateRowsWithPersistedVariables', () => { + beforeEach(() => { + loadFixtures('projects/ci_cd_settings_with_variables.html.raw'); + container = document.querySelector('.js-ci-variable-list-section'); + + const ajaxVariableListEl = document.querySelector('.js-ci-variable-list-section'); + saveButton = ajaxVariableListEl.querySelector('.js-secret-variables-save-button'); + errorBox = container.querySelector('.js-ci-variable-error-box'); + ajaxVariableList = new AjaxFormVariableList({ + container, + formField: 'variables', + saveButton, + errorBox, + saveEndpoint: container.dataset.saveEndpoint, + }); + }); + + it('removes variable that was removed', () => { + expect(container.querySelectorAll('.js-row').length).toBe(3); + + container.querySelector('.js-row-remove-button').click(); + + expect(container.querySelectorAll('.js-row').length).toBe(3); + + ajaxVariableList.updateRowsWithPersistedVariables([]); + + expect(container.querySelectorAll('.js-row').length).toBe(2); + }); + + it('updates new variable row with persisted ID', () => { + const row = container.querySelector('.js-row:last-child'); + const idInput = row.querySelector('.js-ci-variable-input-id'); + const keyInput = row.querySelector('.js-ci-variable-input-key'); + const valueInput = row.querySelector('.js-ci-variable-input-value'); + + keyInput.value = 'foo'; + keyInput.dispatchEvent(new Event('input')); + valueInput.value = 'bar'; + valueInput.dispatchEvent(new Event('input')); + + expect(idInput.value).toEqual(''); + + ajaxVariableList.updateRowsWithPersistedVariables([{ + id: 3, + key: 'foo', + value: 'bar', + }]); + + expect(idInput.value).toEqual('3'); + expect(row.dataset.isPersisted).toEqual('true'); + }); + }); +}); 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 0170ab458d4..6ab7b50e035 100644 --- a/spec/javascripts/ci_variable_list/ci_variable_list_spec.js +++ b/spec/javascripts/ci_variable_list/ci_variable_list_spec.js @@ -4,6 +4,7 @@ import getSetTimeoutPromise from '../helpers/set_timeout_promise_helper'; describe('VariableList', () => { preloadFixtures('pipeline_schedules/edit.html.raw'); preloadFixtures('pipeline_schedules/edit_with_variables.html.raw'); + preloadFixtures('projects/ci_cd_settings.html.raw'); let $wrapper; let variableList; @@ -105,37 +106,8 @@ describe('VariableList', () => { describe('with all inputs(key, value, protected)', () => { beforeEach(() => { - // This markup will be replaced with a fixture when we can render the - // CI/CD settings page with the new dynamic variable list in https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/4110 - $wrapper = $(`<form class="js-variable-list"> - <ul> - <li class="js-row"> - <div class="ci-variable-body-item"> - <input class="js-ci-variable-input-key" name="variables[variables_attributes][][key]"> - </div> - - <div class="ci-variable-body-item"> - <textarea class="js-ci-variable-input-value" name="variables[variables_attributes][][value]"></textarea> - </div> - - <div class="ci-variable-body-item ci-variable-protected-item"> - <button type="button" class="js-project-feature-toggle project-feature-toggle"> - <input - type="hidden" - class="js-ci-variable-input-protected js-project-feature-toggle-input" - name="variables[variables_attributes][][protected]" - value="true" - /> - </button> - </div> - - <button type="button" class="js-row-remove-button"></button> - </li> - </ul> - <button type="button" class="js-secret-value-reveal-button"> - Reveal values - </button> - </form>`); + loadFixtures('projects/ci_cd_settings.html.raw'); + $wrapper = $('.js-ci-variable-list-section'); variableList = new VariableList({ container: $wrapper, @@ -160,4 +132,51 @@ describe('VariableList', () => { .catch(done.fail); }); }); + + describe('toggleEnableRow method', () => { + beforeEach(() => { + loadFixtures('pipeline_schedules/edit_with_variables.html.raw'); + $wrapper = $('.js-ci-variable-list-section'); + + variableList = new VariableList({ + container: $wrapper, + formField: 'variables', + }); + variableList.init(); + }); + + it('should disable all key inputs', () => { + expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3); + + variableList.toggleEnableRow(false); + + expect($wrapper.find('.js-ci-variable-input-key[disabled]').length).toBe(3); + }); + + it('should disable all remove buttons', () => { + expect($wrapper.find('.js-row-remove-button:not([disabled])').length).toBe(3); + + variableList.toggleEnableRow(false); + + expect($wrapper.find('.js-row-remove-button[disabled]').length).toBe(3); + }); + + it('should enable all remove buttons', () => { + variableList.toggleEnableRow(false); + expect($wrapper.find('.js-row-remove-button[disabled]').length).toBe(3); + + variableList.toggleEnableRow(true); + + expect($wrapper.find('.js-row-remove-button:not([disabled])').length).toBe(3); + }); + + it('should enable all key inputs', () => { + variableList.toggleEnableRow(false); + expect($wrapper.find('.js-ci-variable-input-key[disabled]').length).toBe(3); + + variableList.toggleEnableRow(true); + + expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3); + }); + }); }); diff --git a/spec/javascripts/datetime_utility_spec.js b/spec/javascripts/datetime_utility_spec.js index 2e5b65f5610..a8d09202154 100644 --- a/spec/javascripts/datetime_utility_spec.js +++ b/spec/javascripts/datetime_utility_spec.js @@ -105,4 +105,68 @@ describe('dateInWords', () => { it('should return abbreviated month name', () => { expect(datetimeUtility.dateInWords(date, true)).toEqual('Jul 1, 2016'); }); + + it('should return date in words without year', () => { + expect(datetimeUtility.dateInWords(date, true, true)).toEqual('Jul 1'); + }); +}); + +describe('monthInWords', () => { + const date = new Date('2017-01-20'); + + it('returns month name from provided date', () => { + expect(datetimeUtility.monthInWords(date)).toBe('January'); + }); + + it('returns abbreviated month name from provided date', () => { + expect(datetimeUtility.monthInWords(date, true)).toBe('Jan'); + }); +}); + +describe('totalDaysInMonth', () => { + it('returns number of days in a month for given date', () => { + // 1st Feb, 2016 (leap year) + expect(datetimeUtility.totalDaysInMonth(new Date(2016, 1, 1))).toBe(29); + + // 1st Feb, 2017 + expect(datetimeUtility.totalDaysInMonth(new Date(2017, 1, 1))).toBe(28); + + // 1st Jan, 2017 + expect(datetimeUtility.totalDaysInMonth(new Date(2017, 0, 1))).toBe(31); + }); +}); + +describe('getSundays', () => { + it('returns array of dates representing all Sundays of the month', () => { + // December, 2017 (it has 5 Sundays) + const dateOfSundays = [3, 10, 17, 24, 31]; + const sundays = datetimeUtility.getSundays(new Date(2017, 11, 1)); + + expect(sundays.length).toBe(5); + sundays.forEach((sunday, index) => { + expect(sunday.getDate()).toBe(dateOfSundays[index]); + }); + }); +}); + +describe('getTimeframeWindow', () => { + it('returns array of dates representing a timeframe based on provided length and date', () => { + const date = new Date(2018, 0, 1); + const mockTimeframe = [ + new Date(2017, 9, 1), + new Date(2017, 10, 1), + new Date(2017, 11, 1), + new Date(2018, 0, 1), + new Date(2018, 1, 1), + new Date(2018, 2, 31), + ]; + const timeframe = datetimeUtility.getTimeframeWindow(6, date); + + expect(timeframe.length).toBe(6); + timeframe.forEach((timeframeItem, index) => { + expect(timeframeItem.getFullYear() === mockTimeframe[index].getFullYear()).toBeTruthy(); + expect(timeframeItem.getMonth() === mockTimeframe[index].getMonth()).toBeTruthy(); + expect(timeframeItem.getDate() === mockTimeframe[index].getDate()).toBeTruthy(); + }); + }); }); diff --git a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js index 79447787fc9..4a516c517ef 100644 --- a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js +++ b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import eventHub from '~/filtered_search/event_hub'; import RecentSearchesDropdownContent from '~/filtered_search/components/recent_searches_dropdown_content'; -import '~/filtered_search/filtered_search_token_keys'; +import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys'; const createComponent = (propsData) => { const Component = Vue.extend(RecentSearchesDropdownContent); @@ -19,14 +19,14 @@ const trimMarkupWhitespace = text => text.replace(/(\n|\s)+/gm, ' ').trim(); describe('RecentSearchesDropdownContent', () => { const propsDataWithoutItems = { items: [], - allowedKeys: gl.FilteredSearchTokenKeys.getKeys(), + allowedKeys: FilteredSearchTokenKeys.getKeys(), }; const propsDataWithItems = { items: [ 'foo', 'author:@root label:~foo bar', ], - allowedKeys: gl.FilteredSearchTokenKeys.getKeys(), + allowedKeys: FilteredSearchTokenKeys.getKeys(), }; let vm; diff --git a/spec/javascripts/filtered_search/dropdown_user_spec.js b/spec/javascripts/filtered_search/dropdown_user_spec.js index 02415485d19..f1e6119253e 100644 --- a/spec/javascripts/filtered_search/dropdown_user_spec.js +++ b/spec/javascripts/filtered_search/dropdown_user_spec.js @@ -3,6 +3,8 @@ import '~/filtered_search/filtered_search_tokenizer'; import '~/filtered_search/filtered_search_dropdown'; import '~/filtered_search/dropdown_user'; +import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys'; + describe('Dropdown User', () => { describe('getSearchInput', () => { let dropdownUser; @@ -14,7 +16,7 @@ describe('Dropdown User', () => { spyOn(gl.DropdownUtils, 'getSearchInput').and.callFake(() => {}); dropdownUser = new gl.DropdownUser({ - tokenKeys: gl.FilteredSearchTokenKeys, + tokenKeys: FilteredSearchTokenKeys, }); }); diff --git a/spec/javascripts/filtered_search/dropdown_utils_spec.js b/spec/javascripts/filtered_search/dropdown_utils_spec.js index b1b3d43f241..d6e1af105f1 100644 --- a/spec/javascripts/filtered_search/dropdown_utils_spec.js +++ b/spec/javascripts/filtered_search/dropdown_utils_spec.js @@ -1,6 +1,7 @@ import '~/filtered_search/dropdown_utils'; import '~/filtered_search/filtered_search_tokenizer'; import '~/filtered_search/filtered_search_dropdown_manager'; +import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys'; import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper'; describe('Dropdown Utils', () => { @@ -137,7 +138,7 @@ describe('Dropdown Utils', () => { `); input = document.getElementById('test'); - allowedKeys = gl.FilteredSearchTokenKeys.getKeys(); + allowedKeys = FilteredSearchTokenKeys.getKeys(); }); function config() { diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js index b8890e4cda1..0ed9a587dc1 100644 --- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js @@ -3,8 +3,8 @@ import * as recentSearchesStoreSrc from '~/filtered_search/stores/recent_searche import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; import RecentSearchesRoot from '~/filtered_search/recent_searches_root'; +import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys'; import '~/lib/utils/common_utils'; -import '~/filtered_search/filtered_search_token_keys'; import '~/filtered_search/filtered_search_tokenizer'; import '~/filtered_search/filtered_search_dropdown_manager'; import '~/filtered_search/filtered_search_manager'; @@ -14,6 +14,7 @@ describe('Filtered Search Manager', () => { let input; let manager; let tokensContainer; + const page = 'issues'; const placeholder = 'Search or filter results...'; function dispatchBackspaceEvent(element, eventType) { @@ -62,7 +63,7 @@ describe('Filtered Search Manager', () => { input = document.querySelector('.filtered-search'); tokensContainer = document.querySelector('.tokens-container'); - manager = new gl.FilteredSearchManager(); + manager = new gl.FilteredSearchManager({ page }); manager.setup(); }; @@ -80,19 +81,19 @@ describe('Filtered Search Manager', () => { }); it('should instantiate RecentSearchesStore with isLocalStorageAvailable', () => { - manager = new gl.FilteredSearchManager(); + manager = new gl.FilteredSearchManager({ page }); expect(RecentSearchesService.isAvailable).toHaveBeenCalled(); expect(recentSearchesStoreSrc.default).toHaveBeenCalledWith({ isLocalStorageAvailable, - allowedKeys: gl.FilteredSearchTokenKeys.getKeys(), + allowedKeys: FilteredSearchTokenKeys.getKeys(), }); }); }); describe('setup', () => { beforeEach(() => { - manager = new gl.FilteredSearchManager(); + manager = new gl.FilteredSearchManager({ page }); }); it('should not instantiate Flash if an RecentSearchesServiceError is caught', () => { diff --git a/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js b/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js index 69b424c3af5..fbc3926d332 100644 --- a/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js @@ -1,11 +1,11 @@ -import '~/filtered_search/filtered_search_token_keys'; +import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys'; describe('Filtered Search Token Keys', () => { describe('get', () => { let tokenKeys; beforeEach(() => { - tokenKeys = gl.FilteredSearchTokenKeys.get(); + tokenKeys = FilteredSearchTokenKeys.get(); }); it('should return tokenKeys', () => { @@ -21,7 +21,7 @@ describe('Filtered Search Token Keys', () => { let conditions; beforeEach(() => { - conditions = gl.FilteredSearchTokenKeys.getConditions(); + conditions = FilteredSearchTokenKeys.getConditions(); }); it('should return conditions', () => { @@ -35,71 +35,71 @@ describe('Filtered Search Token Keys', () => { describe('searchByKey', () => { it('should return null when key not found', () => { - const tokenKey = gl.FilteredSearchTokenKeys.searchByKey('notakey'); + const tokenKey = FilteredSearchTokenKeys.searchByKey('notakey'); expect(tokenKey === null).toBe(true); }); it('should return tokenKey when found by key', () => { - const tokenKeys = gl.FilteredSearchTokenKeys.get(); - const result = gl.FilteredSearchTokenKeys.searchByKey(tokenKeys[0].key); + const tokenKeys = FilteredSearchTokenKeys.get(); + const result = FilteredSearchTokenKeys.searchByKey(tokenKeys[0].key); expect(result).toEqual(tokenKeys[0]); }); }); describe('searchBySymbol', () => { it('should return null when symbol not found', () => { - const tokenKey = gl.FilteredSearchTokenKeys.searchBySymbol('notasymbol'); + const tokenKey = FilteredSearchTokenKeys.searchBySymbol('notasymbol'); expect(tokenKey === null).toBe(true); }); it('should return tokenKey when found by symbol', () => { - const tokenKeys = gl.FilteredSearchTokenKeys.get(); - const result = gl.FilteredSearchTokenKeys.searchBySymbol(tokenKeys[0].symbol); + const tokenKeys = FilteredSearchTokenKeys.get(); + const result = FilteredSearchTokenKeys.searchBySymbol(tokenKeys[0].symbol); expect(result).toEqual(tokenKeys[0]); }); }); describe('searchByKeyParam', () => { it('should return null when key param not found', () => { - const tokenKey = gl.FilteredSearchTokenKeys.searchByKeyParam('notakeyparam'); + const tokenKey = FilteredSearchTokenKeys.searchByKeyParam('notakeyparam'); expect(tokenKey === null).toBe(true); }); it('should return tokenKey when found by key param', () => { - const tokenKeys = gl.FilteredSearchTokenKeys.get(); - const result = gl.FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`); + const tokenKeys = FilteredSearchTokenKeys.get(); + const result = FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`); expect(result).toEqual(tokenKeys[0]); }); it('should return alternative tokenKey when found by key param', () => { - const tokenKeys = gl.FilteredSearchTokenKeys.getAlternatives(); - const result = gl.FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`); + const tokenKeys = FilteredSearchTokenKeys.getAlternatives(); + const result = FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`); expect(result).toEqual(tokenKeys[0]); }); }); describe('searchByConditionUrl', () => { it('should return null when condition url not found', () => { - const condition = gl.FilteredSearchTokenKeys.searchByConditionUrl(null); + const condition = FilteredSearchTokenKeys.searchByConditionUrl(null); expect(condition === null).toBe(true); }); it('should return condition when found by url', () => { - const conditions = gl.FilteredSearchTokenKeys.getConditions(); - const result = gl.FilteredSearchTokenKeys.searchByConditionUrl(conditions[0].url); + const conditions = FilteredSearchTokenKeys.getConditions(); + const result = FilteredSearchTokenKeys.searchByConditionUrl(conditions[0].url); expect(result).toBe(conditions[0]); }); }); describe('searchByConditionKeyValue', () => { it('should return null when condition tokenKey and value not found', () => { - const condition = gl.FilteredSearchTokenKeys.searchByConditionKeyValue(null, null); + const condition = FilteredSearchTokenKeys.searchByConditionKeyValue(null, null); expect(condition === null).toBe(true); }); it('should return condition when found by tokenKey and value', () => { - const conditions = gl.FilteredSearchTokenKeys.getConditions(); - const result = gl.FilteredSearchTokenKeys + const conditions = FilteredSearchTokenKeys.getConditions(); + const result = FilteredSearchTokenKeys .searchByConditionKeyValue(conditions[0].tokenKey, conditions[0].value); expect(result).toEqual(conditions[0]); }); diff --git a/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js b/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js index 585bea9b499..bf8b66f1110 100644 --- a/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js @@ -1,8 +1,8 @@ -import '~/filtered_search/filtered_search_token_keys'; +import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys'; import '~/filtered_search/filtered_search_tokenizer'; describe('Filtered Search Tokenizer', () => { - const allowedKeys = gl.FilteredSearchTokenKeys.getKeys(); + const allowedKeys = FilteredSearchTokenKeys.getKeys(); describe('processTokens', () => { it('returns for input containing only search value', () => { diff --git a/spec/javascripts/fixtures/groups.rb b/spec/javascripts/fixtures/groups.rb new file mode 100644 index 00000000000..35be52fbf97 --- /dev/null +++ b/spec/javascripts/fixtures/groups.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +describe 'Groups (JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:group) { create(:group, name: 'frontend-fixtures-group' )} + + render_views + + before(:all) do + clean_frontend_fixtures('groups/') + end + + before do + group.add_master(admin) + sign_in(admin) + end + + describe Groups::Settings::CiCdController, '(JavaScript fixtures)', type: :controller do + it 'groups/ci_cd_settings.html.raw' do |example| + get :show, + group_id: group + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end + end +end diff --git a/spec/javascripts/fixtures/projects.rb b/spec/javascripts/fixtures/projects.rb index 2a100e7fab5..b344b389241 100644 --- a/spec/javascripts/fixtures/projects.rb +++ b/spec/javascripts/fixtures/projects.rb @@ -1,11 +1,14 @@ require 'spec_helper' -describe ProjectsController, '(JavaScript fixtures)', type: :controller do +describe 'Projects (JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, namespace: namespace, path: 'builds-project') } + let(:project_variable_populated) { create(:project, namespace: namespace, path: 'builds-project2') } + let!(:variable1) { create(:ci_variable, project: project_variable_populated) } + let!(:variable2) { create(:ci_variable, project: project_variable_populated) } render_views @@ -14,6 +17,9 @@ describe ProjectsController, '(JavaScript fixtures)', type: :controller do end before do + # EE-specific start + # EE specific end + project.add_master(admin) sign_in(admin) end @@ -21,12 +27,43 @@ describe ProjectsController, '(JavaScript fixtures)', type: :controller do remove_repository(project) end - it 'projects/dashboard.html.raw' do |example| - get :show, - namespace_id: project.namespace.to_param, - id: project + describe ProjectsController, '(JavaScript fixtures)', type: :controller do + it 'projects/dashboard.html.raw' do |example| + get :show, + namespace_id: project.namespace.to_param, + id: project - expect(response).to be_success - store_frontend_fixture(response, example.description) + expect(response).to be_success + store_frontend_fixture(response, example.description) + end + + it 'projects/edit.html.raw' do |example| + get :edit, + namespace_id: project.namespace.to_param, + id: project + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end + end + + describe Projects::Settings::CiCdController, '(JavaScript fixtures)', type: :controller do + it 'projects/ci_cd_settings.html.raw' do |example| + get :show, + namespace_id: project.namespace.to_param, + project_id: project + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end + + it 'projects/ci_cd_settings_with_variables.html.raw' do |example| + get :show, + namespace_id: project_variable_populated.namespace.to_param, + project_id: project_variable_populated + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end end end diff --git a/spec/javascripts/groups/components/app_spec.js b/spec/javascripts/groups/components/app_spec.js index 8338efe915b..3adc29262f3 100644 --- a/spec/javascripts/groups/components/app_spec.js +++ b/spec/javascripts/groups/components/app_spec.js @@ -268,10 +268,10 @@ describe('AppComponent', () => { it('updates props which show modal confirmation dialog', () => { const group = Object.assign({}, mockParentGroupItem); - expect(vm.showModal).toBeFalsy(); + expect(vm.updateModal).toBeFalsy(); expect(vm.groupLeaveConfirmationMessage).toBe(''); vm.showLeaveGroupModal(group, mockParentGroupItem); - expect(vm.showModal).toBeTruthy(); + expect(vm.updateModal).toBeTruthy(); expect(vm.groupLeaveConfirmationMessage).toBe(`Are you sure you want to leave the "${group.fullName}" group?`); }); }); @@ -280,9 +280,9 @@ describe('AppComponent', () => { it('hides modal confirmation which is shown before leaving the group', () => { const group = Object.assign({}, mockParentGroupItem); vm.showLeaveGroupModal(group, mockParentGroupItem); - expect(vm.showModal).toBeTruthy(); + expect(vm.updateModal).toBeTruthy(); vm.hideLeaveGroupModal(); - expect(vm.showModal).toBeFalsy(); + expect(vm.updateModal).toBeFalsy(); }); }); @@ -307,7 +307,7 @@ describe('AppComponent', () => { spyOn($, 'scrollTo'); vm.leaveGroup(); - expect(vm.showModal).toBeFalsy(); + expect(vm.updateModal).toBeFalsy(); expect(vm.targetGroup.isBeingRemoved).toBeTruthy(); expect(vm.service.leaveGroup).toHaveBeenCalledWith(vm.targetGroup.leavePath); setTimeout(() => { @@ -475,7 +475,7 @@ describe('AppComponent', () => { it('renders modal confirmation dialog', () => { vm.groupLeaveConfirmationMessage = 'Are you sure you want to leave the "foo" group?'; - vm.showModal = true; + vm.updateModal = true; const modalDialogEl = vm.$el.querySelector('.modal'); expect(modalDialogEl).not.toBe(null); expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?'); diff --git a/spec/javascripts/job_spec.js b/spec/javascripts/job_spec.js index 03b58e9c1d0..b4599688c6d 100644 --- a/spec/javascripts/job_spec.js +++ b/spec/javascripts/job_spec.js @@ -10,6 +10,7 @@ describe('Job', () => { const JOB_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/-/jobs/1`; let mock; let response; + let job; function waitForPromise() { return new Promise(resolve => requestAnimationFrame(resolve)); @@ -22,6 +23,8 @@ describe('Job', () => { spyOn(urlUtils, 'visitUrl'); + response = {}; + mock = new MockAdapter(axios); mock.onGet(new RegExp(`${JOB_URL}/trace.json?(.*)`)).reply(() => [200, response]); @@ -30,7 +33,7 @@ describe('Job', () => { afterEach(() => { mock.restore(); - response = {}; + clearTimeout(job.timeout); }); describe('class constructor', () => { @@ -43,15 +46,19 @@ describe('Job', () => { }); describe('setup', () => { - beforeEach(function () { - this.job = new Job(); + beforeEach(function (done) { + job = new Job(); + + waitForPromise() + .then(done) + .catch(done.fail); }); it('copies build options', function () { - expect(this.job.pagePath).toBe(JOB_URL); - expect(this.job.buildStatus).toBe('success'); - expect(this.job.buildStage).toBe('test'); - expect(this.job.state).toBe(''); + expect(job.pagePath).toBe(JOB_URL); + expect(job.buildStatus).toBe('success'); + expect(job.buildStage).toBe('test'); + expect(job.state).toBe(''); }); it('only shows the jobs matching the current stage', () => { @@ -84,12 +91,12 @@ describe('Job', () => { complete: false, }; - this.job = new Job(); + job = new Job(); waitForPromise() .then(() => { expect($('#build-trace .js-build-output').text()).toMatch(/Update/); - expect(this.job.state).toBe('newstate'); + expect(job.state).toBe('newstate'); response = { html: '<span>More</span>', @@ -103,7 +110,7 @@ describe('Job', () => { .then(waitForPromise) .then(() => { expect($('#build-trace .js-build-output').text()).toMatch(/UpdateMore/); - expect(this.job.state).toBe('finalstate'); + expect(job.state).toBe('finalstate'); }) .then(done) .catch(done.fail); @@ -117,7 +124,7 @@ describe('Job', () => { complete: false, }; - this.job = new Job(); + job = new Job(); waitForPromise() .then(() => { @@ -151,7 +158,7 @@ describe('Job', () => { total: 100, }; - this.job = new Job(); + job = new Job(); waitForPromise() .then(() => { @@ -172,7 +179,7 @@ describe('Job', () => { total: 100, }; - this.job = new Job(); + job = new Job(); waitForPromise() .then(() => { @@ -191,9 +198,10 @@ describe('Job', () => { append: false, size: 50, total: 100, + complete: false, }; - this.job = new Job(); + job = new Job(); waitForPromise() .then(() => { @@ -207,6 +215,7 @@ describe('Job', () => { append: true, size: 10, total: 100, + complete: true, }; }) .then(() => jasmine.clock().tick(4001)) @@ -229,7 +238,7 @@ describe('Job', () => { total: 100, }; - this.job = new Job(); + job = new Job(); expect( document.querySelector('.js-raw-link').textContent.trim(), @@ -247,7 +256,7 @@ describe('Job', () => { total: 100, }; - this.job = new Job(); + job = new Job(); waitForPromise() .then(() => { @@ -269,7 +278,7 @@ describe('Job', () => { total: 100, }; - this.job = new Job(); + job = new Job(); waitForPromise() .then(done) @@ -296,7 +305,7 @@ describe('Job', () => { it('should request build trace with state parameter', (done) => { spyOn(axios, 'get').and.callThrough(); // eslint-disable-next-line no-new - new Job(); + job = new Job(); setTimeout(() => { expect(axios.get).toHaveBeenCalledWith( diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js index 80430011aed..49799c31995 100644 --- a/spec/javascripts/lib/utils/common_utils_spec.js +++ b/spec/javascripts/lib/utils/common_utils_spec.js @@ -480,4 +480,33 @@ describe('common_utils', () => { expect(commonUtils.spriteIcon('test', 'fa fa-test')).toEqual('<svg class="fa fa-test"><use xlink:href="icons.svg#test" /></svg>'); }); }); + + describe('convertObjectPropsToCamelCase', () => { + it('returns new object with camelCase property names by converting object with snake_case names', () => { + const snakeRegEx = /(_\w)/g; + const mockObj = { + id: 1, + group_name: 'GitLab.org', + absolute_web_url: 'https://gitlab.com/gitlab-org/', + }; + const mappings = { + id: 'id', + groupName: 'group_name', + absoluteWebUrl: 'absolute_web_url', + }; + + const convertedObj = commonUtils.convertObjectPropsToCamelCase(mockObj); + + Object.keys(convertedObj).forEach((prop) => { + expect(snakeRegEx.test(prop)).toBeFalsy(); + expect(convertedObj[prop]).toBe(mockObj[mappings[prop]]); + }); + }); + + it('return empty object if method is called with null or undefined', () => { + expect(Object.keys(commonUtils.convertObjectPropsToCamelCase(null)).length).toBe(0); + expect(Object.keys(commonUtils.convertObjectPropsToCamelCase()).length).toBe(0); + expect(Object.keys(commonUtils.convertObjectPropsToCamelCase({})).length).toBe(0); + }); + }); }); diff --git a/spec/javascripts/lib/utils/text_utility_spec.js b/spec/javascripts/lib/utils/text_utility_spec.js index 69a23d7b2f3..e57a55fa71a 100644 --- a/spec/javascripts/lib/utils/text_utility_spec.js +++ b/spec/javascripts/lib/utils/text_utility_spec.js @@ -72,4 +72,10 @@ describe('text_utility', () => { expect(textUtils.stripHtml('This is a text with <p>html</p>.', ' ')).toEqual('This is a text with html .'); }); }); + + describe('convertToCamelCase', () => { + it('converts snake_case string to camelCase string', () => { + expect(textUtils.convertToCamelCase('snake_case')).toBe('snakeCase'); + }); + }); }); diff --git a/spec/javascripts/monitoring/dashboard_state_spec.js b/spec/javascripts/monitoring/dashboard_state_spec.js index 3319eeb3f31..df3198dd3e2 100644 --- a/spec/javascripts/monitoring/dashboard_state_spec.js +++ b/spec/javascripts/monitoring/dashboard_state_spec.js @@ -29,34 +29,6 @@ describe('EmptyState', () => { expect(component.currentState).toBe(component.states.gettingStarted); }); - it('buttonPath returns settings path for the state "gettingStarted"', () => { - const component = createComponent({ - selectedState: 'gettingStarted', - settingsPath: statePaths.settingsPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', - }); - - expect(component.buttonPath).toEqual(statePaths.settingsPath); - expect(component.buttonPath).not.toEqual(statePaths.documentationPath); - }); - - it('buttonPath returns documentation path for any of the other states', () => { - const component = createComponent({ - selectedState: 'loading', - settingsPath: statePaths.settingsPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', - }); - - expect(component.buttonPath).toEqual(statePaths.documentationPath); - expect(component.buttonPath).not.toEqual(statePaths.settingsPath); - }); - it('showButtonDescription returns a description with a link for the unableToConnect state', () => { const component = createComponent({ selectedState: 'unableToConnect', @@ -88,6 +60,7 @@ describe('EmptyState', () => { const component = createComponent({ selectedState: 'gettingStarted', settingsPath: statePaths.settingsPath, + clustersPath: statePaths.clustersPath, documentationPath: statePaths.documentationPath, emptyGettingStartedSvgPath: 'foo', emptyLoadingSvgPath: 'foo', diff --git a/spec/javascripts/monitoring/mock_data.js b/spec/javascripts/monitoring/mock_data.js index 2bbe963e393..f30208b27b6 100644 --- a/spec/javascripts/monitoring/mock_data.js +++ b/spec/javascripts/monitoring/mock_data.js @@ -2471,6 +2471,7 @@ export const deploymentData = [ export const statePaths = { settingsPath: '/root/hello-prometheus/services/prometheus/edit', + clustersPath: '/root/hello-prometheus/clusters', documentationPath: '/help/administration/monitoring/prometheus/index.md', }; diff --git a/spec/javascripts/notes/components/note_app_spec.js b/spec/javascripts/notes/components/note_app_spec.js index 36c56cd3862..12d180137a0 100644 --- a/spec/javascripts/notes/components/note_app_spec.js +++ b/spec/javascripts/notes/components/note_app_spec.js @@ -2,14 +2,29 @@ import _ from 'underscore'; import Vue from 'vue'; import notesApp from '~/notes/components/notes_app.vue'; import service from '~/notes/services/notes_service'; +import '~/render_gfm'; import * as mockData from '../mock_data'; -import getSetTimeoutPromise from '../../helpers/set_timeout_promise_helper'; + +const vueMatchers = { + toIncludeElement() { + return { + compare(vm, selector) { + const result = { + pass: vm.$el.querySelector(selector) !== null, + }; + return result; + }, + }; + }, +}; describe('note_app', () => { let mountComponent; let vm; beforeEach(() => { + jasmine.addMatchers(vueMatchers); + const IssueNotesApp = Vue.extend(notesApp); mountComponent = (data) => { @@ -105,7 +120,7 @@ describe('note_app', () => { }); it('should render loading icon', () => { - expect(vm.$el.querySelector('.js-loading')).toBeDefined(); + expect(vm).toIncludeElement('.js-loading'); }); it('should render form', () => { @@ -118,10 +133,14 @@ describe('note_app', () => { describe('update note', () => { describe('individual note', () => { - beforeEach(() => { + beforeEach((done) => { Vue.http.interceptors.push(mockData.individualNoteInterceptor); spyOn(service, 'updateNote').and.callThrough(); vm = mountComponent(); + setTimeout(() => { + vm.$el.querySelector('.js-note-edit').click(); + Vue.nextTick(done); + }, 0); }); afterEach(() => { @@ -131,40 +150,32 @@ describe('note_app', () => { ); }); - it('renders edit form', (done) => { - setTimeout(() => { - vm.$el.querySelector('.js-note-edit').click(); - Vue.nextTick(() => { - expect(vm.$el.querySelector('.js-vue-issue-note-form')).toBeDefined(); - done(); - }); - }, 0); + it('renders edit form', () => { + expect(vm).toIncludeElement('.js-vue-issue-note-form'); }); it('calls the service to update the note', (done) => { - getSetTimeoutPromise() - .then(() => { - vm.$el.querySelector('.js-note-edit').click(); - }) - .then(Vue.nextTick) - .then(() => { - vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note'; - vm.$el.querySelector('.js-vue-issue-save').click(); - - expect(service.updateNote).toHaveBeenCalled(); - }) - // Wait for the requests to finish before destroying - .then(Vue.nextTick) + vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note'; + vm.$el.querySelector('.js-vue-issue-save').click(); + + expect(service.updateNote).toHaveBeenCalled(); + // Wait for the requests to finish before destroying + Vue.nextTick() .then(done) .catch(done.fail); }); }); - describe('dicussion note', () => { - beforeEach(() => { + describe('discussion note', () => { + beforeEach((done) => { Vue.http.interceptors.push(mockData.discussionNoteInterceptor); spyOn(service, 'updateNote').and.callThrough(); vm = mountComponent(); + + setTimeout(() => { + vm.$el.querySelector('.js-note-edit').click(); + Vue.nextTick(done); + }, 0); }); afterEach(() => { @@ -174,30 +185,17 @@ describe('note_app', () => { ); }); - it('renders edit form', (done) => { - setTimeout(() => { - vm.$el.querySelector('.js-note-edit').click(); - Vue.nextTick(() => { - expect(vm.$el.querySelector('.js-vue-issue-note-form')).toBeDefined(); - done(); - }); - }, 0); + it('renders edit form', () => { + expect(vm).toIncludeElement('.js-vue-issue-note-form'); }); it('updates the note and resets the edit form', (done) => { - getSetTimeoutPromise() - .then(() => { - vm.$el.querySelector('.js-note-edit').click(); - }) - .then(Vue.nextTick) - .then(() => { - vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note'; - vm.$el.querySelector('.js-vue-issue-save').click(); - - expect(service.updateNote).toHaveBeenCalled(); - }) - // Wait for the requests to finish before destroying - .then(Vue.nextTick) + vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note'; + vm.$el.querySelector('.js-vue-issue-save').click(); + + expect(service.updateNote).toHaveBeenCalled(); + // Wait for the requests to finish before destroying + Vue.nextTick() .then(done) .catch(done.fail); }); diff --git a/spec/javascripts/notes/components/noteable_note_spec.js b/spec/javascripts/notes/components/noteable_note_spec.js index cb63b64724d..88a7ffb0b9c 100644 --- a/spec/javascripts/notes/components/noteable_note_spec.js +++ b/spec/javascripts/notes/components/noteable_note_spec.js @@ -56,4 +56,25 @@ describe('issue_note', () => { done(); }, 0); }); + + describe('cancel edit', () => { + it('restores content of updated note', (done) => { + const noteBody = 'updated note text'; + vm.updateNote = () => Promise.resolve(); + + vm.formUpdateHandler(noteBody, null, $.noop); + + setTimeout(() => { + expect(vm.note.note_html).toEqual(noteBody); + + vm.formCancelHandler(); + + setTimeout(() => { + expect(vm.note.note_html).toEqual(noteBody); + + done(); + }); + }); + }); + }); }); diff --git a/spec/javascripts/pipelines/async_button_spec.js b/spec/javascripts/pipelines/async_button_spec.js index d010d897642..8ce33d410a7 100644 --- a/spec/javascripts/pipelines/async_button_spec.js +++ b/spec/javascripts/pipelines/async_button_spec.js @@ -15,6 +15,7 @@ describe('Pipelines Async Button', () => { title: 'Foo', icon: 'repeat', cssClass: 'bar', + id: 123, }, }).$mount(); }); @@ -38,9 +39,8 @@ describe('Pipelines Async Button', () => { describe('With confirm dialog', () => { it('should call the service when confimation is positive', () => { - spyOn(window, 'confirm').and.returnValue(true); - eventHub.$on('postAction', (endpoint) => { - expect(endpoint).toEqual('/foo'); + eventHub.$on('actionConfirmationModal', (data) => { + expect(data.id).toEqual(123); }); component = new AsyncButtonComponent({ @@ -49,7 +49,7 @@ describe('Pipelines Async Button', () => { title: 'Foo', icon: 'fa fa-foo', cssClass: 'bar', - confirmActionMessage: 'bar', + id: 123, }, }).$mount(); diff --git a/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb b/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb index c8df6dd2118..007e93c1db6 100644 --- a/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb +++ b/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb @@ -15,10 +15,6 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :m .to receive(:commits_count=).and_return(nil) end - after do - [Project, MergeRequest, MergeRequestDiff].each(&:reset_column_information) - end - def diffs_to_hashes(diffs) diffs.as_json(only: Gitlab::Git::Diff::SERIALIZE_KEYS).map(&:with_indifferent_access) end diff --git a/spec/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_spec.rb b/spec/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_spec.rb index 4cdb679c97f..e99257e3481 100644 --- a/spec/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_spec.rb +++ b/spec/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_spec.rb @@ -7,10 +7,6 @@ describe Gitlab::BackgroundMigration::PopulateMergeRequestMetricsWithEventsData, .to receive(:commits_count=).and_return(nil) end - after do - [MergeRequest, MergeRequestDiff].each(&:reset_column_information) - end - describe '#perform' do let(:mr_with_event) { create(:merge_request) } let!(:merged_event) { create(:event, :merged, target: mr_with_event) } diff --git a/spec/lib/gitlab/checks/change_access_spec.rb b/spec/lib/gitlab/checks/change_access_spec.rb index c2bca816aae..475b5c5cfb2 100644 --- a/spec/lib/gitlab/checks/change_access_spec.rb +++ b/spec/lib/gitlab/checks/change_access_spec.rb @@ -177,5 +177,44 @@ describe Gitlab::Checks::ChangeAccess do expect { subject.exec }.not_to raise_error end end + + context 'LFS file lock check' do + let(:owner) { create(:user) } + let!(:lock) { create(:lfs_file_lock, user: owner, project: project, path: 'README') } + + before do + allow(project.repository).to receive(:new_commits).and_return( + project.repository.commits_between('be93687618e4b132087f430a4d8fc3a609c9b77c', '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51') + ) + end + + context 'with LFS not enabled' do + it 'skips the validation' do + expect_any_instance_of(described_class).not_to receive(:lfs_file_locks_validation) + + subject.exec + end + end + + context 'with LFS enabled' do + before do + allow(project).to receive(:lfs_enabled?).and_return(true) + end + + context 'when change is sent by a different user' do + it 'raises an error if the user is not allowed to update the file' do + expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, "The path 'README' is locked in Git LFS by #{lock.user.name}") + end + end + + context 'when change is sent by the author od the lock' do + let(:user) { owner } + + it "doesn't raise any error" do + expect { subject.exec }.not_to raise_error + end + end + end + end end end diff --git a/spec/lib/gitlab/checks/project_created_spec.rb b/spec/lib/gitlab/checks/project_created_spec.rb new file mode 100644 index 00000000000..ac02007e111 --- /dev/null +++ b/spec/lib/gitlab/checks/project_created_spec.rb @@ -0,0 +1,46 @@ +require 'rails_helper' + +describe Gitlab::Checks::ProjectCreated, :clean_gitlab_redis_shared_state do + let(:user) { create(:user) } + let(:project) { create(:project) } + + describe '.fetch_message' do + context 'with a project created message queue' do + let(:project_created) { described_class.new(project, user, 'http') } + + before do + project_created.add_message + end + + it 'returns project created message' do + expect(described_class.fetch_message(user.id, project.id)).to eq(project_created.message) + end + + it 'deletes the project created message from redis' do + expect(Gitlab::Redis::SharedState.with { |redis| redis.get("project_created:#{user.id}:#{project.id}") }).not_to be_nil + described_class.fetch_message(user.id, project.id) + expect(Gitlab::Redis::SharedState.with { |redis| redis.get("project_created:#{user.id}:#{project.id}") }).to be_nil + end + end + + context 'with no project created message queue' do + it 'returns nil' do + expect(described_class.fetch_message(1, 2)).to be_nil + end + end + end + + describe '#add_message' do + it 'queues a project created message' do + project_created = described_class.new(project, user, 'http') + + expect(project_created.add_message).to eq('OK') + end + + it 'handles anonymous push' do + project_created = described_class.new(nil, user, 'http') + + expect(project_created.add_message).to be_nil + end + end +end diff --git a/spec/lib/gitlab/checks/project_moved_spec.rb b/spec/lib/gitlab/checks/project_moved_spec.rb index f90c2d6aded..e263d29656c 100644 --- a/spec/lib/gitlab/checks/project_moved_spec.rb +++ b/spec/lib/gitlab/checks/project_moved_spec.rb @@ -4,82 +4,82 @@ describe Gitlab::Checks::ProjectMoved, :clean_gitlab_redis_shared_state do let(:user) { create(:user) } let(:project) { create(:project) } - describe '.fetch_redirct_message' do + describe '.fetch_message' do context 'with a redirect message queue' do - it 'should return the redirect message' do - project_moved = described_class.new(project, user, 'foo/bar', 'http') - project_moved.add_redirect_message + it 'returns the redirect message' do + project_moved = described_class.new(project, user, 'http', 'foo/bar') + project_moved.add_message - expect(described_class.fetch_redirect_message(user.id, project.id)).to eq(project_moved.redirect_message) + expect(described_class.fetch_message(user.id, project.id)).to eq(project_moved.message) end - it 'should delete the redirect message from redis' do - project_moved = described_class.new(project, user, 'foo/bar', 'http') - project_moved.add_redirect_message + it 'deletes the redirect message from redis' do + project_moved = described_class.new(project, user, 'http', 'foo/bar') + project_moved.add_message expect(Gitlab::Redis::SharedState.with { |redis| redis.get("redirect_namespace:#{user.id}:#{project.id}") }).not_to be_nil - described_class.fetch_redirect_message(user.id, project.id) + described_class.fetch_message(user.id, project.id) expect(Gitlab::Redis::SharedState.with { |redis| redis.get("redirect_namespace:#{user.id}:#{project.id}") }).to be_nil end end context 'with no redirect message queue' do - it 'should return nil' do - expect(described_class.fetch_redirect_message(1, 2)).to be_nil + it 'returns nil' do + expect(described_class.fetch_message(1, 2)).to be_nil end end end - describe '#add_redirect_message' do - it 'should queue a redirect message' do - project_moved = described_class.new(project, user, 'foo/bar', 'http') - expect(project_moved.add_redirect_message).to eq("OK") + describe '#add_message' do + it 'queues a redirect message' do + project_moved = described_class.new(project, user, 'http', 'foo/bar') + expect(project_moved.add_message).to eq("OK") end - it 'should handle anonymous clones' do - project_moved = described_class.new(project, nil, 'foo/bar', 'http') + it 'handles anonymous clones' do + project_moved = described_class.new(project, nil, 'http', 'foo/bar') - expect(project_moved.add_redirect_message).to eq(nil) + expect(project_moved.add_message).to eq(nil) end end - describe '#redirect_message' do + describe '#message' do context 'when the push is rejected' do - it 'should return a redirect message telling the user to try again' do - project_moved = described_class.new(project, user, 'foo/bar', 'http') + it 'returns a redirect message telling the user to try again' do + project_moved = described_class.new(project, user, 'http', 'foo/bar') message = "Project 'foo/bar' was moved to '#{project.full_path}'." + "\n\nPlease update your Git remote:" + "\n\n git remote set-url origin #{project.http_url_to_repo} and try again.\n" - expect(project_moved.redirect_message(rejected: true)).to eq(message) + expect(project_moved.message(rejected: true)).to eq(message) end end context 'when the push is not rejected' do - it 'should return a redirect message' do - project_moved = described_class.new(project, user, 'foo/bar', 'http') + it 'returns a redirect message' do + project_moved = described_class.new(project, user, 'http', 'foo/bar') message = "Project 'foo/bar' was moved to '#{project.full_path}'." + "\n\nPlease update your Git remote:" + "\n\n git remote set-url origin #{project.http_url_to_repo}\n" - expect(project_moved.redirect_message).to eq(message) + expect(project_moved.message).to eq(message) end end end describe '#permanent_redirect?' do context 'with a permanent RedirectRoute' do - it 'should return true' do + it 'returns true' do project.route.create_redirect('foo/bar', permanent: true) - project_moved = described_class.new(project, user, 'foo/bar', 'http') + project_moved = described_class.new(project, user, 'http', 'foo/bar') expect(project_moved.permanent_redirect?).to be_truthy end end context 'without a permanent RedirectRoute' do - it 'should return false' do + it 'returns false' do project.route.create_redirect('foo/bar') - project_moved = described_class.new(project, user, 'foo/bar', 'http') + project_moved = described_class.new(project, user, 'http', 'foo/bar') expect(project_moved.permanent_redirect?).to be_falsy end end diff --git a/spec/lib/gitlab/ci/config/loader_spec.rb b/spec/lib/gitlab/ci/config/loader_spec.rb index 2d44b1f60f1..590fc8904c1 100644 --- a/spec/lib/gitlab/ci/config/loader_spec.rb +++ b/spec/lib/gitlab/ci/config/loader_spec.rb @@ -38,6 +38,16 @@ describe Gitlab::Ci::Config::Loader do end end + context 'when there is an unknown alias' do + let(:yml) { 'steps: *bad_alias' } + + describe '#initialize' do + it 'raises FormatError' do + expect { loader }.to raise_error(Gitlab::Ci::Config::Loader::FormatError, 'Unknown alias: bad_alias') + end + end + end + context 'when yaml config is empty' do let(:yml) { '' } diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index 98880fe9f28..f83f932e61e 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -1394,11 +1394,15 @@ EOT describe "Error handling" do it "fails to parse YAML" do - expect {Gitlab::Ci::YamlProcessor.new("invalid: yaml: test")}.to raise_error(Psych::SyntaxError) + expect do + Gitlab::Ci::YamlProcessor.new("invalid: yaml: test") + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError) end it "indicates that object is invalid" do - expect {Gitlab::Ci::YamlProcessor.new("invalid_yaml")}.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError) + expect do + Gitlab::Ci::YamlProcessor.new("invalid_yaml") + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError) end it "returns errors if tags parameter is invalid" do @@ -1688,37 +1692,36 @@ EOT end describe "#validation_message" do + subject { Gitlab::Ci::YamlProcessor.validation_message(content) } + context "when the YAML could not be parsed" do - it "returns an error about invalid configutaion" do - content = YAML.dump("invalid: yaml: test") + let(:content) { YAML.dump("invalid: yaml: test") } - expect(Gitlab::Ci::YamlProcessor.validation_message(content)) - .to eq "Invalid configuration format" - end + it { is_expected.to eq "Invalid configuration format" } end context "when the tags parameter is invalid" do - it "returns an error about invalid tags" do - content = YAML.dump({ rspec: { script: "test", tags: "mysql" } }) + let(:content) { YAML.dump({ rspec: { script: "test", tags: "mysql" } }) } - expect(Gitlab::Ci::YamlProcessor.validation_message(content)) - .to eq "jobs:rspec tags should be an array of strings" - end + it { is_expected.to eq "jobs:rspec tags should be an array of strings" } end context "when YAML content is empty" do - it "returns an error about missing content" do - expect(Gitlab::Ci::YamlProcessor.validation_message('')) - .to eq "Please provide content of .gitlab-ci.yml" - end + let(:content) { '' } + + it { is_expected.to eq "Please provide content of .gitlab-ci.yml" } + end + + context 'when the YAML contains an unknown alias' do + let(:content) { 'steps: *bad_alias' } + + it { is_expected.to eq "Unknown alias: bad_alias" } end context "when the YAML is valid" do - it "does not return any errors" do - content = File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) + let(:content) { File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) } - expect(Gitlab::Ci::YamlProcessor.validation_message(content)).to be_nil - end + it { is_expected.to be_nil } end end diff --git a/spec/lib/gitlab/closing_issue_extractor_spec.rb b/spec/lib/gitlab/closing_issue_extractor_spec.rb index 8c79ef54c6c..28c679af12a 100644 --- a/spec/lib/gitlab/closing_issue_extractor_spec.rb +++ b/spec/lib/gitlab/closing_issue_extractor_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Gitlab::ClosingIssueExtractor do let(:project) { create(:project) } let(:project2) { create(:project) } - let(:forked_project) { Projects::ForkService.new(project, project.creator).execute } + let(:forked_project) { Projects::ForkService.new(project, project2.creator).execute } let(:issue) { create(:issue, project: project) } let(:issue2) { create(:issue, project: project2) } let(:reference) { issue.to_reference } @@ -14,6 +14,7 @@ describe Gitlab::ClosingIssueExtractor do before do project.add_developer(project.creator) + project.add_developer(project2.creator) project2.add_master(project.creator) end diff --git a/spec/lib/gitlab/git/blob_spec.rb b/spec/lib/gitlab/git/blob_spec.rb index 8ac960133c5..59e9e1cc94c 100644 --- a/spec/lib/gitlab/git/blob_spec.rb +++ b/spec/lib/gitlab/git/blob_spec.rb @@ -178,67 +178,77 @@ describe Gitlab::Git::Blob, seed_helper: true do end describe '.batch' do - let(:blob_references) do - [ - [SeedRepo::Commit::ID, "files/ruby/popen.rb"], - [SeedRepo::Commit::ID, 'six'] - ] - end + shared_examples 'loading blobs in batch' do + let(:blob_references) do + [ + [SeedRepo::Commit::ID, "files/ruby/popen.rb"], + [SeedRepo::Commit::ID, 'six'] + ] + end - subject { described_class.batch(repository, blob_references) } + subject { described_class.batch(repository, blob_references) } - it { expect(subject.size).to eq(blob_references.size) } + it { expect(subject.size).to eq(blob_references.size) } - context 'first blob' do - let(:blob) { subject[0] } + context 'first blob' do + let(:blob) { subject[0] } - it { expect(blob.id).to eq(SeedRepo::RubyBlob::ID) } - it { expect(blob.name).to eq(SeedRepo::RubyBlob::NAME) } - it { expect(blob.path).to eq("files/ruby/popen.rb") } - it { expect(blob.commit_id).to eq(SeedRepo::Commit::ID) } - it { expect(blob.data[0..10]).to eq(SeedRepo::RubyBlob::CONTENT[0..10]) } - it { expect(blob.size).to eq(669) } - it { expect(blob.mode).to eq("100644") } - end + it { expect(blob.id).to eq(SeedRepo::RubyBlob::ID) } + it { expect(blob.name).to eq(SeedRepo::RubyBlob::NAME) } + it { expect(blob.path).to eq("files/ruby/popen.rb") } + it { expect(blob.commit_id).to eq(SeedRepo::Commit::ID) } + it { expect(blob.data[0..10]).to eq(SeedRepo::RubyBlob::CONTENT[0..10]) } + it { expect(blob.size).to eq(669) } + it { expect(blob.mode).to eq("100644") } + end - context 'second blob' do - let(:blob) { subject[1] } + context 'second blob' do + let(:blob) { subject[1] } - it { expect(blob.id).to eq('409f37c4f05865e4fb208c771485f211a22c4c2d') } - it { expect(blob.data).to eq('') } - it 'does not mark the blob as binary' do - expect(blob).not_to be_binary + it { expect(blob.id).to eq('409f37c4f05865e4fb208c771485f211a22c4c2d') } + it { expect(blob.data).to eq('') } + it 'does not mark the blob as binary' do + expect(blob).not_to be_binary + end end - end - context 'limiting' do - subject { described_class.batch(repository, blob_references, blob_size_limit: blob_size_limit) } + context 'limiting' do + subject { described_class.batch(repository, blob_references, blob_size_limit: blob_size_limit) } - context 'positive' do - let(:blob_size_limit) { 10 } + context 'positive' do + let(:blob_size_limit) { 10 } - it { expect(subject.first.data.size).to eq(10) } - end + it { expect(subject.first.data.size).to eq(10) } + end - context 'zero' do - let(:blob_size_limit) { 0 } + context 'zero' do + let(:blob_size_limit) { 0 } - it 'only loads the metadata' do - expect(subject.first.size).not_to be(0) - expect(subject.first.data).to eq('') + it 'only loads the metadata' do + expect(subject.first.size).not_to be(0) + expect(subject.first.data).to eq('') + end end - end - context 'negative' do - let(:blob_size_limit) { -1 } + context 'negative' do + let(:blob_size_limit) { -1 } - it 'ignores MAX_DATA_DISPLAY_SIZE' do - stub_const('Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE', 100) + it 'ignores MAX_DATA_DISPLAY_SIZE' do + stub_const('Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE', 100) - expect(subject.first.data.size).to eq(669) + expect(subject.first.data.size).to eq(669) + end end end end + + context 'when Gitaly list_blobs_by_sha_path feature is enabled' do + it_behaves_like 'loading blobs in batch' + end + + context 'when Gitaly list_blobs_by_sha_path feature is disabled', :disable_gitaly do + it_behaves_like 'loading blobs in batch' + end end describe '.batch_lfs_pointers' do diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index ec1c7a96f92..edcf8889c27 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -600,12 +600,16 @@ describe Gitlab::Git::Repository, seed_helper: true do end describe "#refs_hash" do - let(:refs) { repository.refs_hash } + subject { repository.refs_hash } it "should have as many entries as branches and tags" do expected_refs = SeedRepo::Repo::BRANCHES + SeedRepo::Repo::TAGS # We flatten in case a commit is pointed at by more than one branch and/or tag - expect(refs.values.flatten.size).to eq(expected_refs.size) + expect(subject.values.flatten.size).to eq(expected_refs.size) + end + + it 'has valid commit ids as keys' do + expect(subject.keys).to all( match(Commit::COMMIT_SHA_PATTERN) ) end end diff --git a/spec/lib/gitlab/git/wiki_spec.rb b/spec/lib/gitlab/git/wiki_spec.rb index bd8dbf07fa7..761f7732036 100644 --- a/spec/lib/gitlab/git/wiki_spec.rb +++ b/spec/lib/gitlab/git/wiki_spec.rb @@ -3,34 +3,38 @@ require 'spec_helper' describe Gitlab::Git::Wiki do let(:project) { create(:project) } let(:user) { project.owner } - let(:wiki) { ProjectWiki.new(project, user) } - let(:gollum_wiki) { wiki.wiki } + let(:project_wiki) { ProjectWiki.new(project, user) } + subject { project_wiki.wiki } # Remove skip_gitaly_mock flag when gitaly_find_page when - # https://gitlab.com/gitlab-org/gitaly/merge_requests/539 gets merged + # https://gitlab.com/gitlab-org/gitlab-ce/issues/42039 is solved describe '#page', :skip_gitaly_mock do - it 'returns the right page' do + before do create_page('page1', 'content') - create_page('foo/page1', 'content') - - expect(gollum_wiki.page(title: 'page1', dir: '').url_path).to eq 'page1' - expect(gollum_wiki.page(title: 'page1', dir: 'foo').url_path).to eq 'foo/page1' + create_page('foo/page1', 'content foo/page1') + end + after do destroy_page('page1') destroy_page('page1', 'foo') end + + it 'returns the right page' do + expect(subject.page(title: 'page1', dir: '').url_path).to eq 'page1' + expect(subject.page(title: 'page1', dir: 'foo').url_path).to eq 'foo/page1' + end end def create_page(name, content) - gollum_wiki.write_page(name, :markdown, content, commit_details) + subject.write_page(name, :markdown, content, commit_details(name)) end - def commit_details - Gitlab::Git::Wiki::CommitDetails.new(user.name, user.email, "test commit") + def commit_details(name) + Gitlab::Git::Wiki::CommitDetails.new(user.name, user.email, "created page #{name}") end def destroy_page(title, dir = '') - page = gollum_wiki.page(title: title, dir: dir) - wiki.delete_page(page, "test commit") + page = subject.page(title: title, dir: dir) + project_wiki.delete_page(page, "test commit") end end diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index 2009a8ac48c..3c3697e7aa9 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -5,11 +5,19 @@ describe Gitlab::GitAccess do let(:actor) { user } let(:project) { create(:project, :repository) } + let(:project_path) { project.path } + let(:namespace_path) { project&.namespace&.path } let(:protocol) { 'ssh' } let(:authentication_abilities) { %i[read_project download_code push_code] } let(:redirected_path) { nil } - let(:access) { described_class.new(actor, project, protocol, authentication_abilities: authentication_abilities, redirected_path: redirected_path) } + let(:access) do + described_class.new(actor, project, + protocol, authentication_abilities: authentication_abilities, + namespace_path: namespace_path, project_path: project_path, + redirected_path: redirected_path) + end + let(:push_access_check) { access.check('git-receive-pack', '_any') } let(:pull_access_check) { access.check('git-upload-pack', '_any') } @@ -111,7 +119,7 @@ describe Gitlab::GitAccess do end it 'does not block pushes with "not found"' do - expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:upload]) + expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:auth_upload]) end end end @@ -145,6 +153,7 @@ describe Gitlab::GitAccess do context 'when the project is nil' do let(:project) { nil } + let(:project_path) { "new-project" } it 'blocks push and pull with "not found"' do aggregate_failures do @@ -152,6 +161,42 @@ describe Gitlab::GitAccess do expect { push_access_check }.to raise_not_found end end + + context 'when user is allowed to create project in namespace' do + let(:namespace_path) { user.namespace.path } + let(:access) do + described_class.new(actor, nil, + protocol, authentication_abilities: authentication_abilities, + project_path: project_path, namespace_path: namespace_path, + redirected_path: redirected_path) + end + + it 'blocks pull access with "not found"' do + expect { pull_access_check }.to raise_not_found + end + + it 'allows push access' do + expect { push_access_check }.not_to raise_error + end + end + + context 'when user is not allowed to create project in namespace' do + let(:user2) { create(:user) } + let(:namespace_path) { user2.namespace.path } + let(:access) do + described_class.new(actor, nil, + protocol, authentication_abilities: authentication_abilities, + project_path: project_path, namespace_path: namespace_path, + redirected_path: redirected_path) + end + + it 'blocks push and pull with "not found"' do + aggregate_failures do + expect { pull_access_check }.to raise_not_found + expect { push_access_check }.to raise_not_found + end + end + end end end @@ -197,7 +242,7 @@ describe Gitlab::GitAccess do it 'enqueues a redirected message' do push_access_check - expect(Gitlab::Checks::ProjectMoved.fetch_redirect_message(user.id, project.id)).not_to be_nil + expect(Gitlab::Checks::ProjectMoved.fetch_message(user.id, project.id)).not_to be_nil end end @@ -273,6 +318,52 @@ describe Gitlab::GitAccess do end end + describe '#check_authentication_abilities!' do + before do + project.add_master(user) + end + + context 'when download' do + let(:authentication_abilities) { [] } + + it 'raises unauthorized with download error' do + expect { pull_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:auth_download]) + end + + context 'when authentication abilities include download code' do + let(:authentication_abilities) { [:download_code] } + + it 'does not raise any errors' do + expect { pull_access_check }.not_to raise_error + end + end + + context 'when authentication abilities include build download code' do + let(:authentication_abilities) { [:build_download_code] } + + it 'does not raise any errors' do + expect { pull_access_check }.not_to raise_error + end + end + end + + context 'when upload' do + let(:authentication_abilities) { [] } + + it 'raises unauthorized with push error' do + expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:auth_upload]) + end + + context 'when authentication abilities include push code' do + let(:authentication_abilities) { [:push_code] } + + it 'does not raise any errors' do + expect { push_access_check }.not_to raise_error + end + end + end + end + describe '#check_command_disabled!' do before do project.add_master(user) @@ -311,6 +402,117 @@ describe Gitlab::GitAccess do end end + describe '#check_db_accessibility!' do + context 'when in a read-only GitLab instance' do + before do + create(:protected_branch, name: 'feature', project: project) + allow(Gitlab::Database).to receive(:read_only?) { true } + end + + it { expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:cannot_push_to_read_only]) } + end + end + + describe '#ensure_project_on_push!' do + let(:access) do + described_class.new(actor, project, + protocol, authentication_abilities: authentication_abilities, + project_path: project_path, namespace_path: namespace_path, + redirected_path: redirected_path) + end + + context 'when push' do + let(:cmd) { 'git-receive-pack' } + + context 'when project does not exist' do + let(:project_path) { "nonexistent" } + let(:project) { nil } + + context 'when changes is _any' do + let(:changes) { '_any' } + + context 'when authentication abilities include push code' do + let(:authentication_abilities) { [:push_code] } + + context 'when user can create project in namespace' do + let(:namespace_path) { user.namespace.path } + + it 'creates a new project' do + expect { access.send(:ensure_project_on_push!, cmd, changes) }.to change { Project.count }.by(1) + end + end + + context 'when user cannot create project in namespace' do + let(:user2) { create(:user) } + let(:namespace_path) { user2.namespace.path } + + it 'does not create a new project' do + expect { access.send(:ensure_project_on_push!, cmd, changes) }.not_to change { Project.count } + end + end + end + + context 'when authentication abilities do not include push code' do + let(:authentication_abilities) { [] } + + context 'when user can create project in namespace' do + let(:namespace_path) { user.namespace.path } + + it 'does not create a new project' do + expect { access.send(:ensure_project_on_push!, cmd, changes) }.not_to change { Project.count } + end + end + end + end + + context 'when check contains actual changes' do + let(:changes) { "#{Gitlab::Git::BLANK_SHA} 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/new_branch" } + + it 'does not create a new project' do + expect { access.send(:ensure_project_on_push!, cmd, changes) }.not_to change { Project.count } + end + end + end + + context 'when project exists' do + let(:changes) { '_any' } + let!(:project) { create(:project) } + + it 'does not create a new project' do + expect { access.send(:ensure_project_on_push!, cmd, changes) }.not_to change { Project.count } + end + end + + context 'when deploy key is used' do + let(:key) { create(:deploy_key, user: user) } + let(:actor) { key } + let(:project_path) { "nonexistent" } + let(:project) { nil } + let(:namespace_path) { user.namespace.path } + let(:changes) { '_any' } + + it 'does not create a new project' do + expect { access.send(:ensure_project_on_push!, cmd, changes) }.not_to change { Project.count } + end + end + end + + context 'when pull' do + let(:cmd) { 'git-upload-pack' } + let(:changes) { '_any' } + + context 'when project does not exist' do + let(:project_path) { "new-project" } + let(:namespace_path) { user.namespace.path } + let(:project) { nil } + + it 'does not create a new project' do + expect { access.send(:ensure_project_on_push!, cmd, changes) }.not_to change { Project.count } + end + end + end + end + describe '#check_download_access!' do it 'allows masters to pull' do project.add_master(user) @@ -338,7 +540,9 @@ describe Gitlab::GitAccess do context 'when project is public' do let(:public_project) { create(:project, :public, :repository) } - let(:access) { described_class.new(nil, public_project, 'web', authentication_abilities: []) } + let(:project_path) { public_project.path } + let(:namespace_path) { public_project.namespace.path } + let(:access) { described_class.new(nil, public_project, 'web', authentication_abilities: [:download_code], project_path: project_path, namespace_path: namespace_path) } context 'when repository is enabled' do it 'give access to download code' do @@ -638,19 +842,6 @@ describe Gitlab::GitAccess do admin: { push_protected_branch: false, push_all: false, merge_into_protected_branch: false })) end end - - context "when in a read-only GitLab instance" do - before do - create(:protected_branch, name: 'feature', project: project) - allow(Gitlab::Database).to receive(:read_only?) { true } - end - - # Only check admin; if an admin can't do it, other roles can't either - matrix = permissions_matrix[:admin].dup - matrix.each { |key, _| matrix[key] = false } - - run_permission_checks(admin: matrix) - end end describe 'build authentication abilities' do @@ -661,26 +852,26 @@ describe Gitlab::GitAccess do project.add_reporter(user) end - it { expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:upload]) } + it { expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:auth_upload]) } end context 'when unauthorized' do context 'to public project' do let(:project) { create(:project, :public, :repository) } - it { expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:upload]) } + it { expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:auth_upload]) } end context 'to internal project' do let(:project) { create(:project, :internal, :repository) } - it { expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:upload]) } + it { expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:auth_upload]) } end context 'to private project' do let(:project) { create(:project, :private, :repository) } - it { expect { push_access_check }.to raise_not_found } + it { expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:auth_upload]) } end end end @@ -767,8 +958,7 @@ describe Gitlab::GitAccess do end def raise_not_found - raise_error(Gitlab::GitAccess::NotFoundError, - Gitlab::GitAccess::ERROR_MESSAGES[:project_not_found]) + raise_error(Gitlab::GitAccess::NotFoundError, Gitlab::GitAccess::ERROR_MESSAGES[:project_not_found]) end def build_authentication_abilities diff --git a/spec/lib/gitlab/gitaly_client/blobs_stitcher_spec.rb b/spec/lib/gitlab/gitaly_client/blobs_stitcher_spec.rb new file mode 100644 index 00000000000..9db710e759e --- /dev/null +++ b/spec/lib/gitlab/gitaly_client/blobs_stitcher_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe Gitlab::GitalyClient::BlobsStitcher do + describe 'enumeration' do + it 'combines segregated blob messages together' do + messages = [ + OpenStruct.new(oid: 'abcdef1', path: 'path/to/file', size: 1642, revision: 'f00ba7', mode: 0100644, data: "first-line\n"), + OpenStruct.new(oid: '', data: 'second-line'), + OpenStruct.new(oid: '', data: '', revision: 'f00ba7', path: 'path/to/non-existent/file'), + OpenStruct.new(oid: 'abcdef2', path: 'path/to/another-file', size: 2461, revision: 'f00ba8', mode: 0100644, data: "GIF87a\x90\x01".b) + ] + + blobs = described_class.new(messages).to_a + + expect(blobs.size).to be(2) + + expect(blobs[0].id).to eq('abcdef1') + expect(blobs[0].mode).to eq('100644') + expect(blobs[0].name).to eq('file') + expect(blobs[0].path).to eq('path/to/file') + expect(blobs[0].size).to eq(1642) + expect(blobs[0].commit_id).to eq('f00ba7') + expect(blobs[0].data).to eq("first-line\nsecond-line") + expect(blobs[0].binary?).to be false + + expect(blobs[1].id).to eq('abcdef2') + expect(blobs[1].mode).to eq('100644') + expect(blobs[1].name).to eq('another-file') + expect(blobs[1].path).to eq('path/to/another-file') + expect(blobs[1].size).to eq(2461) + expect(blobs[1].commit_id).to eq('f00ba8') + expect(blobs[1].data).to eq("GIF87a\x90\x01".b) + expect(blobs[1].binary?).to be true + 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 3722a91c050..001c4d3e10a 100644 --- a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb @@ -166,6 +166,32 @@ describe Gitlab::GitalyClient::CommitService do described_class.new(repository).find_commit(revision) end + + describe 'caching', :request_store do + let(:commit_dbl) { double(id: 'f01b' * 10) } + + context 'when passed revision is a branch name' do + it 'calls Gitaly' do + expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:find_commit).twice.and_return(double(commit: commit_dbl)) + + commit = nil + 2.times { commit = described_class.new(repository).find_commit('master') } + + expect(commit).to eq(commit_dbl) + end + end + + context 'when passed revision is a commit ID' do + it 'returns a cached commit' do + expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:find_commit).once.and_return(double(commit: commit_dbl)) + + commit = nil + 2.times { commit = described_class.new(repository).find_commit('f01b' * 10) } + + expect(commit).to eq(commit_dbl) + end + end + end end describe '#patch' do diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 0ecb50f7110..41a55027f4d 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -276,6 +276,7 @@ project: - fork_network_member - fork_network - custom_attributes +- lfs_file_locks award_emoji: - awardable - user @@ -290,3 +291,5 @@ push_event_payload: issue_assignees: - issue - assignee +lfs_file_locks: +- user diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 5a33fa3fd53..feaab6673cd 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -530,3 +530,9 @@ ProjectCustomAttribute: - project_id - key - value +LfsFileLock: +- id +- path +- user_id +- project_id +- created_at diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb index 85991c38363..a40330d853f 100644 --- a/spec/lib/gitlab/path_regex_spec.rb +++ b/spec/lib/gitlab/path_regex_spec.rb @@ -194,8 +194,8 @@ describe Gitlab::PathRegex do end end - describe '.root_namespace_path_regex' do - subject { described_class.root_namespace_path_regex } + describe '.root_namespace_route_regex' do + subject { %r{\A#{described_class.root_namespace_route_regex}/\z} } it 'rejects top level routes' do expect(subject).not_to match('admin/') @@ -318,8 +318,8 @@ describe Gitlab::PathRegex do end end - describe '.project_path_regex' do - subject { described_class.project_path_regex } + describe '.project_route_regex' do + subject { %r{\A#{described_class.project_route_regex}/\z} } it 'accepts top level routes' do expect(subject).to match('admin/') diff --git a/spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb b/spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb index c7169717fc1..0697cb2def6 100644 --- a/spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb +++ b/spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb @@ -7,7 +7,7 @@ describe Gitlab::Prometheus::Queries::AdditionalMetricsDeploymentQuery do include_examples 'additional metrics query' do let(:deployment) { create(:deployment, environment: environment) } - let(:query_params) { [deployment.id] } + let(:query_params) { [environment.id, deployment.id] } it 'queries using specific time' do expect(client).to receive(:query_range).with(anything, diff --git a/spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb b/spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb index ffe3ad85baa..84dc31d9732 100644 --- a/spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb +++ b/spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb @@ -31,7 +31,7 @@ describe Gitlab::Prometheus::Queries::DeploymentQuery do expect(client).to receive(:query).with('avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="environment-slug"}[30m])) * 100', time: stop_time) - expect(subject.query(deployment.id)).to eq(memory_values: nil, memory_before: nil, memory_after: nil, - cpu_values: nil, cpu_before: nil, cpu_after: nil) + expect(subject.query(environment.id, deployment.id)).to eq(memory_values: nil, memory_before: nil, memory_after: nil, + cpu_values: nil, cpu_before: nil, cpu_after: nil) end end diff --git a/spec/lib/gitlab/prometheus_client_spec.rb b/spec/lib/gitlab/prometheus_client_spec.rb index de625324092..5d86007f71f 100644 --- a/spec/lib/gitlab/prometheus_client_spec.rb +++ b/spec/lib/gitlab/prometheus_client_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Gitlab::PrometheusClient do include PrometheusHelpers - subject { described_class.new(api_url: 'https://prometheus.example.com') } + subject { described_class.new(RestClient::Resource.new('https://prometheus.example.com')) } describe '#ping' do it 'issues a "query" request to the API endpoint' do @@ -47,16 +47,28 @@ describe Gitlab::PrometheusClient do expect(req_stub).to have_been_requested end end + + context 'when request returns non json data' do + it 'raises a Gitlab::PrometheusError error' do + req_stub = stub_prometheus_request(query_url, status: 200, body: 'not json') + + expect { execute_query } + .to raise_error(Gitlab::PrometheusError, 'Parsing response failed') + expect(req_stub).to have_been_requested + end + end end describe 'failure to reach a provided prometheus url' do let(:prometheus_url) {"https://prometheus.invalid.example.com"} + subject { described_class.new(RestClient::Resource.new(prometheus_url)) } + context 'exceptions are raised' do it 'raises a Gitlab::PrometheusError error when a SocketError is rescued' do req_stub = stub_prometheus_request_with_exception(prometheus_url, SocketError) - expect { subject.send(:get, prometheus_url) } + expect { subject.send(:get, '/', {}) } .to raise_error(Gitlab::PrometheusError, "Can't connect to #{prometheus_url}") expect(req_stub).to have_been_requested end @@ -64,15 +76,15 @@ describe Gitlab::PrometheusClient do it 'raises a Gitlab::PrometheusError error when a SSLError is rescued' do req_stub = stub_prometheus_request_with_exception(prometheus_url, OpenSSL::SSL::SSLError) - expect { subject.send(:get, prometheus_url) } + expect { subject.send(:get, '/', {}) } .to raise_error(Gitlab::PrometheusError, "#{prometheus_url} contains invalid SSL data") expect(req_stub).to have_been_requested end - it 'raises a Gitlab::PrometheusError error when a HTTParty::Error is rescued' do - req_stub = stub_prometheus_request_with_exception(prometheus_url, HTTParty::Error) + it 'raises a Gitlab::PrometheusError error when a RestClient::Exception is rescued' do + req_stub = stub_prometheus_request_with_exception(prometheus_url, RestClient::Exception) - expect { subject.send(:get, prometheus_url) } + expect { subject.send(:get, '/', {}) } .to raise_error(Gitlab::PrometheusError, "Network connection error") expect(req_stub).to have_been_requested end diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index 17b48b3d062..9dbab95f70e 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -20,9 +20,13 @@ describe Gitlab::SearchResults do end describe '#objects' do - it 'returns without_page collection by default' do + it 'returns without_counts collection by default' do expect(results.objects('projects')).to be_kind_of(Kaminari::PaginatableWithoutCount) end + + it 'returns with counts collection when requested' do + expect(results.objects('projects', 1, false)).not_to be_kind_of(Kaminari::PaginatableWithoutCount) + end end describe '#projects_count' do diff --git a/spec/migrations/convert_custom_notification_settings_to_columns_spec.rb b/spec/migrations/convert_custom_notification_settings_to_columns_spec.rb index 759e77ac9db..d1bf6bdf9d6 100644 --- a/spec/migrations/convert_custom_notification_settings_to_columns_spec.rb +++ b/spec/migrations/convert_custom_notification_settings_to_columns_spec.rb @@ -21,7 +21,7 @@ describe ConvertCustomNotificationSettingsToColumns, :migration do events[event] = true end - user = build(:user).becomes(user_class).tap(&:save!) + user = user_class.create!(email: "user-#{SecureRandom.hex}@example.org", username: "user-#{SecureRandom.hex}", encrypted_password: '12345678') create_params = { user_id: user.id, level: params[:level], events: events } notification_setting = described_class::NotificationSetting.create(create_params) @@ -37,7 +37,7 @@ describe ConvertCustomNotificationSettingsToColumns, :migration do events[event] = true end - user = build(:user).becomes(user_class).tap(&:save!) + user = user_class.create!(email: "user-#{SecureRandom.hex}@example.org", username: "user-#{SecureRandom.hex}", encrypted_password: '12345678') create_params = events.merge(user_id: user.id, level: params[:level]) notification_setting = described_class::NotificationSetting.create(create_params) diff --git a/spec/migrations/remove_redundant_pipeline_stages_spec.rb b/spec/migrations/remove_redundant_pipeline_stages_spec.rb new file mode 100644 index 00000000000..8325f986594 --- /dev/null +++ b/spec/migrations/remove_redundant_pipeline_stages_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20180119121225_remove_redundant_pipeline_stages.rb') + +describe RemoveRedundantPipelineStages, :migration do + let(:projects) { table(:projects) } + let(:pipelines) { table(:ci_pipelines) } + let(:stages) { table(:ci_stages) } + let(:builds) { table(:ci_builds) } + + before do + projects.create!(id: 123, name: 'gitlab', path: 'gitlab-ce') + pipelines.create!(id: 234, project_id: 123, ref: 'master', sha: 'adf43c3a') + + stages.create!(id: 6, project_id: 123, pipeline_id: 234, name: 'build') + stages.create!(id: 10, project_id: 123, pipeline_id: 234, name: 'build') + stages.create!(id: 21, project_id: 123, pipeline_id: 234, name: 'build') + stages.create!(id: 41, project_id: 123, pipeline_id: 234, name: 'test') + stages.create!(id: 62, project_id: 123, pipeline_id: 234, name: 'test') + stages.create!(id: 102, project_id: 123, pipeline_id: 234, name: 'deploy') + + builds.create!(id: 1, commit_id: 234, project_id: 123, stage_id: 10) + builds.create!(id: 2, commit_id: 234, project_id: 123, stage_id: 21) + builds.create!(id: 3, commit_id: 234, project_id: 123, stage_id: 21) + builds.create!(id: 4, commit_id: 234, project_id: 123, stage_id: 41) + builds.create!(id: 5, commit_id: 234, project_id: 123, stage_id: 62) + builds.create!(id: 6, commit_id: 234, project_id: 123, stage_id: 102) + end + + it 'removes ambiguous stages and preserves builds' do + expect(stages.all.count).to eq 6 + expect(builds.all.count).to eq 6 + + migrate! + + expect(stages.all.count).to eq 1 + expect(builds.all.count).to eq 6 + expect(builds.all.pluck(:stage_id).compact).to eq [102] + end + + it 'retries when incorrectly added index exception is caught' do + allow_any_instance_of(described_class) + .to receive(:remove_redundant_pipeline_stages!) + + expect_any_instance_of(described_class) + .to receive(:remove_outdated_index!) + .exactly(100).times.and_call_original + + expect { migrate! } + .to raise_error StandardError, /Failed to add an unique index/ + end + + it 'does not retry when unknown exception is being raised' do + allow(subject).to receive(:remove_outdated_index!) + expect(subject).to receive(:remove_redundant_pipeline_stages!).once + allow(subject).to receive(:add_unique_index!).and_raise(StandardError) + + expect { subject.up(attempts: 3) }.to raise_error StandardError + end +end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index ef480e7a80a..ae2d34750a7 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -114,6 +114,40 @@ describe ApplicationSetting do it { expect(setting.repository_storages).to eq(['default']) } end + context 'auto_devops_domain setting' do + context 'when auto_devops_enabled? is true' do + before do + setting.update(auto_devops_enabled: true) + end + + it 'can be blank' do + setting.update(auto_devops_domain: '') + + expect(setting).to be_valid + end + + context 'with a valid value' do + before do + setting.update(auto_devops_domain: 'domain.com') + end + + it 'is valid' do + expect(setting).to be_valid + end + end + + context 'with an invalid value' do + before do + setting.update(auto_devops_domain: 'definitelynotahostname') + end + + it 'is invalid' do + expect(setting).to be_invalid + end + end + end + end + context 'circuitbreaker settings' do [:circuitbreaker_failure_count_threshold, :circuitbreaker_check_interval, diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index b2b64e6ff48..ab170e6351c 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -95,28 +95,68 @@ describe Ci::Runner do subject { runner.online? } - context 'never contacted' do + before do + allow_any_instance_of(described_class).to receive(:cached_attribute).and_call_original + allow_any_instance_of(described_class).to receive(:cached_attribute) + .with(:platform).and_return("darwin") + end + + context 'no cache value' do before do - runner.contacted_at = nil + stub_redis_runner_contacted_at(nil) end - it { is_expected.to be_falsey } - end + context 'never contacted' do + before do + runner.contacted_at = nil + end - context 'contacted long time ago time' do - before do - runner.contacted_at = 1.year.ago + it { is_expected.to be_falsey } + end + + context 'contacted long time ago time' do + before do + runner.contacted_at = 1.year.ago + end + + it { is_expected.to be_falsey } end - it { is_expected.to be_falsey } + context 'contacted 1s ago' do + before do + runner.contacted_at = 1.second.ago + end + + it { is_expected.to be_truthy } + end end - context 'contacted 1s ago' do - before do - runner.contacted_at = 1.second.ago + context 'with cache value' do + context 'contacted long time ago time' do + before do + runner.contacted_at = 1.year.ago + stub_redis_runner_contacted_at(1.year.ago.to_s) + end + + it { is_expected.to be_falsey } + end + + context 'contacted 1s ago' do + before do + runner.contacted_at = 50.minutes.ago + stub_redis_runner_contacted_at(1.second.ago.to_s) + end + + it { is_expected.to be_truthy } end + end - it { is_expected.to be_truthy } + def stub_redis_runner_contacted_at(value) + Gitlab::Redis::SharedState.with do |redis| + cache_key = runner.send(:cache_attribute_key) + expect(redis).to receive(:get).with(cache_key) + .and_return({ contacted_at: value }.to_json).at_least(:once) + end end end @@ -361,6 +401,50 @@ describe Ci::Runner do end end + describe '#update_cached_info' do + let(:runner) { create(:ci_runner) } + + subject { runner.update_cached_info(architecture: '18-bit') } + + context 'when database was updated recently' do + before do + runner.contacted_at = Time.now + end + + it 'updates cache' do + expect_redis_update + + subject + end + end + + context 'when database was not updated recently' do + before do + runner.contacted_at = 2.hours.ago + end + + it 'updates database' do + expect_redis_update + + expect { subject }.to change { runner.reload.read_attribute(:contacted_at) } + .and change { runner.reload.read_attribute(:architecture) } + end + + it 'updates cache' do + expect_redis_update + + subject + end + end + + def expect_redis_update + Gitlab::Redis::SharedState.with do |redis| + redis_key = runner.send(:cache_attribute_key) + expect(redis).to receive(:set).with(redis_key, anything, any_args) + end + end + end + describe '#destroy' do let(:runner) { create(:ci_runner) } diff --git a/spec/models/clusters/applications/prometheus_spec.rb b/spec/models/clusters/applications/prometheus_spec.rb index 696099f7cf7..01037919530 100644 --- a/spec/models/clusters/applications/prometheus_spec.rb +++ b/spec/models/clusters/applications/prometheus_spec.rb @@ -6,6 +6,24 @@ describe Clusters::Applications::Prometheus do include_examples 'cluster application specs', described_class + describe 'transition to installed' do + let(:project) { create(:project) } + let(:cluster) { create(:cluster, projects: [project]) } + let(:prometheus_service) { double('prometheus_service') } + + subject { create(:clusters_applications_prometheus, :installing, cluster: cluster) } + + before do + allow(project).to receive(:find_or_initialize_service).with('prometheus').and_return prometheus_service + end + + it 'ensures Prometheus service is activated' do + expect(prometheus_service).to receive(:update).with(active: true) + + subject.make_installed + end + end + describe "#chart_values_file" do subject { create(:clusters_applications_prometheus).chart_values_file } @@ -13,4 +31,58 @@ describe Clusters::Applications::Prometheus do expect(subject).to eq("#{Rails.root}/vendor/prometheus/values.yaml") end end + + describe '#proxy_client' do + context 'cluster is nil' do + it 'returns nil' do + expect(subject.cluster).to be_nil + expect(subject.proxy_client).to be_nil + end + end + + context "cluster doesn't have kubeclient" do + let(:cluster) { create(:cluster) } + subject { create(:clusters_applications_prometheus, cluster: cluster) } + + it 'returns nil' do + expect(subject.proxy_client).to be_nil + end + end + + context 'cluster has kubeclient' do + let(:kubernetes_url) { 'http://example.com' } + let(:k8s_discover_response) do + { + resources: [ + { + name: 'service', + kind: 'Service' + } + ] + } + end + + let(:kube_client) { Kubeclient::Client.new(kubernetes_url) } + + let(:cluster) { create(:cluster) } + subject { create(:clusters_applications_prometheus, cluster: cluster) } + + before do + allow(kube_client.rest_client).to receive(:get).and_return(k8s_discover_response.to_json) + allow(subject.cluster).to receive(:kubeclient).and_return(kube_client) + end + + it 'creates proxy prometheus rest client' do + expect(subject.proxy_client).to be_instance_of(RestClient::Resource) + end + + it 'creates proper url' do + expect(subject.proxy_client.url).to eq('http://example.com/api/v1/proxy/namespaces/gitlab-managed-apps/service/prometheus-prometheus-server:80') + end + + it 'copies options and headers from kube client to proxy client' do + expect(subject.proxy_client.options).to eq(kube_client.rest_client.options.merge(headers: kube_client.headers)) + end + end + end end diff --git a/spec/models/concerns/redis_cacheable_spec.rb b/spec/models/concerns/redis_cacheable_spec.rb new file mode 100644 index 00000000000..3d7963120b6 --- /dev/null +++ b/spec/models/concerns/redis_cacheable_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +describe RedisCacheable do + let(:model) { double } + + before do + model.extend(described_class) + allow(model).to receive(:cache_attribute_key).and_return('key') + end + + describe '#cached_attribute' do + let(:payload) { { attribute: 'value' } } + + subject { model.cached_attribute(payload.keys.first) } + + it 'gets the cache attribute' do + Gitlab::Redis::SharedState.with do |redis| + expect(redis).to receive(:get).with('key') + .and_return(payload.to_json) + end + + expect(subject).to eq(payload.values.first) + end + end + + describe '#cache_attributes' do + let(:values) { { name: 'new_name' } } + + subject { model.cache_attributes(values) } + + it 'sets the cache attributes' do + Gitlab::Redis::SharedState.with do |redis| + expect(redis).to receive(:set).with('key', values.to_json, anything) + end + + subject + end + end +end diff --git a/spec/models/concerns/routable_spec.rb b/spec/models/concerns/routable_spec.rb index 3106207811a..8cb50d7465c 100644 --- a/spec/models/concerns/routable_spec.rb +++ b/spec/models/concerns/routable_spec.rb @@ -39,7 +39,7 @@ describe Group, 'Routable' do create(:group, parent: group, path: 'xyz') duplicate = build(:project, namespace: group, path: 'xyz') - expect { duplicate.save! }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Route path has already been taken, Route is invalid') + expect { duplicate.save! }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Path has already been taken') end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 5ea4acb6687..338fb314ee9 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -41,7 +41,6 @@ describe Group do describe 'validations' do it { is_expected.to validate_presence_of :name } - it { is_expected.to validate_uniqueness_of(:name).scoped_to(:parent_id) } it { is_expected.to validate_presence_of :path } it { is_expected.not_to validate_presence_of :owner } it { is_expected.to validate_presence_of :two_factor_grace_period } diff --git a/spec/models/lfs_file_lock_spec.rb b/spec/models/lfs_file_lock_spec.rb new file mode 100644 index 00000000000..ce87b01b49c --- /dev/null +++ b/spec/models/lfs_file_lock_spec.rb @@ -0,0 +1,57 @@ +require 'rails_helper' + +describe LfsFileLock do + set(:lfs_file_lock) { create(:lfs_file_lock) } + subject { lfs_file_lock } + + it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:user) } + + it { is_expected.to validate_presence_of(:project_id) } + it { is_expected.to validate_presence_of(:user_id) } + it { is_expected.to validate_presence_of(:path) } + + describe '#can_be_unlocked_by?' do + let(:developer) { create(:user) } + let(:master) { create(:user) } + + before do + project = lfs_file_lock.project + + project.add_developer(developer) + project.add_master(master) + end + + context "when it's forced" do + it 'can be unlocked by the author' do + user = lfs_file_lock.user + + expect(lfs_file_lock.can_be_unlocked_by?(user, true)).to eq(true) + end + + it 'can be unlocked by a master' do + expect(lfs_file_lock.can_be_unlocked_by?(master, true)).to eq(true) + end + + it "can't be unlocked by other user" do + expect(lfs_file_lock.can_be_unlocked_by?(developer, true)).to eq(false) + end + end + + context "when it isn't forced" do + it 'can be unlocked by the author' do + user = lfs_file_lock.user + + expect(lfs_file_lock.can_be_unlocked_by?(user)).to eq(true) + end + + it "can't be unlocked by a master" do + expect(lfs_file_lock.can_be_unlocked_by?(master)).to eq(false) + end + + it "can't be unlocked by other user" do + expect(lfs_file_lock.can_be_unlocked_by?(developer)).to eq(false) + end + end + end +end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 5e126bc4bea..191b60e4383 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -15,7 +15,6 @@ describe Namespace do describe 'validations' do it { is_expected.to validate_presence_of(:name) } - it { is_expected.to validate_uniqueness_of(:name).scoped_to(:parent_id) } it { is_expected.to validate_length_of(:name).is_at_most(255) } it { is_expected.to validate_length_of(:description).is_at_most(255) } it { is_expected.to validate_presence_of(:path) } diff --git a/spec/models/project_auto_devops_spec.rb b/spec/models/project_auto_devops_spec.rb index 12069575866..296b91a771c 100644 --- a/spec/models/project_auto_devops_spec.rb +++ b/spec/models/project_auto_devops_spec.rb @@ -18,7 +18,21 @@ describe ProjectAutoDevops do context 'when domain is empty' do let(:auto_devops) { build_stubbed(:project_auto_devops, project: project, domain: '') } - it { expect(auto_devops).not_to have_domain } + context 'when there is an instance domain specified' do + before do + allow(Gitlab::CurrentSettings).to receive(:auto_devops_domain).and_return('example.com') + end + + it { expect(auto_devops).to have_domain } + end + + context 'when there is no instance domain specified' do + before do + allow(Gitlab::CurrentSettings).to receive(:auto_devops_domain).and_return(nil) + end + + it { expect(auto_devops).not_to have_domain } + end end end @@ -29,9 +43,32 @@ describe ProjectAutoDevops do let(:domain) { 'example.com' } it 'returns AUTO_DEVOPS_DOMAIN' do - expect(auto_devops.variables).to include( - { key: 'AUTO_DEVOPS_DOMAIN', value: 'example.com', public: true }) + expect(auto_devops.variables).to include(domain_variable) end end + + context 'when domain is not defined' do + let(:domain) { nil } + + context 'when there is an instance domain specified' do + before do + allow(Gitlab::CurrentSettings).to receive(:auto_devops_domain).and_return('example.com') + end + + it { expect(auto_devops.variables).to include(domain_variable) } + end + + context 'when there is no instance domain specified' do + before do + allow(Gitlab::CurrentSettings).to receive(:auto_devops_domain).and_return(nil) + end + + it { expect(auto_devops.variables).not_to include(domain_variable) } + end + end + + def domain_variable + { key: 'AUTO_DEVOPS_DOMAIN', value: 'example.com', public: true } + end end end diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb index bf39e8d7a39..ed17e019d42 100644 --- a/spec/models/project_services/prometheus_service_spec.rb +++ b/spec/models/project_services/prometheus_service_spec.rb @@ -13,17 +13,17 @@ describe PrometheusService, :use_clean_rails_memory_store_caching do end describe 'Validations' do - context 'when service is active' do + context 'when manual_configuration is enabled' do before do - subject.active = true + subject.manual_configuration = true end it { is_expected.to validate_presence_of(:api_url) } end - context 'when service is inactive' do + context 'when manual configuration is disabled' do before do - subject.active = false + subject.manual_configuration = false end it { is_expected.not_to validate_presence_of(:api_url) } @@ -31,12 +31,17 @@ describe PrometheusService, :use_clean_rails_memory_store_caching do end describe '#test' do + before do + service.manual_configuration = true + end + let!(:req_stub) { stub_prometheus_request(prometheus_query_url('1'), body: prometheus_value_body('vector')) } context 'success' do it 'reads the discovery endpoint' do + expect(service.test[:result]).to eq('Checked API endpoint') expect(service.test[:success]).to be_truthy - expect(req_stub).to have_been_requested + expect(req_stub).to have_been_requested.twice end end @@ -70,6 +75,25 @@ describe PrometheusService, :use_clean_rails_memory_store_caching do end end + describe '#matched_metrics' do + let(:matched_metrics_query) { Gitlab::Prometheus::Queries::MatchedMetricsQuery } + let(:client) { double(:client, label_values: nil) } + + context 'with valid data' do + subject { service.matched_metrics } + + before do + allow(service).to receive(:client).and_return(client) + synchronous_reactive_cache(service) + end + + it 'returns reactive data' do + expect(subject[:success]).to be_truthy + expect(subject[:data]).to eq([]) + end + end + end + describe '#deployment_metrics' do let(:deployment) { build_stubbed(:deployment) } let(:deployment_query) { Gitlab::Prometheus::Queries::DeploymentQuery } @@ -83,7 +107,7 @@ describe PrometheusService, :use_clean_rails_memory_store_caching do let(:fake_deployment_time) { 10 } before do - stub_reactive_cache(service, prometheus_data, deployment_query, deployment.id) + stub_reactive_cache(service, prometheus_data, deployment_query, deployment.environment.id, deployment.id) end it 'returns reactive data' do @@ -96,13 +120,17 @@ describe PrometheusService, :use_clean_rails_memory_store_caching do describe '#calculate_reactive_cache' do let(:environment) { create(:environment, slug: 'env-slug') } - - around do |example| - Timecop.freeze { example.run } + before do + service.manual_configuration = true + service.active = true end subject do - service.calculate_reactive_cache(environment_query.to_s, environment.id) + service.calculate_reactive_cache(environment_query.name, environment.id) + end + + around do |example| + Timecop.freeze { example.run } end context 'when service is inactive' do @@ -132,4 +160,193 @@ describe PrometheusService, :use_clean_rails_memory_store_caching do end end end + + describe '#client' do + context 'manual configuration is enabled' do + let(:api_url) { 'http://some_url' } + before do + subject.manual_configuration = true + subject.api_url = api_url + end + + it 'returns simple rest client from api_url' do + expect(subject.client).to be_instance_of(Gitlab::PrometheusClient) + expect(subject.client.rest_client.url).to eq(api_url) + end + end + + context 'manual configuration is disabled' do + let!(:cluster_for_all) { create(:cluster, environment_scope: '*', projects: [project]) } + let!(:cluster_for_dev) { create(:cluster, environment_scope: 'dev', projects: [project]) } + + let!(:prometheus_for_dev) { create(:clusters_applications_prometheus, :installed, cluster: cluster_for_dev) } + let(:proxy_client) { double('proxy_client') } + + before do + service.manual_configuration = false + end + + context 'with cluster for all environments with prometheus installed' do + let!(:prometheus_for_all) { create(:clusters_applications_prometheus, :installed, cluster: cluster_for_all) } + + context 'without environment supplied' do + it 'returns client handling all environments' do + expect(service).to receive(:client_from_cluster).with(cluster_for_all).and_return(proxy_client).twice + + expect(service.client).to be_instance_of(Gitlab::PrometheusClient) + expect(service.client.rest_client).to eq(proxy_client) + end + end + + context 'with dev environment supplied' do + let!(:environment) { create(:environment, project: project, name: 'dev') } + + it 'returns dev cluster client' do + expect(service).to receive(:client_from_cluster).with(cluster_for_dev).and_return(proxy_client).twice + + expect(service.client(environment.id)).to be_instance_of(Gitlab::PrometheusClient) + expect(service.client(environment.id).rest_client).to eq(proxy_client) + end + end + + context 'with prod environment supplied' do + let!(:environment) { create(:environment, project: project, name: 'prod') } + + it 'returns dev cluster client' do + expect(service).to receive(:client_from_cluster).with(cluster_for_all).and_return(proxy_client).twice + + expect(service.client(environment.id)).to be_instance_of(Gitlab::PrometheusClient) + expect(service.client(environment.id).rest_client).to eq(proxy_client) + end + end + end + + context 'with cluster for all environments without prometheus installed' do + context 'without environment supplied' do + it 'raises PrometheusError because cluster was not found' do + expect { service.client }.to raise_error(Gitlab::PrometheusError, /couldn't find cluster with Prometheus installed/) + end + end + + context 'with dev environment supplied' do + let!(:environment) { create(:environment, project: project, name: 'dev') } + + it 'returns dev cluster client' do + expect(service).to receive(:client_from_cluster).with(cluster_for_dev).and_return(proxy_client).twice + + expect(service.client(environment.id)).to be_instance_of(Gitlab::PrometheusClient) + expect(service.client(environment.id).rest_client).to eq(proxy_client) + end + end + + context 'with prod environment supplied' do + let!(:environment) { create(:environment, project: project, name: 'prod') } + + it 'raises PrometheusError because cluster was not found' do + expect { service.client }.to raise_error(Gitlab::PrometheusError, /couldn't find cluster with Prometheus installed/) + end + end + end + end + end + + describe '#prometheus_installed?' do + context 'clusters with installed prometheus' do + let!(:cluster) { create(:cluster, projects: [project]) } + let!(:prometheus) { create(:clusters_applications_prometheus, :installed, cluster: cluster) } + + it 'returns true' do + expect(service.prometheus_installed?).to be(true) + end + end + + context 'clusters without prometheus installed' do + let(:cluster) { create(:cluster, projects: [project]) } + let!(:prometheus) { create(:clusters_applications_prometheus, cluster: cluster) } + + it 'returns false' do + expect(service.prometheus_installed?).to be(false) + end + end + + context 'clusters without prometheus' do + let(:cluster) { create(:cluster, projects: [project]) } + + it 'returns false' do + expect(service.prometheus_installed?).to be(false) + end + end + + context 'no clusters' do + it 'returns false' do + expect(service.prometheus_installed?).to be(false) + end + end + end + + describe '#synchronize_service_state! before_save callback' do + context 'no clusters with prometheus are installed' do + context 'when service is inactive' do + before do + service.active = false + end + + it 'activates service when manual_configuration is enabled' do + expect { service.update!(manual_configuration: true) }.to change { service.active }.from(false).to(true) + end + + it 'keeps service inactive when manual_configuration is disabled' do + expect { service.update!(manual_configuration: false) }.not_to change { service.active }.from(false) + end + end + + context 'when service is active' do + before do + service.active = true + end + + it 'keeps the service active when manual_configuration is enabled' do + expect { service.update!(manual_configuration: true) }.not_to change { service.active }.from(true) + end + + it 'inactivates the service when manual_configuration is disabled' do + expect { service.update!(manual_configuration: false) }.to change { service.active }.from(true).to(false) + end + end + end + + context 'with prometheus installed in the cluster' do + before do + allow(service).to receive(:prometheus_installed?).and_return(true) + end + + context 'when service is inactive' do + before do + service.active = false + end + + it 'activates service when manual_configuration is enabled' do + expect { service.update!(manual_configuration: true) }.to change { service.active }.from(false).to(true) + end + + it 'activates service when manual_configuration is disabled' do + expect { service.update!(manual_configuration: false) }.to change { service.active }.from(false).to(true) + end + end + + context 'when service is active' do + before do + service.active = true + end + + it 'keeps service active when manual_configuration is enabled' do + expect { service.update!(manual_configuration: true) }.not_to change { service.active }.from(true) + end + + it 'keeps service active when manual_configuration is disabled' do + expect { service.update!(manual_configuration: false) }.not_to change { service.active }.from(true) + end + end + end + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index da940571bc1..c6ca038a2ba 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -80,6 +80,7 @@ describe Project do it { is_expected.to have_many(:members_and_requesters) } it { is_expected.to have_many(:clusters) } it { is_expected.to have_many(:custom_attributes).class_name('ProjectCustomAttribute') } + it { is_expected.to have_many(:lfs_file_locks) } context 'after initialized' do it "has a project_feature" do @@ -129,7 +130,6 @@ describe Project do it { is_expected.to validate_length_of(:name).is_at_most(255) } it { is_expected.to validate_presence_of(:path) } - it { is_expected.to validate_uniqueness_of(:path).scoped_to(:namespace_id) } it { is_expected.to validate_length_of(:path).is_at_most(255) } it { is_expected.to validate_length_of(:description).is_at_most(2000) } @@ -2071,7 +2071,7 @@ describe Project do create(:ci_variable, :protected, value: 'protected', project: project) end - subject { project.secret_variables_for(ref: 'ref') } + subject { project.reload.secret_variables_for(ref: 'ref') } before do stub_application_setting( @@ -3010,18 +3010,40 @@ describe Project do subject { project.auto_devops_variables } - context 'when enabled in settings' do + context 'when enabled in instance settings' do before do stub_application_setting(auto_devops_enabled: true) end context 'when domain is empty' do before do + stub_application_setting(auto_devops_domain: nil) + end + + it 'variables does not include AUTO_DEVOPS_DOMAIN' do + is_expected.not_to include(domain_variable) + end + end + + context 'when domain is configured' do + before do + stub_application_setting(auto_devops_domain: 'example.com') + end + + it 'variables includes AUTO_DEVOPS_DOMAIN' do + is_expected.to include(domain_variable) + end + end + end + + context 'when explicitely enabled' do + context 'when domain is empty' do + before do create(:project_auto_devops, project: project, domain: nil) end - it 'variables are empty' do - is_expected.to be_empty + it 'variables does not include AUTO_DEVOPS_DOMAIN' do + is_expected.not_to include(domain_variable) end end @@ -3030,11 +3052,15 @@ describe Project do create(:project_auto_devops, project: project, domain: 'example.com') end - it "variables are not empty" do - is_expected.not_to be_empty + it 'variables includes AUTO_DEVOPS_DOMAIN' do + is_expected.to include(domain_variable) end end end + + def domain_variable + { key: 'AUTO_DEVOPS_DOMAIN', value: 'example.com', public: true } + end end describe '#latest_successful_builds_for' do diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 02a5ee54262..a6d48e369ac 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -262,6 +262,28 @@ describe Repository do end end + describe '#new_commits' do + let(:new_refs) do + double(:git_rev_list, new_refs: %w[ + c1acaa58bbcbc3eafe538cb8274ba387047b69f8 + 5937ac0a7beb003549fc5fd26fc247adbce4a52e + ]) + end + + it 'delegates to Gitlab::Git::RevList' do + expect(Gitlab::Git::RevList).to receive(:new).with( + repository.raw, + newrev: 'aaaabbbbccccddddeeeeffffgggghhhhiiiijjjj').and_return(new_refs) + + commits = repository.new_commits('aaaabbbbccccddddeeeeffffgggghhhhiiiijjjj') + + expect(commits).to eq([ + repository.commit('c1acaa58bbcbc3eafe538cb8274ba387047b69f8'), + repository.commit('5937ac0a7beb003549fc5fd26fc247adbce4a52e') + ]) + end + end + describe '#commits_by' do set(:project) { create(:project, :repository) } diff --git a/spec/models/route_spec.rb b/spec/models/route_spec.rb index 88f54fd10e5..dfac82b327a 100644 --- a/spec/models/route_spec.rb +++ b/spec/models/route_spec.rb @@ -27,7 +27,7 @@ describe Route do redirect.save!(validate: false) expect(new_route.valid?).to be_falsey - expect(new_route.errors.first[1]).to eq('foo has been taken before. Please use another one') + expect(new_route.errors.first[1]).to eq('has been taken before') end end @@ -49,7 +49,7 @@ describe Route do redirect.save!(validate: false) expect(route.valid?).to be_falsey - expect(route.errors.first[1]).to eq('foo has been taken before. Please use another one') + expect(route.errors.first[1]).to eq('has been taken before') end end @@ -368,7 +368,7 @@ describe Route do group2.path = 'foo' group2.valid? - expect(group2.errors["route.path"].first).to eq('foo has been taken before. Please use another one') + expect(group2.errors[:path]).to eq(['has been taken before']) end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 011416cc176..cb02d526a98 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -101,7 +101,7 @@ describe User do user = build(:user, username: 'dashboard') expect(user).not_to be_valid - expect(user.errors.values).to eq [['dashboard is a reserved name']] + expect(user.errors.messages[:username]).to eq ['dashboard is a reserved name'] end it 'allows child names' do @@ -116,12 +116,6 @@ describe User do expect(user).to be_valid end - it 'validates uniqueness' do - user = build(:user) - - expect(user).to validate_uniqueness_of(:username).case_insensitive - end - context 'when username is changed' do let(:user) { build_stubbed(:user, username: 'old_path', namespace: build_stubbed(:namespace)) } @@ -132,6 +126,35 @@ describe User do expect(user.errors.messages[:username].first).to match('cannot be changed if a personal project has container registry tags') end end + + context 'when the username was used by another user before' do + let(:username) { 'foo' } + let!(:other_user) { create(:user, username: username) } + + before do + other_user.username = 'bar' + other_user.save! + end + + it 'is invalid' do + user = build(:user, username: username) + + expect(user).not_to be_valid + expect(user.errors.full_messages).to eq(['Username has been taken before']) + end + end + + context 'when the username is in use by another user' do + let(:username) { 'foo' } + let!(:other_user) { create(:user, username: username) } + + it 'is invalid' do + user = build(:user, username: username) + + expect(user).not_to be_valid + expect(user.errors.full_messages).to eq(['Username has already been taken']) + end + end end it 'has a DB-level NOT NULL constraint on projects_limit' do @@ -2270,17 +2293,17 @@ describe User do end context 'when there is a validation error (namespace name taken) while updating namespace' do - let!(:conflicting_namespace) { create(:group, name: new_username, path: 'quz') } + let!(:conflicting_namespace) { create(:group, path: new_username) } it 'causes the user save to fail' do expect(user.update_attributes(username: new_username)).to be_falsey - expect(user.namespace.errors.messages[:name].first).to eq('has already been taken') + expect(user.namespace.errors.messages[:path].first).to eq('has already been taken') end it 'adds the namespace errors to the user' do user.update_attributes(username: new_username) - expect(user.errors.full_messages.first).to eq('Namespace name has already been taken') + expect(user.errors.full_messages.first).to eq('Username has already been taken') end end end @@ -2623,7 +2646,7 @@ describe User do it 'should raise an ActiveRecord::RecordInvalid exception' do user2 = build(:user, username: 'foo') - expect { user2.save! }.to raise_error(ActiveRecord::RecordInvalid, /Route path foo has been taken before. Please use another one, Route is invalid/) + expect { user2.save! }.to raise_error(ActiveRecord::RecordInvalid, /Username has been taken before/) end end diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb index d53ba497ed1..b2b7721674c 100644 --- a/spec/models/wiki_page_spec.rb +++ b/spec/models/wiki_page_spec.rb @@ -188,162 +188,181 @@ describe WikiPage do end end - describe '#create', :skip_gitaly_mock do - context 'with valid attributes' do - it 'raises an error if a page with the same path already exists' do - create_page('New Page', 'content') - create_page('foo/bar', 'content') - expect { create_page('New Page', 'other content') }.to raise_error Gitlab::Git::Wiki::DuplicatePageError - expect { create_page('foo/bar', 'other content') }.to raise_error Gitlab::Git::Wiki::DuplicatePageError - - destroy_page('New Page') - destroy_page('bar', 'foo') - end + describe '#create' do + shared_examples 'create method' do + context 'with valid attributes' do + it 'raises an error if a page with the same path already exists' do + create_page('New Page', 'content') + create_page('foo/bar', 'content') + expect { create_page('New Page', 'other content') }.to raise_error Gitlab::Git::Wiki::DuplicatePageError + expect { create_page('foo/bar', 'other content') }.to raise_error Gitlab::Git::Wiki::DuplicatePageError + + destroy_page('New Page') + destroy_page('bar', 'foo') + end - it 'if the title is preceded by a / it is removed' do - create_page('/New Page', 'content') + it 'if the title is preceded by a / it is removed' do + create_page('/New Page', 'content') - expect(wiki.find_page('New Page')).not_to be_nil + expect(wiki.find_page('New Page')).not_to be_nil - destroy_page('New Page') + destroy_page('New Page') + end end end - end - # Remove skip_gitaly_mock flag when gitaly_update_page implements moving pages - describe "#update", :skip_gitaly_mock do - before do - create_page("Update", "content") - @page = wiki.find_page("Update") + context 'when Gitaly is enabled' do + it_behaves_like 'create method' end - after do - destroy_page(@page.title, @page.directory) + context 'when Gitaly is disabled', :skip_gitaly_mock do + it_behaves_like 'create method' end + end - context "with valid attributes" do - it "updates the content of the page" do - new_content = "new content" - - @page.update(content: new_content) + describe "#update" do + shared_examples 'update method' do + before do + create_page("Update", "content") @page = wiki.find_page("Update") + end - expect(@page.content).to eq("new content") + after do + destroy_page(@page.title, @page.directory) end - it "updates the title of the page" do - new_title = "Index v.1.2.4" + context "with valid attributes" do + it "updates the content of the page" do + new_content = "new content" - @page.update(title: new_title) - @page = wiki.find_page(new_title) + @page.update(content: new_content) + @page = wiki.find_page("Update") - expect(@page.title).to eq(new_title) - end + expect(@page.content).to eq("new content") + end - it "returns true" do - expect(@page.update(content: "more content")).to be_truthy + it "updates the title of the page" do + new_title = "Index v.1.2.4" + + @page.update(title: new_title) + @page = wiki.find_page(new_title) + + expect(@page.title).to eq(new_title) + end + + it "returns true" do + expect(@page.update(content: "more content")).to be_truthy + end end - end - context 'with same last commit sha' do - it 'returns true' do - expect(@page.update(content: 'more content', last_commit_sha: @page.last_commit_sha)).to be_truthy + context 'with same last commit sha' do + it 'returns true' do + expect(@page.update(content: 'more content', last_commit_sha: @page.last_commit_sha)).to be_truthy + end end - end - context 'with different last commit sha' do - it 'raises exception' do - expect { @page.update(content: 'more content', last_commit_sha: 'xxx') }.to raise_error(WikiPage::PageChangedError) + context 'with different last commit sha' do + it 'raises exception' do + expect { @page.update(content: 'more content', last_commit_sha: 'xxx') }.to raise_error(WikiPage::PageChangedError) + end end - end - context 'when renaming a page' do - it 'raises an error if the page already exists' do - create_page('Existing Page', 'content') + context 'when renaming a page' do + it 'raises an error if the page already exists' do + create_page('Existing Page', 'content') - expect { @page.update(title: 'Existing Page', content: 'new_content') }.to raise_error(WikiPage::PageRenameError) - expect(@page.title).to eq 'Update' - expect(@page.content).to eq 'new_content' + expect { @page.update(title: 'Existing Page', content: 'new_content') }.to raise_error(WikiPage::PageRenameError) + expect(@page.title).to eq 'Update' + expect(@page.content).to eq 'new_content' - destroy_page('Existing Page') - end + destroy_page('Existing Page') + end - it 'updates the content and rename the file' do - new_title = 'Renamed Page' - new_content = 'updated content' + it 'updates the content and rename the file' do + new_title = 'Renamed Page' + new_content = 'updated content' - expect(@page.update(title: new_title, content: new_content)).to be_truthy + expect(@page.update(title: new_title, content: new_content)).to be_truthy - @page = wiki.find_page(new_title) + @page = wiki.find_page(new_title) - expect(@page).not_to be_nil - expect(@page.content).to eq new_content + expect(@page).not_to be_nil + expect(@page.content).to eq new_content + end end - end - - context 'when moving a page' do - it 'raises an error if the page already exists' do - create_page('foo/Existing Page', 'content') - expect { @page.update(title: 'foo/Existing Page', content: 'new_content') }.to raise_error(WikiPage::PageRenameError) - expect(@page.title).to eq 'Update' - expect(@page.content).to eq 'new_content' + context 'when moving a page' do + it 'raises an error if the page already exists' do + create_page('foo/Existing Page', 'content') - destroy_page('Existing Page', 'foo') - end + expect { @page.update(title: 'foo/Existing Page', content: 'new_content') }.to raise_error(WikiPage::PageRenameError) + expect(@page.title).to eq 'Update' + expect(@page.content).to eq 'new_content' - it 'updates the content and moves the file' do - new_title = 'foo/Other Page' - new_content = 'new_content' + destroy_page('Existing Page', 'foo') + end - expect(@page.update(title: new_title, content: new_content)).to be_truthy + it 'updates the content and moves the file' do + new_title = 'foo/Other Page' + new_content = 'new_content' - page = wiki.find_page(new_title) + expect(@page.update(title: new_title, content: new_content)).to be_truthy - expect(page).not_to be_nil - expect(page.content).to eq new_content - end + page = wiki.find_page(new_title) - context 'in subdir' do - before do - create_page('foo/Existing Page', 'content') - @page = wiki.find_page('foo/Existing Page') + expect(page).not_to be_nil + expect(page.content).to eq new_content end - it 'moves the page to the root folder if the title is preceded by /' do - expect(@page.slug).to eq 'foo/Existing-Page' - expect(@page.update(title: '/Existing Page', content: 'new_content')).to be_truthy - expect(@page.slug).to eq 'Existing-Page' + context 'in subdir' do + before do + create_page('foo/Existing Page', 'content') + @page = wiki.find_page('foo/Existing Page') + end + + it 'moves the page to the root folder if the title is preceded by /', :skip_gitaly_mock do + expect(@page.slug).to eq 'foo/Existing-Page' + expect(@page.update(title: '/Existing Page', content: 'new_content')).to be_truthy + expect(@page.slug).to eq 'Existing-Page' + end + + it 'does nothing if it has the same title' do + original_path = @page.slug + + expect(@page.update(title: 'Existing Page', content: 'new_content')).to be_truthy + expect(@page.slug).to eq original_path + end end - it 'does nothing if it has the same title' do - original_path = @page.slug + context 'in root dir' do + it 'does nothing if the title is preceded by /' do + original_path = @page.slug - expect(@page.update(title: 'Existing Page', content: 'new_content')).to be_truthy - expect(@page.slug).to eq original_path + expect(@page.update(title: '/Update', content: 'new_content')).to be_truthy + expect(@page.slug).to eq original_path + end end end - context 'in root dir' do - it 'does nothing if the title is preceded by /' do - original_path = @page.slug + context "with invalid attributes" do + it 'aborts update if title blank' do + expect(@page.update(title: '', content: 'new_content')).to be_falsey + expect(@page.content).to eq 'new_content' - expect(@page.update(title: '/Update', content: 'new_content')).to be_truthy - expect(@page.slug).to eq original_path + page = wiki.find_page('Update') + expect(page.content).to eq 'content' + + @page.title = 'Update' end end end - context "with invalid attributes" do - it 'aborts update if title blank' do - expect(@page.update(title: '', content: 'new_content')).to be_falsey - expect(@page.content).to eq 'new_content' - - page = wiki.find_page('Update') - expect(page.content).to eq 'content' + context 'when Gitaly is enabled' do + it_behaves_like 'update method' + end - @page.title = 'Update' - end + context 'when Gitaly is disabled', :skip_gitaly_mock do + it_behaves_like 'update method' end end diff --git a/spec/presenters/ci/group_variable_presenter_spec.rb b/spec/presenters/ci/group_variable_presenter_spec.rb index d404028405b..cb58a757564 100644 --- a/spec/presenters/ci/group_variable_presenter_spec.rb +++ b/spec/presenters/ci/group_variable_presenter_spec.rb @@ -35,29 +35,20 @@ describe Ci::GroupVariablePresenter do end describe '#form_path' do - context 'when variable is persisted' do - subject { described_class.new(variable).form_path } + subject { described_class.new(variable).form_path } - it { is_expected.to eq(group_variable_path(group, variable)) } - end - - context 'when variable is not persisted' do - let(:variable) { build(:ci_group_variable, group: group) } - subject { described_class.new(variable).form_path } - - it { is_expected.to eq(group_variables_path(group)) } - end + it { is_expected.to eq(group_settings_ci_cd_path(group)) } end describe '#edit_path' do subject { described_class.new(variable).edit_path } - it { is_expected.to eq(group_variable_path(group, variable)) } + it { is_expected.to eq(group_variables_path(group)) } end describe '#delete_path' do subject { described_class.new(variable).delete_path } - it { is_expected.to eq(group_variable_path(group, variable)) } + it { is_expected.to eq(group_variables_path(group)) } end end diff --git a/spec/presenters/ci/variable_presenter_spec.rb b/spec/presenters/ci/variable_presenter_spec.rb index db62f86edb0..e3ce88372ea 100644 --- a/spec/presenters/ci/variable_presenter_spec.rb +++ b/spec/presenters/ci/variable_presenter_spec.rb @@ -35,29 +35,20 @@ describe Ci::VariablePresenter do end describe '#form_path' do - context 'when variable is persisted' do - subject { described_class.new(variable).form_path } + subject { described_class.new(variable).form_path } - it { is_expected.to eq(project_variable_path(project, variable)) } - end - - context 'when variable is not persisted' do - let(:variable) { build(:ci_variable, project: project) } - subject { described_class.new(variable).form_path } - - it { is_expected.to eq(project_variables_path(project)) } - end + it { is_expected.to eq(project_settings_ci_cd_path(project)) } end describe '#edit_path' do subject { described_class.new(variable).edit_path } - it { is_expected.to eq(project_variable_path(project, variable)) } + it { is_expected.to eq(project_variables_path(project)) } end describe '#delete_path' do subject { described_class.new(variable).delete_path } - it { is_expected.to eq(project_variable_path(project, variable)) } + it { is_expected.to eq(project_variables_path(project)) } end end diff --git a/spec/requests/api/group_variables_spec.rb b/spec/requests/api/group_variables_spec.rb index a4f198eb5c9..64fa7dc824c 100644 --- a/spec/requests/api/group_variables_spec.rb +++ b/spec/requests/api/group_variables_spec.rb @@ -142,12 +142,12 @@ describe API::GroupVariables do end it 'updates variable data' do - initial_variable = group.variables.first + initial_variable = group.variables.reload.first value_before = initial_variable.value put api("/groups/#{group.id}/variables/#{variable.key}", user), value: 'VALUE_1_UP', protected: true - updated_variable = group.variables.first + updated_variable = group.variables.reload.first expect(response).to have_gitlab_http_status(200) expect(value_before).to eq(variable.value) diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb index 884a258fd12..ea6b0a71849 100644 --- a/spec/requests/api/internal_spec.rb +++ b/spec/requests/api/internal_spec.rb @@ -368,7 +368,7 @@ describe API::Internal do context 'project as /namespace/project' do it do - pull(key, project_with_repo_path('/' + project.full_path)) + push(key, project_with_repo_path('/' + project.full_path)) expect(response).to have_gitlab_http_status(200) expect(json_response["status"]).to be_truthy @@ -379,7 +379,7 @@ describe API::Internal do context 'project as namespace/project' do it do - pull(key, project_with_repo_path(project.full_path)) + push(key, project_with_repo_path(project.full_path)) expect(response).to have_gitlab_http_status(200) expect(json_response["status"]).to be_truthy @@ -807,14 +807,27 @@ describe API::Internal do context 'with a redirected data' do it 'returns redirected message on the response' do - project_moved = Gitlab::Checks::ProjectMoved.new(project, user, 'foo/baz', 'http') - project_moved.add_redirect_message + project_moved = Gitlab::Checks::ProjectMoved.new(project, user, 'http', 'foo/baz') + project_moved.add_message post api("/internal/post_receive"), valid_params expect(response).to have_gitlab_http_status(200) expect(json_response["redirected_message"]).to be_present - expect(json_response["redirected_message"]).to eq(project_moved.redirect_message) + expect(json_response["redirected_message"]).to eq(project_moved.message) + end + end + + context 'with new project data' do + it 'returns new project message on the response' do + project_created = Gitlab::Checks::ProjectCreated.new(project, user, 'http') + project_created.add_message + + post api("/internal/post_receive"), valid_params + + expect(response).to have_gitlab_http_status(200) + expect(json_response["project_created_message"]).to be_present + expect(json_response["project_created_message"]).to eq(project_created.message) end end diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb index 0bd88748479..f10b6e43d09 100644 --- a/spec/requests/api/runner_spec.rb +++ b/spec/requests/api/runner_spec.rb @@ -8,6 +8,7 @@ describe API::Runner do before do stub_gitlab_calls stub_application_setting(runners_registration_token: registration_token) + allow_any_instance_of(Ci::Runner).to receive(:cache_attributes) end describe '/api/v4/runners' do @@ -408,7 +409,7 @@ describe API::Runner do expect { request_job }.to change { runner.reload.contacted_at } end - %w(name version revision platform architecture).each do |param| + %w(version revision platform architecture).each do |param| context "when info parameter '#{param}' is present" do let(:value) { "#{param}_value" } diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb new file mode 100644 index 00000000000..a0026c6e11c --- /dev/null +++ b/spec/requests/api/search_spec.rb @@ -0,0 +1,298 @@ +require 'spec_helper' + +describe API::Search do + set(:user) { create(:user) } + set(:group) { create(:group) } + set(:project) { create(:project, :public, name: 'awesome project', group: group) } + set(:repo_project) { create(:project, :public, :repository, group: group) } + + shared_examples 'response is correct' do |schema:, size: 1| + it { expect(response).to have_gitlab_http_status(200) } + it { expect(response).to match_response_schema(schema) } + it { expect(response).to include_limited_pagination_headers } + it { expect(json_response.size).to eq(size) } + end + + describe 'GET /search' do + context 'when user is not authenticated' do + it 'returns 401 error' do + get api('/search'), scope: 'projects', search: 'awesome' + + expect(response).to have_gitlab_http_status(401) + end + end + + context 'when scope is not supported' do + it 'returns 400 error' do + get api('/search', user), scope: 'unsupported', search: 'awesome' + + expect(response).to have_gitlab_http_status(400) + end + end + + context 'when scope is missing' do + it 'returns 400 error' do + get api('/search', user), search: 'awesome' + + expect(response).to have_gitlab_http_status(400) + end + end + + context 'with correct params' do + context 'for projects scope' do + before do + get api('/search', user), scope: 'projects', search: 'awesome' + end + + it_behaves_like 'response is correct', schema: 'public_api/v4/projects' + end + + context 'for issues scope' do + before do + create(:issue, project: project, title: 'awesome issue') + + get api('/search', user), scope: 'issues', search: 'awesome' + end + + it_behaves_like 'response is correct', schema: 'public_api/v4/issues' + end + + context 'for merge_requests scope' do + before do + create(:merge_request, source_project: repo_project, title: 'awesome mr') + + get api('/search', user), scope: 'merge_requests', search: 'awesome' + end + + it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests' + end + + context 'for milestones scope' do + before do + create(:milestone, project: project, title: 'awesome milestone') + + get api('/search', user), scope: 'milestones', search: 'awesome' + end + + it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' + end + + context 'for snippet_titles scope' do + before do + create(:snippet, :public, title: 'awesome snippet', content: 'snippet content') + + get api('/search', user), scope: 'snippet_titles', search: 'awesome' + end + + it_behaves_like 'response is correct', schema: 'public_api/v4/snippets' + end + + context 'for snippet_blobs scope' do + before do + create(:snippet, :public, title: 'awesome snippet', content: 'snippet content') + + get api('/search', user), scope: 'snippet_blobs', search: 'content' + end + + it_behaves_like 'response is correct', schema: 'public_api/v4/snippets' + end + end + end + + describe "GET /groups/:id/-/search" do + context 'when user is not authenticated' do + it 'returns 401 error' do + get api("/groups/#{group.id}/-/search"), scope: 'projects', search: 'awesome' + + expect(response).to have_gitlab_http_status(401) + end + end + + context 'when scope is not supported' do + it 'returns 400 error' do + get api("/groups/#{group.id}/-/search", user), scope: 'unsupported', search: 'awesome' + + expect(response).to have_gitlab_http_status(400) + end + end + + context 'when scope is missing' do + it 'returns 400 error' do + get api("/groups/#{group.id}/-/search", user), search: 'awesome' + + expect(response).to have_gitlab_http_status(400) + end + end + + context 'when group does not exist' do + it 'returns 404 error' do + get api('/groups/9999/-/search', user), scope: 'issues', search: 'awesome' + + expect(response).to have_gitlab_http_status(404) + end + end + + context 'when user does can not see the group' do + it 'returns 404 error' do + private_group = create(:group, :private) + + get api("/groups/#{private_group.id}/-/search", user), scope: 'issues', search: 'awesome' + + expect(response).to have_gitlab_http_status(404) + end + end + + context 'with correct params' do + context 'for projects scope' do + before do + get api("/groups/#{group.id}/-/search", user), scope: 'projects', search: 'awesome' + end + + it_behaves_like 'response is correct', schema: 'public_api/v4/projects' + end + + context 'for issues scope' do + before do + create(:issue, project: project, title: 'awesome issue') + + get api("/groups/#{group.id}/-/search", user), scope: 'issues', search: 'awesome' + end + + it_behaves_like 'response is correct', schema: 'public_api/v4/issues' + end + + context 'for merge_requests scope' do + before do + create(:merge_request, source_project: repo_project, title: 'awesome mr') + + get api("/groups/#{group.id}/-/search", user), scope: 'merge_requests', search: 'awesome' + end + + it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests' + end + + context 'for milestones scope' do + before do + create(:milestone, project: project, title: 'awesome milestone') + + get api("/groups/#{group.id}/-/search", user), scope: 'milestones', search: 'awesome' + end + + it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' + end + end + end + + describe "GET /projects/:id/search" do + context 'when user is not authenticated' do + it 'returns 401 error' do + get api("/projects/#{project.id}/-/search"), scope: 'issues', search: 'awesome' + + expect(response).to have_gitlab_http_status(401) + end + end + + context 'when scope is not supported' do + it 'returns 400 error' do + get api("/projects/#{project.id}/-/search", user), scope: 'unsupported', search: 'awesome' + + expect(response).to have_gitlab_http_status(400) + end + end + + context 'when scope is missing' do + it 'returns 400 error' do + get api("/projects/#{project.id}/-/search", user), search: 'awesome' + + expect(response).to have_gitlab_http_status(400) + end + end + + context 'when project does not exist' do + it 'returns 404 error' do + get api('/projects/9999/-/search', user), scope: 'issues', search: 'awesome' + + expect(response).to have_gitlab_http_status(404) + end + end + + context 'when user does can not see the project' do + it 'returns 404 error' do + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + + get api("/projects/#{project.id}/-/search", user), scope: 'issues', search: 'awesome' + + expect(response).to have_gitlab_http_status(404) + end + end + + context 'with correct params' do + context 'for issues scope' do + before do + create(:issue, project: project, title: 'awesome issue') + + get api("/projects/#{project.id}/-/search", user), scope: 'issues', search: 'awesome' + end + + it_behaves_like 'response is correct', schema: 'public_api/v4/issues' + end + + context 'for merge_requests scope' do + before do + create(:merge_request, source_project: repo_project, title: 'awesome mr') + + get api("/projects/#{repo_project.id}/-/search", user), scope: 'merge_requests', search: 'awesome' + end + + it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests' + end + + context 'for milestones scope' do + before do + create(:milestone, project: project, title: 'awesome milestone') + + get api("/projects/#{project.id}/-/search", user), scope: 'milestones', search: 'awesome' + end + + it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' + end + + context 'for notes scope' do + before do + create(:note_on_merge_request, project: project, note: 'awesome note') + + get api("/projects/#{project.id}/-/search", user), scope: 'notes', search: 'awesome' + end + + it_behaves_like 'response is correct', schema: 'public_api/v4/notes' + end + + context 'for wiki_blobs scope' do + before do + wiki = create(:project_wiki, project: project) + create(:wiki_page, wiki: wiki, attrs: { title: 'home', content: "Awesome page" }) + + get api("/projects/#{project.id}/-/search", user), scope: 'wiki_blobs', search: 'awesome' + end + + it_behaves_like 'response is correct', schema: 'public_api/v4/blobs' + end + + context 'for commits scope' do + before do + get api("/projects/#{repo_project.id}/-/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6' + end + + it_behaves_like 'response is correct', schema: 'public_api/v4/commits' + end + + context 'for blobs scope' do + before do + get api("/projects/#{repo_project.id}/-/search", user), scope: 'blobs', search: 'monitors' + end + + it_behaves_like 'response is correct', schema: 'public_api/v4/blobs', size: 2 + end + end + end +end diff --git a/spec/requests/api/variables_spec.rb b/spec/requests/api/variables_spec.rb index 79ee6c126f6..62215ea3d7d 100644 --- a/spec/requests/api/variables_spec.rb +++ b/spec/requests/api/variables_spec.rb @@ -122,12 +122,12 @@ describe API::Variables do describe 'PUT /projects/:id/variables/:key' do context 'authorized user with proper permissions' do it 'updates variable data' do - initial_variable = project.variables.first + initial_variable = project.variables.reload.first value_before = initial_variable.value put api("/projects/#{project.id}/variables/#{variable.key}", user), value: 'VALUE_1_UP', protected: true - updated_variable = project.variables.first + updated_variable = project.variables.reload.first expect(response).to have_gitlab_http_status(200) expect(value_before).to eq(variable.value) diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index 27bd22d6bca..2e2dccdafad 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -107,15 +107,39 @@ describe 'Git HTTP requests' do let(:user) { create(:user) } context "when the project doesn't exist" do - let(:path) { 'doesnt/exist.git' } + context "when namespace doesn't exist" do + let(:path) { 'doesnt/exist.git' } - it_behaves_like 'pulls require Basic HTTP Authentication' - it_behaves_like 'pushes require Basic HTTP Authentication' + it_behaves_like 'pulls require Basic HTTP Authentication' + it_behaves_like 'pushes require Basic HTTP Authentication' - context 'when authenticated' do - it 'rejects downloads and uploads with 404 Not Found' do - download_or_upload(path, user: user.username, password: user.password) do |response| - expect(response).to have_gitlab_http_status(:not_found) + context 'when authenticated' do + it 'rejects downloads and uploads with 404 Not Found' do + download_or_upload(path, user: user.username, password: user.password) do |response| + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + end + + context 'when namespace exists' do + let(:path) { "#{user.namespace.path}/new-project.git"} + + context 'when authenticated' do + it 'creates a new project under the existing namespace' do + expect do + upload(path, user: user.username, password: user.password) do |response| + expect(response).to have_gitlab_http_status(:ok) + end + end.to change { user.projects.count }.by(1) + end + + it 'rejects push with 422 Unprocessable Entity when project is invalid' do + path = "#{user.namespace.path}/new.git" + + push_get(path, user: user.username, password: user.password) + + expect(response).to have_gitlab_http_status(:unprocessable_entity) end end end @@ -596,7 +620,7 @@ describe 'Git HTTP requests' do push_get(path, env) expect(response).to have_gitlab_http_status(:forbidden) - expect(response.body).to eq(git_access_error(:upload)) + expect(response.body).to eq(git_access_error(:auth_upload)) end # We are "authenticated" as CI using a valid token here. But we are @@ -636,7 +660,7 @@ describe 'Git HTTP requests' do push_get path, env expect(response).to have_gitlab_http_status(:forbidden) - expect(response.body).to eq(git_access_error(:upload)) + expect(response.body).to eq(git_access_error(:auth_upload)) end end diff --git a/spec/requests/lfs_http_spec.rb b/spec/requests/lfs_http_spec.rb index 930ef49b7f3..971b45c411d 100644 --- a/spec/requests/lfs_http_spec.rb +++ b/spec/requests/lfs_http_spec.rb @@ -1208,7 +1208,7 @@ describe 'Git LFS API and storage' do end def post_lfs_json(url, body = nil, headers = nil) - post(url, body.try(:to_json), (headers || {}).merge('Content-Type' => 'application/vnd.git-lfs+json')) + post(url, body.try(:to_json), (headers || {}).merge('Content-Type' => LfsRequest::CONTENT_TYPE)) end def json_response diff --git a/spec/requests/lfs_locks_api_spec.rb b/spec/requests/lfs_locks_api_spec.rb new file mode 100644 index 00000000000..e44a11a7232 --- /dev/null +++ b/spec/requests/lfs_locks_api_spec.rb @@ -0,0 +1,159 @@ +require 'spec_helper' + +describe 'Git LFS File Locking API' do + include WorkhorseHelpers + + let(:project) { create(:project) } + let(:master) { create(:user) } + let(:developer) { create(:user) } + let(:guest) { create(:user) } + let(:path) { 'README.md' } + let(:headers) do + { + 'Authorization' => authorization + }.compact + end + + shared_examples 'unauthorized request' do + context 'when user is not authorized' do + let(:authorization) { authorize_user(guest) } + + it 'returns a forbidden 403 response' do + post_lfs_json url, body, headers + + expect(response).to have_gitlab_http_status(403) + end + end + end + + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + + project.add_developer(master) + project.add_developer(developer) + project.add_guest(guest) + end + + describe 'Create File Lock endpoint' do + let(:url) { "#{project.http_url_to_repo}/info/lfs/locks" } + let(:authorization) { authorize_user(developer) } + let(:body) { { path: path } } + + include_examples 'unauthorized request' + + context 'with an existent lock' do + before do + lock_file('README.md', developer) + end + + it 'return an error message' do + post_lfs_json url, body, headers + + expect(response).to have_gitlab_http_status(409) + + expect(json_response.keys).to match_array(%w(lock message documentation_url)) + expect(json_response['message']).to match(/already locked/) + end + + it 'returns the existen lock' do + post_lfs_json url, body, headers + + expect(json_response['lock']['path']).to eq('README.md') + end + end + + context 'without an existent lock' do + it 'creates the lock' do + post_lfs_json url, body, headers + + expect(response).to have_gitlab_http_status(201) + + expect(json_response['lock'].keys).to match_array(%w(id path locked_at owner)) + end + end + end + + describe 'Listing File Locks endpoint' do + let(:url) { "#{project.http_url_to_repo}/info/lfs/locks" } + let(:authorization) { authorize_user(developer) } + + include_examples 'unauthorized request' + + it 'returns the list of locked files' do + lock_file('README.md', developer) + lock_file('README', developer) + + do_get url, nil, headers + + expect(response).to have_gitlab_http_status(200) + + expect(json_response['locks'].size).to eq(2) + expect(json_response['locks'].first.keys).to match_array(%w(id path locked_at owner)) + end + end + + describe 'List File Locks for verification endpoint' do + let(:url) { "#{project.http_url_to_repo}/info/lfs/locks/verify" } + let(:authorization) { authorize_user(developer) } + + include_examples 'unauthorized request' + + it 'returns the list of locked files grouped by owner' do + lock_file('README.md', master) + lock_file('README', developer) + + post_lfs_json url, nil, headers + + expect(response).to have_gitlab_http_status(200) + + expect(json_response['ours'].size).to eq(1) + expect(json_response['ours'].first['path']).to eq('README') + expect(json_response['theirs'].size).to eq(1) + expect(json_response['theirs'].first['path']).to eq('README.md') + end + end + + describe 'Delete File Lock endpoint' do + let!(:lock) { lock_file('README.md', developer) } + let(:url) { "#{project.http_url_to_repo}/info/lfs/locks/#{lock[:id]}/unlock" } + let(:authorization) { authorize_user(developer) } + + include_examples 'unauthorized request' + + context 'with an existent lock' do + it 'deletes the lock' do + post_lfs_json url, nil, headers + + expect(response).to have_gitlab_http_status(200) + end + + it 'returns the deleted lock' do + post_lfs_json url, nil, headers + + expect(json_response['lock'].keys).to match_array(%w(id path locked_at owner)) + end + end + end + + def lock_file(path, author) + result = Lfs::LockFileService.new(project, author, { path: path }).execute + + result[:lock] + end + + def authorize_user(user) + ActionController::HttpAuthentication::Basic.encode_credentials(user.username, user.password) + end + + def post_lfs_json(url, body = nil, headers = nil) + post(url, body.try(:to_json), (headers || {}).merge('Content-Type' => LfsRequest::CONTENT_TYPE)) + end + + def do_get(url, params = nil, headers = nil) + get(url, (params || {}), (headers || {}).merge('Content-Type' => LfsRequest::CONTENT_TYPE)) + end + + def json_response + @json_response ||= JSON.parse(response.body) + end +end diff --git a/spec/serializers/group_variable_entity_spec.rb b/spec/serializers/group_variable_entity_spec.rb new file mode 100644 index 00000000000..f6de7d01f98 --- /dev/null +++ b/spec/serializers/group_variable_entity_spec.rb @@ -0,0 +1,14 @@ +require 'spec_helper' + +describe GroupVariableEntity do + let(:variable) { create(:ci_group_variable) } + let(:entity) { described_class.new(variable) } + + describe '#as_json' do + subject { entity.as_json } + + it 'contains required fields' do + expect(subject).to include(:id, :key, :value, :protected) + end + end +end diff --git a/spec/serializers/lfs_file_lock_entity_spec.rb b/spec/serializers/lfs_file_lock_entity_spec.rb new file mode 100644 index 00000000000..5919f473a90 --- /dev/null +++ b/spec/serializers/lfs_file_lock_entity_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe LfsFileLockEntity do + let(:user) { create(:user) } + let(:resource) { create(:lfs_file_lock, user: user) } + + let(:request) { double('request', current_user: user) } + + subject { described_class.new(resource, request: request).as_json } + + it 'exposes basic attrs of the lock' do + expect(subject).to include(:id, :path, :locked_at) + end + + it 'exposes the owner info' do + expect(subject).to include(:owner) + expect(subject[:owner][:name]).to eq(user.name) + end +end diff --git a/spec/serializers/variable_entity_spec.rb b/spec/serializers/variable_entity_spec.rb new file mode 100644 index 00000000000..effc0022633 --- /dev/null +++ b/spec/serializers/variable_entity_spec.rb @@ -0,0 +1,14 @@ +require 'spec_helper' + +describe VariableEntity do + let(:variable) { create(:ci_variable) } + let(:entity) { described_class.new(variable) } + + describe '#as_json' do + subject { entity.as_json } + + it 'contains required fields' do + expect(subject).to include(:id, :key, :value, :protected) + end + end +end diff --git a/spec/services/ci/ensure_stage_service_spec.rb b/spec/services/ci/ensure_stage_service_spec.rb new file mode 100644 index 00000000000..d17e30763d7 --- /dev/null +++ b/spec/services/ci/ensure_stage_service_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +describe Ci::EnsureStageService, '#execute' do + set(:project) { create(:project) } + set(:user) { create(:user) } + + let(:stage) { create(:ci_stage_entity) } + let(:job) { build(:ci_build) } + + let(:service) { described_class.new(project, user) } + + context 'when build has a stage assigned' do + it 'does not create a new stage' do + job.assign_attributes(stage_id: stage.id) + + expect { service.execute(job) }.not_to change { Ci::Stage.count } + end + end + + context 'when build does not have a stage assigned' do + it 'creates a new stage' do + job.assign_attributes(stage_id: nil, stage: 'test') + + expect { service.execute(job) }.to change { Ci::Stage.count }.by(1) + end + end + + context 'when build is invalid' do + it 'does not create a new stage' do + job.assign_attributes(stage_id: nil, ref: nil) + + expect { service.execute(job) }.not_to change { Ci::Stage.count } + end + end + + context 'when new stage can not be created because of an exception' do + before do + allow(Ci::Stage).to receive(:create!) + .and_raise(ActiveRecord::RecordNotUnique.new('Duplicates!')) + end + + it 'retries up to two times' do + job.assign_attributes(stage_id: nil) + + expect(service).to receive(:find_stage).exactly(2).times + + expect { service.execute(job) } + .to raise_error(Ci::EnsureStageService::EnsureStageError) + end + end +end diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index 2c2f48e323d..db9c216d3f4 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -5,7 +5,11 @@ describe Ci::RetryBuildService do set(:project) { create(:project) } set(:pipeline) { create(:ci_pipeline, project: project) } - let(:build) { create(:ci_build, pipeline: pipeline) } + let(:stage) do + Ci::Stage.create!(project: project, pipeline: pipeline, name: 'test') + end + + let(:build) { create(:ci_build, pipeline: pipeline, stage_id: stage.id) } let(:service) do described_class.new(project, user) @@ -27,29 +31,27 @@ describe Ci::RetryBuildService do user_id auto_canceled_by_id retried failure_reason].freeze shared_examples 'build duplication' do - let(:stage) do - # TODO, we still do not have factory for new stages, we will need to - # switch existing factory to persist stages, instead of using LegacyStage - # - Ci::Stage.create!(project: project, pipeline: pipeline, name: 'test') - end + let(:another_pipeline) { create(:ci_empty_pipeline, project: project) } let(:build) do create(:ci_build, :failed, :artifacts, :expired, :erased, :queued, :coverage, :tags, :allowed_to_fail, :on_tag, :triggered, :trace_artifact, :teardown_environment, - description: 'my-job', stage: 'test', pipeline: pipeline, - auto_canceled_by: create(:ci_empty_pipeline, project: project)) do |build| - ## - # TODO, workaround for FactoryBot limitation when having both - # stage (text) and stage_id (integer) columns in the table. - build.stage_id = stage.id - end + description: 'my-job', stage: 'test', stage_id: stage.id, + pipeline: pipeline, auto_canceled_by: another_pipeline) + end + + before do + # Make sure that build has both `stage_id` and `stage` because FactoryBot + # can reset one of the fields when assigning another. We plan to deprecate + # and remove legacy `stage` column in the future. + build.update_attributes(stage: 'test', stage_id: stage.id) end describe 'clone accessors' do CLONE_ACCESSORS.each do |attribute| it "clones #{attribute} build attribute" do + expect(build.send(attribute)).not_to be_nil expect(new_build.send(attribute)).not_to be_nil expect(new_build.send(attribute)).to eq build.send(attribute) end @@ -122,10 +124,12 @@ describe Ci::RetryBuildService do context 'when there are subsequent builds that are skipped' do let!(:subsequent_build) do - create(:ci_build, :skipped, stage_idx: 1, pipeline: pipeline) + create(:ci_build, :skipped, stage_idx: 2, + pipeline: pipeline, + stage: 'deploy') end - it 'resumes pipeline processing in subsequent stages' do + it 'resumes pipeline processing in a subsequent stage' do service.execute(build) expect(subsequent_build.reload).to be_created diff --git a/spec/services/groups/transfer_service_spec.rb b/spec/services/groups/transfer_service_spec.rb index bcc01b087f3..e1c873f8c1e 100644 --- a/spec/services/groups/transfer_service_spec.rb +++ b/spec/services/groups/transfer_service_spec.rb @@ -177,7 +177,7 @@ describe Groups::TransferService, :postgresql do it 'should add an error on group' do transfer_service.execute(new_parent_group) - expect(transfer_service.error).to eq('Transfer failed: Validation failed: Route path has already been taken, Route is invalid') + expect(transfer_service.error).to eq('Transfer failed: Validation failed: Path has already been taken') end end diff --git a/spec/services/lfs/lock_file_service_spec.rb b/spec/services/lfs/lock_file_service_spec.rb new file mode 100644 index 00000000000..3e58eea2501 --- /dev/null +++ b/spec/services/lfs/lock_file_service_spec.rb @@ -0,0 +1,62 @@ +require 'spec_helper' + +describe Lfs::LockFileService do + let(:project) { create(:project) } + let(:current_user) { create(:user) } + + subject { described_class.new(project, current_user, params) } + + describe '#execute' do + let(:params) { { path: 'README.md' } } + + context 'when not authorized' do + it "doesn't succeed" do + result = subject.execute + + expect(result[:status]).to eq(:error) + expect(result[:http_status]).to eq(403) + expect(result[:message]).to eq('You have no permissions') + end + end + + context 'when authorized' do + before do + project.add_developer(current_user) + end + + context 'with an existent lock' do + let!(:lock) { create(:lfs_file_lock, project: project) } + + it "doesn't succeed" do + expect(subject.execute[:status]).to eq(:error) + end + + it "doesn't create the Lock" do + expect do + subject.execute + end.not_to change { LfsFileLock.count } + end + end + + context 'without an existent lock' do + it "succeeds" do + expect(subject.execute[:status]).to eq(:success) + end + + it "creates the Lock" do + expect do + subject.execute + end.to change { LfsFileLock.count }.by(1) + end + end + + context 'when an error is raised' do + it "doesn't succeed" do + allow_any_instance_of(described_class).to receive(:create_lock!).and_raise(StandardError) + + expect(subject.execute[:status]).to eq(:error) + end + end + end + end +end diff --git a/spec/services/lfs/locks_finder_service_spec.rb b/spec/services/lfs/locks_finder_service_spec.rb new file mode 100644 index 00000000000..e409b77babf --- /dev/null +++ b/spec/services/lfs/locks_finder_service_spec.rb @@ -0,0 +1,101 @@ +require 'spec_helper' + +describe Lfs::LocksFinderService do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:params) { {} } + + subject { described_class.new(project, user, params) } + + shared_examples 'no results' do + it 'returns an empty list' do + result = subject.execute + + expect(result[:status]).to eq(:success) + expect(result[:locks]).to be_blank + end + end + + describe '#execute' do + let!(:lock_1) { create(:lfs_file_lock, project: project) } + let!(:lock_2) { create(:lfs_file_lock, project: project, path: 'README') } + + context 'find by id' do + context 'with results' do + let(:params) do + { id: lock_1.id } + end + + it 'returns the record' do + result = subject.execute + + expect(result[:status]).to eq(:success) + expect(result[:locks].size).to eq(1) + expect(result[:locks].first).to eq(lock_1) + end + end + + context 'without results' do + let(:params) do + { id: 123 } + end + + include_examples 'no results' + end + end + + context 'find by path' do + context 'with results' do + let(:params) do + { path: lock_1.path } + end + + it 'returns the record' do + result = subject.execute + + expect(result[:status]).to eq(:success) + expect(result[:locks].size).to eq(1) + expect(result[:locks].first).to eq(lock_1) + end + end + + context 'without results' do + let(:params) do + { path: 'not-found' } + end + + include_examples 'no results' + end + end + + context 'find all' do + context 'with results' do + it 'returns all the records' do + result = subject.execute + + expect(result[:status]).to eq(:success) + expect(result[:locks].size).to eq(2) + end + end + + context 'without results' do + before do + LfsFileLock.delete_all + end + + include_examples 'no results' + end + end + + context 'when an error is raised' do + it "doesn't succeed" do + allow_any_instance_of(described_class).to receive(:find_locks).and_raise(StandardError) + + result = subject.execute + + expect(result[:status]).to eq(:error) + expect(result[:locks]).to be_blank + end + end + end +end diff --git a/spec/services/lfs/unlock_file_service_spec.rb b/spec/services/lfs/unlock_file_service_spec.rb new file mode 100644 index 00000000000..4bea112b9c6 --- /dev/null +++ b/spec/services/lfs/unlock_file_service_spec.rb @@ -0,0 +1,105 @@ +require 'spec_helper' + +describe Lfs::UnlockFileService do + let(:project) { create(:project) } + let(:current_user) { create(:user) } + let(:lock_author) { create(:user) } + let!(:lock) { create(:lfs_file_lock, user: lock_author, project: project) } + let(:params) { {} } + + subject { described_class.new(project, current_user, params) } + + describe '#execute' do + context 'when not authorized' do + it "doesn't succeed" do + result = subject.execute + + expect(result[:status]).to eq(:error) + expect(result[:http_status]).to eq(403) + expect(result[:message]).to eq('You have no permissions') + end + end + + context 'when authorized' do + before do + project.add_developer(current_user) + end + + context 'when lock does not exists' do + let(:params) { { id: 123 } } + it "doesn't succeed" do + result = subject.execute + + expect(result[:status]).to eq(:error) + expect(result[:http_status]).to eq(404) + end + end + + context 'when unlocked by the author' do + let(:current_user) { lock_author } + let(:params) { { id: lock.id } } + + it "succeeds" do + result = subject.execute + + expect(result[:status]).to eq(:success) + expect(result[:lock]).to be_present + end + end + + context 'when unlocked by a different user' do + let(:current_user) { create(:user) } + let(:params) { { id: lock.id } } + + it "doesn't succeed" do + result = subject.execute + + expect(result[:status]).to eq(:error) + expect(result[:message]).to match(/is locked by GitLab User #{lock_author.id}/) + expect(result[:http_status]).to eq(403) + end + end + + context 'when forced' do + let(:developer) { create(:user) } + let(:master) { create(:user) } + + before do + project.add_developer(developer) + project.add_master(master) + end + + context 'by a regular user' do + let(:current_user) { developer } + let(:params) do + { id: lock.id, + force: true } + end + + it "doesn't succeed" do + result = subject.execute + + expect(result[:status]).to eq(:error) + expect(result[:message]).to match(/You must have master access/) + expect(result[:http_status]).to eq(403) + end + end + + context 'by a master user' do + let(:current_user) { master } + let(:params) do + { id: lock.id, + force: true } + end + + it "succeeds" do + result = subject.execute + + expect(result[:status]).to eq(:success) + expect(result[:lock]).to be_present + end + end + end + end + end +end diff --git a/spec/services/projects/gitlab_projects_import_service_spec.rb b/spec/services/projects/gitlab_projects_import_service_spec.rb index bb0e274c93e..6b8f9619bc4 100644 --- a/spec/services/projects/gitlab_projects_import_service_spec.rb +++ b/spec/services/projects/gitlab_projects_import_service_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Projects::GitlabProjectsImportService do - set(:namespace) { build(:namespace) } + set(:namespace) { create(:namespace) } let(:file) { fixture_file_upload(Rails.root + 'spec/fixtures/doc_sample.txt', 'text/plain') } subject { described_class.new(namespace.owner, { namespace_id: namespace.id, path: path, file: file }) } diff --git a/spec/services/users/update_service_spec.rb b/spec/services/users/update_service_spec.rb index f8d4a47b212..a4b7fe4674f 100644 --- a/spec/services/users/update_service_spec.rb +++ b/spec/services/users/update_service_spec.rb @@ -21,13 +21,13 @@ describe Users::UpdateService do end it 'includes namespace error messages' do - create(:group, name: 'taken', path: 'something_else') + create(:group, path: 'taken') result = {} expect do result = update_user(user, { username: 'taken' }) end.not_to change { user.reload.username } expect(result[:status]).to eq(:error) - expect(result[:message]).to eq('Namespace name has already been taken') + expect(result[:message]).to eq('Username has already been taken') end def update_user(user, opts) diff --git a/spec/support/features/variable_list_shared_examples.rb b/spec/support/features/variable_list_shared_examples.rb new file mode 100644 index 00000000000..83bf06b6727 --- /dev/null +++ b/spec/support/features/variable_list_shared_examples.rb @@ -0,0 +1,269 @@ +shared_examples 'variable list' do + it 'shows list of variables' do + page.within('.js-ci-variable-list-section') do + expect(first('.js-ci-variable-input-key').value).to eq(variable.key) + end + end + + it 'adds new secret variable' do + page.within('.js-ci-variable-list-section .js-row:last-child') do + find('.js-ci-variable-input-key').set('key') + find('.js-ci-variable-input-value').set('key value') + end + + click_button('Save variables') + wait_for_requests + + visit page_path + + # We check the first row because it re-sorts to alphabetical order on refresh + page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do + expect(find('.js-ci-variable-input-key').value).to eq('key') + expect(find('.js-ci-variable-input-value', visible: false).value).to eq('key value') + end + end + + it 'adds empty variable' do + page.within('.js-ci-variable-list-section .js-row:last-child') do + find('.js-ci-variable-input-key').set('key') + find('.js-ci-variable-input-value').set('') + end + + click_button('Save variables') + wait_for_requests + + visit page_path + + # We check the first row because it re-sorts to alphabetical order on refresh + page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do + expect(find('.js-ci-variable-input-key').value).to eq('key') + expect(find('.js-ci-variable-input-value', visible: false).value).to eq('') + end + end + + it 'adds new unprotected variable' do + page.within('.js-ci-variable-list-section .js-row:last-child') do + find('.js-ci-variable-input-key').set('key') + find('.js-ci-variable-input-value').set('key value') + find('.ci-variable-protected-item .js-project-feature-toggle').click + + expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('false') + end + + click_button('Save variables') + wait_for_requests + + visit page_path + + # We check the first row because it re-sorts to alphabetical order on refresh + page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do + expect(find('.js-ci-variable-input-key').value).to eq('key') + expect(find('.js-ci-variable-input-value', visible: false).value).to eq('key value') + expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('false') + end + end + + it 'reveals and hides variables' do + page.within('.js-ci-variable-list-section') do + expect(first('.js-ci-variable-input-key').value).to eq(variable.key) + expect(first('.js-ci-variable-input-value', visible: false).value).to eq(variable.value) + expect(page).to have_content('*' * 20) + + click_button('Reveal value') + + expect(first('.js-ci-variable-input-key').value).to eq(variable.key) + expect(first('.js-ci-variable-input-value').value).to eq(variable.value) + expect(page).not_to have_content('*' * 20) + + click_button('Hide value') + + expect(first('.js-ci-variable-input-key').value).to eq(variable.key) + expect(first('.js-ci-variable-input-value', visible: false).value).to eq(variable.value) + expect(page).to have_content('*' * 20) + end + end + + it 'deletes variable' do + page.within('.js-ci-variable-list-section') do + expect(page).to have_selector('.js-row', count: 2) + + first('.js-row-remove-button').click + + click_button('Save variables') + wait_for_requests + + expect(page).to have_selector('.js-row', count: 1) + end + end + + it 'edits variable' do + page.within('.js-ci-variable-list-section') do + click_button('Reveal value') + + page.within('.js-row:nth-child(1)') do + find('.js-ci-variable-input-key').set('new_key') + find('.js-ci-variable-input-value').set('new_value') + end + + click_button('Save variables') + wait_for_requests + + visit page_path + + page.within('.js-row:nth-child(1)') do + expect(find('.js-ci-variable-input-key').value).to eq('new_key') + expect(find('.js-ci-variable-input-value', visible: false).value).to eq('new_value') + end + end + end + + it 'edits variable with empty value' do + page.within('.js-ci-variable-list-section') do + click_button('Reveal value') + + page.within('.js-row:nth-child(1)') do + find('.js-ci-variable-input-key').set('new_key') + find('.js-ci-variable-input-value').set('') + end + + click_button('Save variables') + wait_for_requests + + visit page_path + + page.within('.js-row:nth-child(1)') do + expect(find('.js-ci-variable-input-key').value).to eq('new_key') + expect(find('.js-ci-variable-input-value', visible: false).value).to eq('') + end + end + end + + it 'edits variable to be protected' do + # Create the unprotected variable + page.within('.js-ci-variable-list-section .js-row:last-child') do + find('.js-ci-variable-input-key').set('unprotected_key') + find('.js-ci-variable-input-value').set('unprotected_value') + find('.ci-variable-protected-item .js-project-feature-toggle').click + + expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('false') + end + + click_button('Save variables') + wait_for_requests + + visit page_path + + # We check the first row because it re-sorts to alphabetical order on refresh + page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do + find('.ci-variable-protected-item .js-project-feature-toggle').click + + expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true') + end + + click_button('Save variables') + wait_for_requests + + visit page_path + + # We check the first row because it re-sorts to alphabetical order on refresh + page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do + expect(find('.js-ci-variable-input-key').value).to eq('unprotected_key') + expect(find('.js-ci-variable-input-value', visible: false).value).to eq('unprotected_value') + expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true') + end + end + + it 'edits variable to be unprotected' do + # Create the protected variable + page.within('.js-ci-variable-list-section .js-row:last-child') do + find('.js-ci-variable-input-key').set('protected_key') + find('.js-ci-variable-input-value').set('protected_value') + + expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true') + end + + click_button('Save variables') + wait_for_requests + + visit page_path + + page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do + find('.ci-variable-protected-item .js-project-feature-toggle').click + + expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('false') + end + + click_button('Save variables') + wait_for_requests + + visit page_path + + page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do + expect(find('.js-ci-variable-input-key').value).to eq('protected_key') + expect(find('.js-ci-variable-input-value', visible: false).value).to eq('protected_value') + expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('false') + end + end + + it 'handles multiple edits and deletion in the middle' do + page.within('.js-ci-variable-list-section') do + # Create 2 variables + page.within('.js-row:last-child') do + find('.js-ci-variable-input-key').set('akey') + find('.js-ci-variable-input-value').set('akeyvalue') + end + page.within('.js-row:last-child') do + find('.js-ci-variable-input-key').set('zkey') + find('.js-ci-variable-input-value').set('zkeyvalue') + end + + click_button('Save variables') + wait_for_requests + + expect(page).to have_selector('.js-row', count: 4) + + # Remove the `akey` variable + page.within('.js-row:nth-child(2)') do + first('.js-row-remove-button').click + end + + # Add another variable + page.within('.js-row:last-child') do + find('.js-ci-variable-input-key').set('ckey') + find('.js-ci-variable-input-value').set('ckeyvalue') + end + + click_button('Save variables') + wait_for_requests + + visit page_path + + # Expect to find 3 variables(4 rows) in alphbetical order + expect(page).to have_selector('.js-row', count: 4) + row_keys = all('.js-ci-variable-input-key') + expect(row_keys[0].value).to eq('ckey') + expect(row_keys[1].value).to eq('test_key') + expect(row_keys[2].value).to eq('zkey') + expect(row_keys[3].value).to eq('') + end + end + + it 'shows validation error box about duplicate keys' do + page.within('.js-ci-variable-list-section .js-row:last-child') do + find('.js-ci-variable-input-key').set('samekey') + find('.js-ci-variable-input-value').set('value1') + end + page.within('.js-ci-variable-list-section .js-row:last-child') do + find('.js-ci-variable-input-key').set('samekey') + find('.js-ci-variable-input-value').set('value2') + end + + click_button('Save variables') + wait_for_requests + + # 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 Duplicate variables: samekey') + end + end +end diff --git a/spec/support/matchers/pagination_matcher.rb b/spec/support/matchers/pagination_matcher.rb index 60f5e8239a7..9a7697e2bfc 100644 --- a/spec/support/matchers/pagination_matcher.rb +++ b/spec/support/matchers/pagination_matcher.rb @@ -3,3 +3,9 @@ RSpec::Matchers.define :include_pagination_headers do |expected| expect(actual.headers).to include('X-Total', 'X-Total-Pages', 'X-Per-Page', 'X-Page', 'X-Next-Page', 'X-Prev-Page', 'Link') end end + +RSpec::Matchers.define :include_limited_pagination_headers do |expected| + match do |actual| + expect(actual.headers).to include('X-Per-Page', 'X-Page', 'X-Next-Page', 'X-Prev-Page', 'Link') + end +end diff --git a/spec/support/migrations_helpers.rb b/spec/support/migrations_helpers.rb index 6522d74ba89..ba4a1bee089 100644 --- a/spec/support/migrations_helpers.rb +++ b/spec/support/migrations_helpers.rb @@ -15,18 +15,22 @@ module MigrationsHelpers ActiveRecord::Migrator.migrations(migrations_paths) end - def reset_column_in_migration_models + def clear_schema_cache! ActiveRecord::Base.connection_pool.connections.each do |conn| conn.schema_cache.clear! end + end - described_class.constants.sort.each do |name| - const = described_class.const_get(name) + def reset_column_in_all_models + clear_schema_cache! - if const.is_a?(Class) && const < ActiveRecord::Base - const.reset_column_information - end - end + # Reset column information for the most offending classes **after** we + # migrated the schema up, otherwise, column information could be outdated + ActiveRecord::Base.descendants.each { |klass| klass.reset_column_information } + + # Without that, we get errors because of missing attributes, e.g. + # super: no superclass method `elasticsearch_indexing' for #<ApplicationSetting:0x00007f85628508d8> + ApplicationSetting.define_attribute_methods end def previous_migration @@ -45,7 +49,7 @@ module MigrationsHelpers migration_schema_version) end - reset_column_in_migration_models + reset_column_in_all_models end def schema_migrate_up! @@ -53,7 +57,7 @@ module MigrationsHelpers ActiveRecord::Migrator.migrate(migrations_paths) end - reset_column_in_migration_models + reset_column_in_all_models end def disable_migrations_output diff --git a/spec/support/reactive_caching_helpers.rb b/spec/support/reactive_caching_helpers.rb index 34124f02133..e22dd974c6a 100644 --- a/spec/support/reactive_caching_helpers.rb +++ b/spec/support/reactive_caching_helpers.rb @@ -13,6 +13,12 @@ module ReactiveCachingHelpers write_reactive_cache(subject, data, *qualifiers) if data end + def synchronous_reactive_cache(subject) + allow(service).to receive(:with_reactive_cache) do |*args, &block| + block.call(service.calculate_reactive_cache(*args)) + end + end + def read_reactive_cache(subject, *qualifiers) Rails.cache.read(reactive_cache_key(subject, *qualifiers)) end diff --git a/spec/support/shared_examples/controllers/variables_shared_examples.rb b/spec/support/shared_examples/controllers/variables_shared_examples.rb new file mode 100644 index 00000000000..d7acf8c0032 --- /dev/null +++ b/spec/support/shared_examples/controllers/variables_shared_examples.rb @@ -0,0 +1,123 @@ +shared_examples 'GET #show lists all variables' do + it 'renders the variables as json' do + subject + + expect(response).to match_response_schema('variables') + end + + it 'has only one variable' do + subject + + expect(json_response['variables'].count).to eq(1) + end +end + +shared_examples 'PATCH #update updates variables' do + let(:variable_attributes) do + { id: variable.id, + key: variable.key, + value: variable.value, + protected: variable.protected?.to_s } + end + let(:new_variable_attributes) do + { key: 'new_key', + value: 'dummy_value', + protected: 'false' } + end + + context 'with invalid new variable parameters' do + let(:variables_attributes) do + [ + variable_attributes.merge(value: 'other_value'), + new_variable_attributes.merge(key: '...?') + ] + end + + it 'does not update the existing variable' do + expect { subject }.not_to change { variable.reload.value } + end + + it 'does not create the new variable' do + expect { subject }.not_to change { owner.variables.count } + end + + it 'returns a bad request response' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + + context 'with duplicate new variable parameters' do + let(:variables_attributes) do + [ + new_variable_attributes, + new_variable_attributes.merge(value: 'other_value') + ] + end + + it 'does not update the existing variable' do + expect { subject }.not_to change { variable.reload.value } + end + + it 'does not create the new variable' do + expect { subject }.not_to change { owner.variables.count } + end + + it 'returns a bad request response' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + + context 'with valid new variable parameters' do + let(:variables_attributes) do + [ + variable_attributes.merge(value: 'other_value'), + new_variable_attributes + ] + end + + it 'updates the existing variable' do + expect { subject }.to change { variable.reload.value }.to('other_value') + end + + it 'creates the new variable' do + expect { subject }.to change { owner.variables.count }.by(1) + end + + it 'returns a successful response' do + subject + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'has all variables in response' do + subject + + expect(response).to match_response_schema('variables') + end + end + + context 'with a deleted variable' do + let(:variables_attributes) { [variable_attributes.merge(_destroy: 'true')] } + + it 'destroys the variable' do + expect { subject }.to change { owner.variables.count }.by(-1) + expect { variable.reload }.to raise_error ActiveRecord::RecordNotFound + end + + it 'returns a successful response' do + subject + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'has all variables in response' do + subject + + expect(response).to match_response_schema('variables') + end + end +end diff --git a/spec/validators/user_path_validator_spec.rb b/spec/validators/user_path_validator_spec.rb deleted file mode 100644 index a46089cc24f..00000000000 --- a/spec/validators/user_path_validator_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -require 'spec_helper' - -describe UserPathValidator do - let(:validator) { described_class.new(attributes: [:username]) } - - describe '.valid_path?' do - it 'handles invalid utf8' do - expect(described_class.valid_path?("a\0weird\255path")).to be_falsey - end - end - - describe '#validates_each' do - it 'adds a message when the path is not in the correct format' do - user = build(:user) - - validator.validate_each(user, :username, "Path with spaces, and comma's!") - - expect(user.errors[:username]).to include(Gitlab::PathRegex.namespace_format_message) - end - - it 'adds a message when the path is reserved when creating' do - user = build(:user, username: 'help') - - validator.validate_each(user, :username, 'help') - - expect(user.errors[:username]).to include('help is a reserved name') - end - - it 'adds a message when the path is reserved when updating' do - user = create(:user) - user.username = 'help' - - validator.validate_each(user, :username, 'help') - - expect(user.errors[:username]).to include('help is a reserved name') - end - end -end diff --git a/vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml b/vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml index a7cd2bc972c..b2439d96e00 100644 --- a/vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml @@ -103,6 +103,7 @@ performance: artifacts: paths: - performance.json + - sitespeed-results/ only: refs: - branches @@ -503,16 +504,16 @@ production: export CI_ENVIRONMENT_URL=$(cat environment_url.txt) mkdir gitlab-exporter - wget -O gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/10-3/index.js + wget -O gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/10-5/index.js mkdir sitespeed-results if [ -f .gitlab-urls.txt ] then sed -i -e 's@^@'"$CI_ENVIRONMENT_URL"'@' .gitlab-urls.txt - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:6.0.3 --plugins.add ./gitlab-exporter --outputFolder sitespeed-results .gitlab-urls.txt + docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:6.3.1 --plugins.add ./gitlab-exporter --outputFolder sitespeed-results .gitlab-urls.txt else - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:6.0.3 --plugins.add ./gitlab-exporter --outputFolder sitespeed-results "$CI_ENVIRONMENT_URL" + docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:6.3.1 --plugins.add ./gitlab-exporter --outputFolder sitespeed-results "$CI_ENVIRONMENT_URL" fi mv sitespeed-results/data/performance.json performance.json |