diff options
194 files changed, 2209 insertions, 1342 deletions
diff --git a/.eslintrc b/.eslintrc index ad5eaebccae..8f9cdfb14ac 100644 --- a/.eslintrc +++ b/.eslintrc @@ -36,7 +36,7 @@ "import/no-commonjs": "error", "no-multiple-empty-lines": ["error", { "max": 1 }], "promise/catch-or-return": "error", - "no-underscore-dangle": ["error", { "allow": ["__"]}], + "no-underscore-dangle": ["error", { "allow": ["__", "_links"]}], "vue/html-self-closing": ["error", { "html": { "void": "always", diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ad603fdc75..c3bb93fbe3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 10.4.4 (2018-02-16) + +### Security (1 change) + +- Update nokogiri to 1.8.2. !16807 + +### Fixed (9 changes) + +- Fix 500 error when loading a merge request with an invalid comment. !16795 +- Cleanup new branch/merge request form in issues. !16854 +- Fix GitLab import leaving group_id on ProjectLabel. !16877 +- Fix forking projects when no restricted visibility levels are defined applicationwide. !16881 +- Resolve PrepareUntrackedUploads PostgreSQL syntax error. !17019 +- Fixed error 500 when removing an identity with synced attributes and visiting the profile page. !17054 +- Validate user namespace before saving so that errors persist on model. +- LDAP Person no longer throws exception on invalid entry. +- Fix JIRA not working when a trailing slash is included. + + ## 10.4.3 (2018-02-05) ### Security (4 changes) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ed56da0353d..dfe4bf65f9f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -174,7 +174,7 @@ Assigning a team label makes sure issues get the attention of the appropriate people. The current team labels are ~Build, ~"CI/CD", ~Discussion, ~Documentation, ~Edge, -~Geo, ~Gitaly, ~Platform, ~Monitoring, ~Release, and ~"UX". +~Geo, ~Gitaly, ~Monitoring, ~Platform, ~Release, ~"Security Products" and ~"UX". The descriptions on the [labels page][labels-page] explain what falls under the responsibility of each team. diff --git a/PROCESS.md b/PROCESS.md index 99af3be7f14..c24210341e0 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -71,11 +71,15 @@ star, smile, etc.). Some good tips about code reviews can be found in our ## Feature freeze on the 7th for the release on the 22nd -After the 7th (Pacific Standard Time Zone) of each month, RC1 of the upcoming release (to be shipped on the 22nd) is created and deployed to GitLab.com and the stable branch for this release is frozen, which means master is no longer merged into it. +After 7th at 23:59 (Pacific Standard Time Zone) of each month, RC1 of the upcoming release (to be shipped on the 22nd) is created and deployed to GitLab.com and the stable branch for this release is frozen, which means master is no longer merged into it. Merge requests may still be merged into master during this period, but they will go into the _next_ release, unless they are manually cherry-picked into the stable branch. + By freezing the stable branches 2 weeks prior to a release, we reduce the risk of a last minute merge request potentially breaking things. +Any release candidate that gets created after this date can become a final release, +hence the name release candidate. + ### Between the 1st and the 7th These types of merge requests for the upcoming release need special consideration: @@ -193,11 +197,10 @@ to be backported down to the `9.5` release, you will need to assign it the ### Asking for an exception If you think a merge request should go into an RC or patch even though it does not meet these requirements, -you can ask for an exception to be made. Exceptions require sign-off from 3 people besides the developer: +you can ask for an exception to be made. -1. a Release Manager -2. an Engineering Lead -3. an Engineering Director, the VP of Engineering, or the CTO +Go to [Release tasks issue tracker](https://gitlab.com/gitlab-org/release/tasks/issues/new) and create an issue +using the `Exception-request` issue template. You can find who is who on the [team page](https://about.gitlab.com/team/). diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 87109a802e5..3283ce5ec36 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -50,10 +50,8 @@ class AwardsHandler { this.registerEventListener('on', $('html'), 'click', (e) => { const $target = $(e.target); - if (!$target.closest('.emoji-menu-content').length) { - $('.js-awards-block.current').removeClass('current'); - } if (!$target.closest('.emoji-menu').length) { + $('.js-awards-block.current').removeClass('current'); if ($('.emoji-menu').is(':visible')) { $('.js-add-award.is-active').removeClass('is-active'); this.hideMenuElement($('.emoji-menu')); diff --git a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js index 76f93e5c6bd..b33adff609f 100644 --- a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js +++ b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js @@ -75,6 +75,7 @@ export default class AjaxVariableList { if (res.status === statusCodes.OK && res.data) { this.updateRowsWithPersistedVariables(res.data.variables); + this.variableList.hideValues(); } else if (res.status === statusCodes.BAD_REQUEST) { // Validation failed this.errorBox.innerHTML = generateErrorBoxContent(res.data); 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 3467e88119b..745f3404295 100644 --- a/app/assets/javascripts/ci_variable_list/ci_variable_list.js +++ b/app/assets/javascripts/ci_variable_list/ci_variable_list.js @@ -178,6 +178,10 @@ export default class VariableList { this.$container.find('.js-row-remove-button').attr('disabled', !isEnabled); } + hideValues() { + this.secretValues.updateDom(false); + } + getAllData() { // Ignore the last empty row because we don't want to try persist // a blank variable and run into validation problems. diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js index 4b2f75fffde..2be63bd8c76 100644 --- a/app/assets/javascripts/commits.js +++ b/app/assets/javascripts/commits.js @@ -1,52 +1,36 @@ -/* eslint-disable func-names, wrap-iife, consistent-return, - no-return-assign, no-param-reassign, one-var-declaration-per-line, no-unused-vars, - prefer-template, object-shorthand, prefer-arrow-callback */ - import { pluralize } from './lib/utils/text_utility'; import { localTimeAgo } from './lib/utils/datetime_utility'; import Pager from './pager'; import axios from './lib/utils/axios_utils'; -export default (function () { - const CommitsList = {}; - - CommitsList.timer = null; +export default class CommitsList { + constructor(limit = 0) { + this.timer = null; - CommitsList.init = function (limit) { this.$contentList = $('.content_list'); - $('body').on('click', '.day-commits-table li.commit', function (e) { - if (e.target.nodeName !== 'A') { - location.href = $(this).attr('url'); - e.stopPropagation(); - return false; - } - }); - - Pager.init(parseInt(limit, 10), false, false, this.processCommits); + Pager.init(parseInt(limit, 10), false, false, this.processCommits.bind(this)); this.content = $('#commits-list'); this.searchField = $('#commits-search'); this.lastSearch = this.searchField.val(); - return this.initSearch(); - }; + this.initSearch(); + } - CommitsList.initSearch = function () { + initSearch() { this.timer = null; - return this.searchField.keyup((function (_this) { - return function () { - clearTimeout(_this.timer); - return _this.timer = setTimeout(_this.filterResults, 500); - }; - })(this)); - }; + this.searchField.on('keyup', () => { + clearTimeout(this.timer); + this.timer = setTimeout(this.filterResults.bind(this), 500); + }); + } - CommitsList.filterResults = function () { + filterResults() { const form = $('.commits-search-form'); - const search = CommitsList.searchField.val(); - if (search === CommitsList.lastSearch) return Promise.resolve(); - const commitsUrl = form.attr('action') + '?' + form.serialize(); - CommitsList.content.fadeTo('fast', 0.5); + const search = this.searchField.val(); + if (search === this.lastSearch) return Promise.resolve(); + const commitsUrl = `${form.attr('action')}?${form.serialize()}`; + this.content.fadeTo('fast', 0.5); const params = form.serializeArray().reduce((acc, obj) => Object.assign(acc, { [obj.name]: obj.value, }), {}); @@ -55,9 +39,9 @@ export default (function () { params, }) .then(({ data }) => { - CommitsList.lastSearch = search; - CommitsList.content.html(data.html); - CommitsList.content.fadeTo('fast', 1.0); + this.lastSearch = search; + this.content.html(data.html); + this.content.fadeTo('fast', 1.0); // Change url so if user reload a page - search results are saved history.replaceState({ @@ -65,16 +49,16 @@ export default (function () { }, document.title, commitsUrl); }) .catch(() => { - CommitsList.content.fadeTo('fast', 1.0); - CommitsList.lastSearch = null; + this.content.fadeTo('fast', 1.0); + this.lastSearch = null; }); - }; + } // Prepare loaded data. - CommitsList.processCommits = (data) => { + processCommits(data) { let processedData = data; const $processedData = $(processedData); - const $commitsHeadersLast = CommitsList.$contentList.find('li.js-commit-header').last(); + const $commitsHeadersLast = this.$contentList.find('li.js-commit-header').last(); const lastShownDay = $commitsHeadersLast.data('day'); const $loadedCommitsHeadersFirst = $processedData.filter('li.js-commit-header').first(); const loadedShownDayFirst = $loadedCommitsHeadersFirst.data('day'); @@ -97,7 +81,5 @@ export default (function () { localTimeAgo($processedData.find('.js-timeago')); return processedData; - }; - - return CommitsList; -})(); + } +} diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index f8082c74943..8f708dde063 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -224,6 +224,11 @@ var Dispatcher; .then(callDefault) .catch(fail); break; + case 'projects:services:edit': + import('./pages/projects/services/edit') + .then(callDefault) + .catch(fail); + break; case 'projects:snippets:edit': case 'projects:snippets:update': import('./pages/projects/snippets/edit') @@ -462,11 +467,6 @@ var Dispatcher; .then(callDefault) .catch(fail); break; - case 'users:show': - import('./pages/users/show') - .then(callDefault) - .catch(fail); - break; case 'admin:conversational_development_index:show': import('./pages/admin/conversational_development_index/show') .then(callDefault) diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js index 134a503864e..35094f8e73b 100644 --- a/app/assets/javascripts/importer_status.js +++ b/app/assets/javascripts/importer_status.js @@ -59,29 +59,36 @@ class ImporterStatus { .catch(() => flash(__('An error occurred while importing project'))); } - setAutoUpdate() { - return setInterval(() => $.get(this.jobsUrl, data => $.each(data, (i, job) => { - const jobItem = $(`#project_${job.id}`); - const statusField = jobItem.find('.job-status'); + autoUpdate() { + return axios.get(this.jobsUrl) + .then(({ data = [] }) => { + data.forEach((job) => { + const jobItem = $(`#project_${job.id}`); + const statusField = jobItem.find('.job-status'); + + const spinner = '<i class="fa fa-spinner fa-spin"></i>'; - const spinner = '<i class="fa fa-spinner fa-spin"></i>'; + switch (job.import_status) { + case 'finished': + jobItem.removeClass('active').addClass('success'); + statusField.html('<span><i class="fa fa-check"></i> done</span>'); + break; + case 'scheduled': + statusField.html(`${spinner} scheduled`); + break; + case 'started': + statusField.html(`${spinner} started`); + break; + default: + statusField.html(job.import_status); + break; + } + }); + }); + } - switch (job.import_status) { - case 'finished': - jobItem.removeClass('active').addClass('success'); - statusField.html('<span><i class="fa fa-check"></i> done</span>'); - break; - case 'scheduled': - statusField.html(`${spinner} scheduled`); - break; - case 'started': - statusField.html(`${spinner} started`); - break; - default: - statusField.html(job.import_status); - break; - } - })), 4000); + setAutoUpdate() { + setInterval(this.autoUpdate.bind(this), 4000); } } diff --git a/app/assets/javascripts/integrations/index.js b/app/assets/javascripts/integrations/index.js deleted file mode 100644 index 10fe6bac0e8..00000000000 --- a/app/assets/javascripts/integrations/index.js +++ /dev/null @@ -1,7 +0,0 @@ -/* eslint-disable no-new */ -import IntegrationSettingsForm from './integration_settings_form'; - -$(() => { - const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); - integrationSettingsForm.init(); -}); diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue index 555725cbe12..ba1d8e4d8db 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue +++ b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue @@ -1,13 +1,13 @@ <script> import axios from '~/lib/utils/axios_utils'; - import Flash from '~/flash'; - import modal from '~/vue_shared/components/modal.vue'; - import { s__ } from '~/locale'; + import createFlash from '~/flash'; + import GlModal from '~/vue_shared/components/gl_modal.vue'; import { redirectTo } from '~/lib/utils/url_utility'; + import { s__ } from '~/locale'; export default { components: { - modal, + GlModal, }, props: { url: { @@ -17,7 +17,7 @@ }, computed: { text() { - return s__('AdminArea|You’re about to stop all jobs. This will halt all current jobs that are running.'); + return s__('AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running.'); }, }, methods: { @@ -28,7 +28,7 @@ redirectTo(response.request.responseURL); }) .catch((error) => { - Flash(s__('AdminArea|Stopping jobs failed')); + createFlash(s__('AdminArea|Stopping jobs failed')); throw error; }); }, @@ -37,11 +37,13 @@ </script> <template> - <modal + <gl-modal id="stop-jobs-modal" - :title="s__('AdminArea|Stop all jobs?')" - :text="text" - kind="danger" - :primary-button-label="s__('AdminArea|Stop jobs')" - @submit="onSubmit" /> + :header-title-text="s__('AdminArea|Stop all jobs?')" + footer-primary-button-variant="danger" + :footer-primary-button-text="s__('AdminArea|Stop jobs')" + @submit="onSubmit" + > + {{ text }} + </gl-modal> </template> diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js index 0e004bd9174..31d58eabaaf 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/index.js +++ b/app/assets/javascripts/pages/admin/jobs/index/index.js @@ -8,22 +8,23 @@ Vue.use(Translate); export default () => { const stopJobsButton = document.getElementById('stop-jobs-button'); - - // eslint-disable-next-line no-new - new Vue({ - el: '#stop-jobs-modal', - components: { - stopJobsModal, - }, - mounted() { - stopJobsButton.classList.remove('disabled'); - }, - render(createElement) { - return createElement('stop-jobs-modal', { - props: { - url: stopJobsButton.dataset.url, - }, - }); - }, - }); + if (stopJobsButton) { + // eslint-disable-next-line no-new + new Vue({ + el: '#stop-jobs-modal', + components: { + stopJobsModal, + }, + mounted() { + stopJobsButton.classList.remove('disabled'); + }, + render(createElement) { + return createElement('stop-jobs-modal', { + props: { + url: stopJobsButton.dataset.url, + }, + }); + }, + }); + } }; diff --git a/app/assets/javascripts/pages/projects/commits/show/index.js b/app/assets/javascripts/pages/projects/commits/show/index.js index 90b5882a24f..6110fda17de 100644 --- a/app/assets/javascripts/pages/projects/commits/show/index.js +++ b/app/assets/javascripts/pages/projects/commits/show/index.js @@ -3,7 +3,7 @@ import GpgBadges from '~/gpg_badges'; import ShortcutsNavigation from '~/shortcuts_navigation'; export default () => { - CommitsList.init(document.querySelector('.js-project-commits-show').dataset.commitsLimit); + new CommitsList(document.querySelector('.js-project-commits-show').dataset.commitsLimit); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new GpgBadges.fetch(); }; diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js new file mode 100644 index 00000000000..d65be6bc69e --- /dev/null +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js @@ -0,0 +1,3 @@ +import initForm from '../shared/init_form'; + +document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js new file mode 100644 index 00000000000..d65be6bc69e --- /dev/null +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js @@ -0,0 +1,3 @@ +import initForm from '../shared/init_form'; + +document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js index a6c945e22b0..544360dcd51 100644 --- a/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import PipelineSchedulesCallout from './components/pipeline_schedules_callout.vue'; +import PipelineSchedulesCallout from '../shared/components/pipeline_schedules_callout.vue'; document.addEventListener('DOMContentLoaded', () => new Vue({ el: '#pipeline-schedules-callout', diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js new file mode 100644 index 00000000000..d65be6bc69e --- /dev/null +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js @@ -0,0 +1,3 @@ +import initForm from '../shared/init_form'; + +document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue index 2d18fa2044b..2d18fa2044b 100644 --- a/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue diff --git a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue index aa04a0ac47a..77508e62cef 100644 --- a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue @@ -1,7 +1,7 @@ <script> import Vue from 'vue'; import Cookies from 'js-cookie'; - import Translate from '../../vue_shared/translate'; + import Translate from '../../../../../vue_shared/translate'; import illustrationSvg from '../icons/intro_illustration.svg'; Vue.use(Translate); diff --git a/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js index 0c3926d76b5..0c3926d76b5 100644 --- a/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js diff --git a/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js index 95ed9c7dc21..95ed9c7dc21 100644 --- a/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js diff --git a/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg index 26d1ff97b3e..26d1ff97b3e 100644 --- a/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js index 0b1a81bae13..cfd30d6053f 100644 --- a/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js @@ -1,10 +1,10 @@ import Vue from 'vue'; -import Translate from '../vue_shared/translate'; -import GlFieldErrors from '../gl_field_errors'; +import Translate from '../../../../vue_shared/translate'; +import GlFieldErrors from '../../../../gl_field_errors'; import intervalPatternInput from './components/interval_pattern_input.vue'; import TimezoneDropdown from './components/timezone_dropdown'; import TargetBranchDropdown from './components/target_branch_dropdown'; -import setupNativeFormVariableList from '../ci_variable_list/native_form_variable_list'; +import setupNativeFormVariableList from '../../../../ci_variable_list/native_form_variable_list'; Vue.use(Translate); @@ -27,7 +27,7 @@ function initIntervalPatternInput() { }); } -document.addEventListener('DOMContentLoaded', () => { +export default () => { /* Most of the form is written in haml, but for fields with more complex behaviors, * you should mount individual Vue components here. If at some point components need * to share state, it may make sense to refactor the whole form to Vue */ @@ -46,4 +46,4 @@ document.addEventListener('DOMContentLoaded', () => { container: $('.js-ci-variable-list-section'), formField: 'schedule', }); -}); +}; diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js new file mode 100644 index 00000000000..d65be6bc69e --- /dev/null +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js @@ -0,0 +1,3 @@ +import initForm from '../shared/init_form'; + +document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pages/projects/pipelines/charts/index.js b/app/assets/javascripts/pages/projects/pipelines/charts/index.js new file mode 100644 index 00000000000..c1dafda0e24 --- /dev/null +++ b/app/assets/javascripts/pages/projects/pipelines/charts/index.js @@ -0,0 +1,56 @@ +import Chart from 'vendor/Chart'; + +const options = { + scaleOverlay: true, + responsive: true, + maintainAspectRatio: false, +}; + +const buildChart = (chartScope) => { + const data = { + labels: chartScope.labels, + datasets: [{ + fillColor: '#707070', + strokeColor: '#707070', + pointColor: '#707070', + pointStrokeColor: '#EEE', + data: chartScope.totalValues, + }, + { + fillColor: '#1aaa55', + strokeColor: '#1aaa55', + pointColor: '#1aaa55', + pointStrokeColor: '#fff', + data: chartScope.successValues, + }, + ], + }; + const ctx = $(`#${chartScope.scope}Chart`).get(0).getContext('2d'); + + new Chart(ctx).Line(data, options); +}; + +document.addEventListener('DOMContentLoaded', () => { + const chartTimesData = JSON.parse(document.getElementById('pipelinesTimesChartsData').innerHTML); + const chartsData = JSON.parse(document.getElementById('pipelinesChartsData').innerHTML); + const data = { + labels: chartTimesData.labels, + datasets: [{ + fillColor: 'rgba(220,220,220,0.5)', + strokeColor: 'rgba(220,220,220,1)', + barStrokeWidth: 1, + barValueSpacing: 1, + barDatasetSpacing: 1, + data: chartTimesData.values, + }], + }; + + if (window.innerWidth < 768) { + // Scale fonts if window width lower than 768px (iPad portrait) + options.scaleFontSize = 8; + } + + new Chart($('#build_timesChart').get(0).getContext('2d')).Bar(data, options); + + chartsData.forEach(scope => buildChart(scope)); +}); diff --git a/app/assets/javascripts/pages/projects/services/edit/index.js b/app/assets/javascripts/pages/projects/services/edit/index.js new file mode 100644 index 00000000000..5c73171e62e --- /dev/null +++ b/app/assets/javascripts/pages/projects/services/edit/index.js @@ -0,0 +1,13 @@ +import IntegrationSettingsForm from '~/integrations/integration_settings_form'; +import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics'; + +export default () => { + const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring'); + const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + integrationSettingsForm.init(); + + if (prometheusSettingsWrapper) { + const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring'); + prometheusMetrics.loadActiveMetrics(); + } +}; diff --git a/app/assets/javascripts/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 57306322aa4..57306322aa4 100644 --- a/app/assets/javascripts/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js diff --git a/app/assets/javascripts/users/index.js b/app/assets/javascripts/pages/users/index.js index 9fd8452a2b6..899dcd42e37 100644 --- a/app/assets/javascripts/users/index.js +++ b/app/assets/javascripts/pages/users/index.js @@ -1,3 +1,4 @@ +import UserCallout from '~/user_callout'; import Cookies from 'js-cookie'; import UserTabs from './user_tabs'; @@ -22,4 +23,5 @@ document.addEventListener('DOMContentLoaded', () => { const page = $('body').attr('data-page'); const action = page.split(':')[1]; initUserProfile(action); + new UserCallout(); // eslint-disable-line no-new }); diff --git a/app/assets/javascripts/pages/users/show/index.js b/app/assets/javascripts/pages/users/show/index.js deleted file mode 100644 index f18f98b4e9a..00000000000 --- a/app/assets/javascripts/pages/users/show/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import UserCallout from '~/user_callout'; - -export default () => new UserCallout(); diff --git a/app/assets/javascripts/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index e13b9839a20..c1217623467 100644 --- a/app/assets/javascripts/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -1,9 +1,9 @@ -import axios from '../lib/utils/axios_utils'; -import Activities from '../activities'; +import axios from '~/lib/utils/axios_utils'; +import Activities from '~/activities'; +import { localTimeAgo } from '~/lib/utils/datetime_utility'; +import { __ } from '~/locale'; +import flash from '~/flash'; import ActivityCalendar from './activity_calendar'; -import { localTimeAgo } from '../lib/utils/datetime_utility'; -import { __ } from '../locale'; -import flash from '../flash'; /** * UserTabs diff --git a/app/assets/javascripts/pipelines/pipelines_charts.js b/app/assets/javascripts/pipelines/pipelines_charts.js deleted file mode 100644 index 821aa7e229f..00000000000 --- a/app/assets/javascripts/pipelines/pipelines_charts.js +++ /dev/null @@ -1,38 +0,0 @@ -import Chart from 'vendor/Chart'; - -document.addEventListener('DOMContentLoaded', () => { - const chartData = JSON.parse(document.getElementById('pipelinesChartsData').innerHTML); - const buildChart = (chartScope) => { - const data = { - labels: chartScope.labels, - datasets: [{ - fillColor: '#707070', - strokeColor: '#707070', - pointColor: '#707070', - pointStrokeColor: '#EEE', - data: chartScope.totalValues, - }, - { - fillColor: '#1aaa55', - strokeColor: '#1aaa55', - pointColor: '#1aaa55', - pointStrokeColor: '#fff', - data: chartScope.successValues, - }, - ], - }; - const ctx = $(`#${chartScope.scope}Chart`).get(0).getContext('2d'); - const options = { - scaleOverlay: true, - responsive: true, - maintainAspectRatio: false, - }; - if (window.innerWidth < 768) { - // Scale fonts if window width lower than 768px (iPad portrait) - options.scaleFontSize = 8; - } - new Chart(ctx).Line(data, options); - }; - - chartData.forEach(scope => buildChart(scope)); -}); diff --git a/app/assets/javascripts/pipelines/pipelines_times.js b/app/assets/javascripts/pipelines/pipelines_times.js deleted file mode 100644 index b5e7a0e53d9..00000000000 --- a/app/assets/javascripts/pipelines/pipelines_times.js +++ /dev/null @@ -1,27 +0,0 @@ -import Chart from 'vendor/Chart'; - -document.addEventListener('DOMContentLoaded', () => { - const chartData = JSON.parse(document.getElementById('pipelinesTimesChartsData').innerHTML); - const data = { - labels: chartData.labels, - datasets: [{ - fillColor: 'rgba(220,220,220,0.5)', - strokeColor: 'rgba(220,220,220,1)', - barStrokeWidth: 1, - barValueSpacing: 1, - barDatasetSpacing: 1, - data: chartData.values, - }], - }; - const ctx = $('#build_timesChart').get(0).getContext('2d'); - const options = { - scaleOverlay: true, - responsive: true, - maintainAspectRatio: false, - }; - if (window.innerWidth < 768) { - // Scale fonts if window width lower than 768px (iPad portrait) - options.scaleFontSize = 8; - } - new Chart(ctx).Bar(data, options); -}); diff --git a/app/assets/javascripts/prometheus_metrics/index.js b/app/assets/javascripts/prometheus_metrics/index.js deleted file mode 100644 index a0c43c5abe1..00000000000 --- a/app/assets/javascripts/prometheus_metrics/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import PrometheusMetrics from './prometheus_metrics'; - -$(() => { - const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring'); - prometheusMetrics.loadActiveMetrics(); -}); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js deleted file mode 100644 index cea3d97fa88..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js +++ /dev/null @@ -1,19 +0,0 @@ -import statusIcon from '../mr_widget_status_icon.vue'; - -export default { - name: 'MRWidgetNotAllowed', - components: { - statusIcon, - }, - template: ` - <div class="mr-widget-body media"> - <status-icon status="success" :show-disabled-button="true" /> - <div class="media-body space-children"> - <span class="bold"> - Ready to be merged automatically. - Ask someone with write access to this repository to merge this request - </span> - </div> - </div> - `, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue new file mode 100644 index 00000000000..e4af50b09f8 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue @@ -0,0 +1,25 @@ +<script> + import StatusIcon from '../mr_widget_status_icon.vue'; + + export default { + name: 'MRWidgetNotAllowed', + components: { + StatusIcon, + }, + }; +</script> + +<template> + <div class="mr-widget-body media"> + <status-icon + status="success" + :show-disabled-button="true" + /> + <div class="media-body space-children"> + <span class="bold"> + {{ s__(`mrWidget|Ready to be merged automatically. +Ask someone with write access to this repository to merge this request`) }} + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js deleted file mode 100644 index e66ce071ab4..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js +++ /dev/null @@ -1,18 +0,0 @@ -import statusIcon from '../mr_widget_status_icon.vue'; - -export default { - name: 'MRWidgetPipelineBlocked', - components: { - statusIcon, - }, - template: ` - <div class="mr-widget-body media"> - <status-icon status="warning" :show-disabled-button="true" /> - <div class="media-body space-children"> - <span class="bold"> - Pipeline blocked. The pipeline for this merge request requires a manual action to proceed - </span> - </div> - </div> - `, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue new file mode 100644 index 00000000000..6d7cc03f7ad --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue @@ -0,0 +1,24 @@ +<script> + import StatusIcon from '../mr_widget_status_icon.vue'; + + export default { + name: 'MRWidgetPipelineBlocked', + components: { + StatusIcon, + }, + }; +</script> +<template> + <div class="mr-widget-body media"> + <status-icon + status="warning" + :show-disabled-button="true" + /> + <div class="media-body space-children"> + <span class="bold"> + {{ s__(`mrWidget|Pipeline blocked. +The pipeline for this merge request requires a manual action to proceed`) }} + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index 5517888c3b1..edb3baa39e4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -25,11 +25,11 @@ export { default as ArchivedState } from './components/states/mr_widget_archived export { default as ConflictsState } from './components/states/mr_widget_conflicts.vue'; export { default as NothingToMergeState } from './components/states/mr_widget_nothing_to_merge'; export { default as MissingBranchState } from './components/states/mr_widget_missing_branch.vue'; -export { default as NotAllowedState } from './components/states/mr_widget_not_allowed'; +export { default as NotAllowedState } from './components/states/mr_widget_not_allowed.vue'; export { default as ReadyToMergeState } from './components/states/mr_widget_ready_to_merge'; export { default as SHAMismatchState } from './components/states/mr_widget_sha_mismatch'; export { default as UnresolvedDiscussionsState } from './components/states/mr_widget_unresolved_discussions'; -export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked'; +export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked.vue'; export { default as PipelineFailedState } from './components/states/mr_widget_pipeline_failed'; export { default as MergeWhenPipelineSucceedsState } from './components/states/mr_widget_merge_when_pipeline_succeeds.vue'; export { default as RebaseState } from './components/states/mr_widget_rebase.vue'; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js index edf67fcd0a7..d8f0442ef9d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js @@ -152,6 +152,7 @@ export default { }, handleNotification(data) { if (data.ci_status === this.mr.ciStatus) return; + if (!data.pipeline) return; const label = data.pipeline.details.status.label; const title = `Pipeline ${label}`; diff --git a/app/assets/javascripts/vue_shared/components/gl_modal.vue b/app/assets/javascripts/vue_shared/components/gl_modal.vue new file mode 100644 index 00000000000..67c9181c7b1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/gl_modal.vue @@ -0,0 +1,106 @@ +<script> + const buttonVariants = [ + 'danger', + 'primary', + 'success', + 'warning', + ]; + + export default { + name: 'GlModal', + + props: { + id: { + type: String, + required: false, + default: null, + }, + headerTitleText: { + type: String, + required: false, + default: '', + }, + footerPrimaryButtonVariant: { + type: String, + required: false, + default: 'primary', + validator: value => buttonVariants.indexOf(value) !== -1, + }, + footerPrimaryButtonText: { + type: String, + required: false, + default: '', + }, + }, + + methods: { + emitCancel(event) { + this.$emit('cancel', event); + }, + emitSubmit(event) { + this.$emit('submit', event); + }, + }, + }; +</script> + +<template> + <div + :id="id" + class="modal fade" + tabindex="-1" + role="dialog" + > + <div + class="modal-dialog" + role="document" + > + <div class="modal-content"> + <div class="modal-header"> + <slot name="header"> + <button + type="button" + class="close" + data-dismiss="modal" + :aria-label="s__('Modal|Close')" + @click="emitCancel($event)" + > + <span aria-hidden="true">×</span> + </button> + <h4 class="modal-title"> + <slot name="title"> + {{ headerTitleText }} + </slot> + </h4> + </slot> + </div> + + <div class="modal-body"> + <slot></slot> + </div> + + <div class="modal-footer"> + <slot name="footer"> + <button + type="button" + class="btn" + data-dismiss="modal" + @click="emitCancel($event)" + > + {{ s__('Modal|Cancel') }} + </button> + <button + type="button" + class="btn" + :class="`btn-${footerPrimaryButtonVariant}`" + data-dismiss="modal" + @click="emitSubmit($event)" + > + {{ footerPrimaryButtonText }} + </button> + </slot> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index dcd98cb522f..7e829826eba 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -255,8 +255,6 @@ ul.controls { } .author_link { - display: inline-block; - .avatar-inline { margin-left: 0; margin-right: 0; diff --git a/app/controllers/projects/clusters/gcp_controller.rb b/app/controllers/projects/clusters/gcp_controller.rb index 94d33b91562..0f41af7d87b 100644 --- a/app/controllers/projects/clusters/gcp_controller.rb +++ b/app/controllers/projects/clusters/gcp_controller.rb @@ -39,12 +39,12 @@ class Projects::Clusters::GcpController < Projects::ApplicationController def verify_billing case google_project_billing_status - when 'true' - return - when 'false' - flash[:alert] = _('Please <a href=%{link_to_billing} target="_blank" rel="noopener noreferrer">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again.').html_safe % { link_to_billing: "https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral" } - else + when nil flash[:alert] = _('We could not verify that one of your projects on GCP has billing enabled. Please try again.') + when false + flash[:alert] = _('Please <a href=%{link_to_billing} target="_blank" rel="noopener noreferrer">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again.').html_safe % { link_to_billing: "https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral" } + when true + return end @cluster = ::Clusters::Cluster.new(create_params) @@ -81,9 +81,7 @@ class Projects::Clusters::GcpController < Projects::ApplicationController end def google_project_billing_status - Gitlab::Redis::SharedState.with do |redis| - redis.get(CheckGcpProjectBillingWorker.redis_shared_state_key_for(token_in_session)) - end + CheckGcpProjectBillingWorker.get_billing_state(token_in_session) end def token_in_session diff --git a/app/models/external_issue.rb b/app/models/external_issue.rb index 2aaba2e4c90..282fd7edcb7 100644 --- a/app/models/external_issue.rb +++ b/app/models/external_issue.rb @@ -39,7 +39,7 @@ class ExternalIssue end def to_reference(_from = nil, full: nil) - id + reference_link_text end def reference_link_text(from = nil) diff --git a/app/models/identity.rb b/app/models/identity.rb index b3fa7d8176a..2b433e9b988 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -9,6 +9,7 @@ class Identity < ActiveRecord::Base validates :user_id, uniqueness: { scope: :provider } before_save :ensure_normalized_extern_uid, if: :extern_uid_changed? + after_destroy :clear_user_synced_attributes, if: :user_synced_attributes_metadata_from_provider? scope :with_provider, ->(provider) { where(provider: provider) } scope :with_extern_uid, ->(provider, extern_uid) do @@ -34,4 +35,12 @@ class Identity < ActiveRecord::Base self.extern_uid = Identity.normalize_uid(self.provider, self.extern_uid) end + + def user_synced_attributes_metadata_from_provider? + user.user_synced_attributes_metadata&.provider == provider + end + + def clear_user_synced_attributes + user.user_synced_attributes_metadata&.destroy + end end diff --git a/app/models/project.rb b/app/models/project.rb index 3893b1818f3..2ba6a863500 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -261,7 +261,7 @@ class Project < ActiveRecord::Base validates :repository_storage, presence: true, inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } } - validates :variables, variable_duplicates: true + validates :variables, variable_duplicates: { scope: :environment_scope } has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent diff --git a/app/models/repository.rb b/app/models/repository.rb index 1cf55fd4332..4f754b11da4 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -593,7 +593,15 @@ class Repository def license_key return unless exists? - Licensee.license(path).try(:key) + # The licensee gem creates a Rugged object from the path: + # https://github.com/benbalter/licensee/blob/v8.7.0/lib/licensee/projects/git_project.rb + begin + Licensee.license(path).try(:key) + # Normally we would rescue Rugged::Error, but that is banned by lint-rugged + # and we need to migrate this endpoint to Gitaly: + # https://gitlab.com/gitlab-org/gitaly/issues/1026 + rescue + end end cache_method :license_key diff --git a/app/models/user.rb b/app/models/user.rb index 4097fe2b5dc..5e84d2da805 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -249,7 +249,7 @@ class User < ActiveRecord::Base def find_for_database_authentication(warden_conditions) conditions = warden_conditions.dup if login = conditions.delete(:login) - where(conditions).find_by("lower(username) = :value OR lower(email) = :value", value: login.downcase) + where(conditions).find_by("lower(username) = :value OR lower(email) = :value", value: login.downcase.strip) else find_by(conditions) end diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 2f511ab44b7..299b9c6215f 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -19,19 +19,10 @@ module Issues # on rewriting notes (unfolding references) # ActiveRecord::Base.transaction do - # New issue tasks - # @new_issue = create_new_issue - rewrite_notes - rewrite_issue_award_emoji - add_note_moved_from - - # Old issue tasks - # - add_note_moved_to - close_issue - mark_as_moved + update_new_issue + update_old_issue end notify_participants @@ -41,6 +32,18 @@ module Issues private + def update_new_issue + rewrite_notes + rewrite_issue_award_emoji + add_note_moved_from + end + + def update_old_issue + add_note_moved_to + close_issue + mark_as_moved + end + def create_new_issue new_params = { id: nil, iid: nil, label_ids: cloneable_label_ids, milestone_id: cloneable_milestone_id, diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index ab6f8ea44a9..4b186d93772 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -134,7 +134,7 @@ module MergeRequests end def append_closes_description - return unless issue + return unless issue&.to_reference.present? closes_issue = "Closes #{issue.to_reference}" @@ -163,7 +163,7 @@ module MergeRequests return if merge_request.title.present? if issue_iid.present? - merge_request.title = "Resolve #{issue_iid}" + merge_request.title = "Resolve #{issue.to_reference}" branch_title = source_branch.downcase.remove(issue_iid.downcase).titleize.humanize merge_request.title += " \"#{branch_title}\"" if branch_title.present? end diff --git a/app/validators/variable_duplicates_validator.rb b/app/validators/variable_duplicates_validator.rb index 8a9d8892e9b..4bfa3c45303 100644 --- a/app/validators/variable_duplicates_validator.rb +++ b/app/validators/variable_duplicates_validator.rb @@ -1,13 +1,28 @@ # VariableDuplicatesValidator # -# This validtor is designed for especially the following condition +# This validator is designed for especially the following condition # - Use `accepts_nested_attributes_for :xxx` in a parent model # - Use `validates :xxx, uniqueness: { scope: :xxx_id }` in a child model class VariableDuplicatesValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) - duplicates = value.reject(&:marked_for_destruction?).group_by(&:key).select { |_, v| v.many? }.map(&:first) + if options[:scope] + scoped = value.group_by do |variable| + Array(options[:scope]).map { |attr| variable.send(attr) } # rubocop:disable GitlabSecurity/PublicSend + end + scoped.each_value { |scope| validate_duplicates(record, attribute, scope) } + else + validate_duplicates(record, attribute, value) + end + end + + private + + def validate_duplicates(record, attribute, values) + duplicates = values.reject(&:marked_for_destruction?).group_by(&:key).select { |_, v| v.many? }.map(&:first) if duplicates.any? - record.errors.add(attribute, "Duplicate variables: #{duplicates.join(", ")}") + error_message = "have duplicate values (#{duplicates.join(", ")})" + error_message += " for #{values.first.send(options[:scope])} scope" if options[:scope] # rubocop:disable GitlabSecurity/PublicSend + record.errors.add(attribute, error_message) end end end diff --git a/app/views/admin/jobs/index.html.haml b/app/views/admin/jobs/index.html.haml index a01676d82a8..4e3e2f7a475 100644 --- a/app/views/admin/jobs/index.html.haml +++ b/app/views/admin/jobs/index.html.haml @@ -7,10 +7,9 @@ - build_path_proc = ->(scope) { admin_jobs_path(scope: scope) } = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope - .nav-controls - - if @all_builds.running_or_pending.any? - #stop-jobs-modal - + - if @all_builds.running_or_pending.any? + #stop-jobs-modal + .nav-controls %button#stop-jobs-button.btn.btn-danger{ data: { toggle: 'modal', target: '#stop-jobs-modal', url: cancel_all_admin_jobs_path } } diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index e16d132f869..0931ceb1512 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -58,7 +58,7 @@ - if @project.avatar? %hr = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _("Avatar will be removed. Are you sure?") }, method: :delete, class: "btn btn-danger btn-inverted" - = f.submit 'Save changes', class: "btn btn-success" + = f.submit 'Save changes', class: "btn btn-success js-btn-save-general-project-settings" %section.settings.sharing-permissions.no-animate{ class: ('expanded' if expanded) } .settings-header diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml index ff440e99042..160e325996a 100644 --- a/app/views/projects/pipeline_schedules/_form.html.haml +++ b/app/views/projects/pipeline_schedules/_form.html.haml @@ -1,7 +1,3 @@ -- content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_vue' - = webpack_bundle_tag 'schedule_form' - = form_for [@project.namespace.becomes(Namespace), @project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "form-horizontal js-pipeline-schedule-form" } do |f| = form_errors(@schedule) .form-group diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml index 4fbdd1dd5e4..bcb6dddba1a 100644 --- a/app/views/projects/pipeline_schedules/index.html.haml +++ b/app/views/projects/pipeline_schedules/index.html.haml @@ -1,9 +1,5 @@ - breadcrumb_title _("Schedules") -- content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_vue' - = webpack_bundle_tag 'schedules_index' - - @no_container = true - page_title _("Pipeline Schedules") diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml index ba55bc23add..a86cb14960a 100644 --- a/app/views/projects/pipelines/charts.html.haml +++ b/app/views/projects/pipelines/charts.html.haml @@ -1,9 +1,6 @@ - @no_container = true - breadcrumb_title "CI / CD Charts" - page_title _("Charts"), _("Pipelines") -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('common_d3') - = page_specific_javascript_bundle_tag('graphs') %div{ class: container_class } .sub-header-block diff --git a/app/views/projects/pipelines/charts/_pipeline_times.haml b/app/views/projects/pipelines/charts/_pipeline_times.haml index a5dbd1b1532..510697c2ae9 100644 --- a/app/views/projects/pipelines/charts/_pipeline_times.haml +++ b/app/views/projects/pipelines/charts/_pipeline_times.haml @@ -1,6 +1,3 @@ -- content_for :page_specific_javascripts do - = webpack_bundle_tag('pipelines_times') - %div %p.light = _("Commit duration in minutes for last 30 commits") diff --git a/app/views/projects/pipelines/charts/_pipelines.haml b/app/views/projects/pipelines/charts/_pipelines.haml index 41dc2f6cf9d..2f4b6def155 100644 --- a/app/views/projects/pipelines/charts/_pipelines.haml +++ b/app/views/projects/pipelines/charts/_pipelines.haml @@ -1,6 +1,3 @@ -- content_for :page_specific_javascripts do - = webpack_bundle_tag('pipelines_charts') - %h4= _("Pipelines charts") %p diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index 0808b28a9df..17e804d682b 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -1,6 +1,3 @@ -- content_for :page_specific_javascripts do - = webpack_bundle_tag('integrations') - .row.prepend-top-default.append-bottom-default .col-lg-3 %h4.prepend-top-0 diff --git a/app/views/projects/services/prometheus/_show.html.haml b/app/views/projects/services/prometheus/_show.html.haml index b0cb5ce5e8f..5f38ecd6820 100644 --- a/app/views/projects/services/prometheus/_show.html.haml +++ b/app/views/projects/services/prometheus/_show.html.haml @@ -1,6 +1,3 @@ -- content_for :page_specific_javascripts do - = webpack_bundle_tag('prometheus_metrics') - .row.prepend-top-default.append-bottom-default.prometheus-metrics-monitoring.js-prometheus-metrics-monitoring .col-lg-3 %h4.prepend-top-0 diff --git a/app/views/projects/update.js.haml b/app/views/projects/update.js.haml index 2c05ebe52ae..1a353953838 100644 --- a/app/views/projects/update.js.haml +++ b/app/views/projects/update.js.haml @@ -6,4 +6,4 @@ $(".project-edit-errors").html("#{escape_javascript(render('errors'))}"); $('.save-project-loader').hide(); $('.project-edit-container').show(); - $('.edit-project .btn-save').enable(); + $('.edit-project .js-btn-save-general-project-settings').enable(); diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml index 1a259b679c7..8607be9cd06 100644 --- a/app/views/shared/groups/_dropdown.html.haml +++ b/app/views/shared/groups/_dropdown.html.haml @@ -23,11 +23,11 @@ - if show_archive_options %li.divider %li.js-filter-archived-projects - = link_to group_children_path(@group, archived: nil), class: ("is-active" unless params[:archived].present?) do + = link_to filter_groups_path(archived: nil), class: ("is-active" unless params[:archived].present?) do Hide archived projects %li.js-filter-archived-projects - = link_to group_children_path(@group, archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do + = link_to filter_groups_path(archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do Show archived projects %li.js-filter-archived-projects - = link_to group_children_path(@group, archived: 'only'), class: ("is-active" if params[:archived] == 'only') do + = link_to filter_groups_path(archived: 'only'), class: ("is-active" if params[:archived] == 'only') do Show archived projects only diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index c9a77d668a2..a396d1007a7 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -4,9 +4,6 @@ - page_description @user.bio - header_title @user.name, user_path(@user) - @no_container = true -- content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_d3' - = webpack_bundle_tag 'users' = content_for :meta_tags do = auto_discovery_link_tag(:atom, user_url(@user, format: :atom), title: "#{@user.name} activity") diff --git a/app/workers/check_gcp_project_billing_worker.rb b/app/workers/check_gcp_project_billing_worker.rb index 5466ccdda59..363f81590ab 100644 --- a/app/workers/check_gcp_project_billing_worker.rb +++ b/app/workers/check_gcp_project_billing_worker.rb @@ -7,6 +7,7 @@ class CheckGcpProjectBillingWorker LEASE_TIMEOUT = 3.seconds.to_i SESSION_KEY_TIMEOUT = 5.minutes BILLING_TIMEOUT = 1.hour + BILLING_CHANGED_LABELS = { state_transition: nil }.freeze def self.get_session_token(token_key) Gitlab::Redis::SharedState.with do |redis| @@ -22,8 +23,11 @@ class CheckGcpProjectBillingWorker end end - def self.redis_shared_state_key_for(token) - "gitlab:gcp:#{Digest::SHA1.hexdigest(token)}:billing_enabled" + def self.get_billing_state(token) + Gitlab::Redis::SharedState.with do |redis| + value = redis.get(redis_shared_state_key_for(token)) + ActiveRecord::Type::Boolean.new.type_cast_from_user(value) + end end def perform(token_key) @@ -33,12 +37,9 @@ class CheckGcpProjectBillingWorker return unless token return unless try_obtain_lease_for(token) - billing_enabled_projects = CheckGcpProjectBillingService.new.execute(token) - Gitlab::Redis::SharedState.with do |redis| - redis.set(self.class.redis_shared_state_key_for(token), - !billing_enabled_projects.empty?, - ex: BILLING_TIMEOUT) - end + billing_enabled_state = !CheckGcpProjectBillingService.new.execute(token).empty? + update_billing_change_counter(self.class.get_billing_state(token), billing_enabled_state) + self.class.set_billing_state(token, billing_enabled_state) end private @@ -51,9 +52,41 @@ class CheckGcpProjectBillingWorker "gitlab:gcp:session:#{token_key}" end + def self.redis_shared_state_key_for(token) + "gitlab:gcp:#{Digest::SHA1.hexdigest(token)}:billing_enabled" + end + + def self.set_billing_state(token, value) + Gitlab::Redis::SharedState.with do |redis| + redis.set(redis_shared_state_key_for(token), value, ex: BILLING_TIMEOUT) + end + end + def try_obtain_lease_for(token) Gitlab::ExclusiveLease .new("check_gcp_project_billing_worker:#{token.hash}", timeout: LEASE_TIMEOUT) .try_obtain end + + def billing_changed_counter + @billing_changed_counter ||= Gitlab::Metrics.counter( + :gcp_billing_change_count, + "Counts the number of times a GCP project changed billing_enabled state from false to true", + BILLING_CHANGED_LABELS + ) + end + + def state_transition(previous_state, current_state) + if previous_state.nil? && !current_state + 'no_billing' + elsif previous_state.nil? && current_state + 'with_billing' + elsif !previous_state && current_state + 'billing_configured' + end + end + + def update_billing_change_counter(previous_state, current_state) + billing_changed_counter.increment(state_transition: state_transition(previous_state, current_state)) + end end diff --git a/changelogs/unreleased/34130-null-pipes.yml b/changelogs/unreleased/34130-null-pipes.yml new file mode 100644 index 00000000000..a56e5cf8db2 --- /dev/null +++ b/changelogs/unreleased/34130-null-pipes.yml @@ -0,0 +1,5 @@ +--- +title: Prevent MR Widget error when no CI configured +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/35530-teleporting-emoji.yml b/changelogs/unreleased/35530-teleporting-emoji.yml new file mode 100644 index 00000000000..a60a42b9e48 --- /dev/null +++ b/changelogs/unreleased/35530-teleporting-emoji.yml @@ -0,0 +1,5 @@ +--- +title: Fix Teleporting Emoji +merge_request: 16963 +author: Jared Deckard <jared.deckard@gmail.com> +type: fixed diff --git a/changelogs/unreleased/39607-fix-avatar--vertical-align.yml b/changelogs/unreleased/39607-fix-avatar--vertical-align.yml new file mode 100644 index 00000000000..4d9fee12f04 --- /dev/null +++ b/changelogs/unreleased/39607-fix-avatar--vertical-align.yml @@ -0,0 +1,5 @@ +--- +title: "Fix user avatar's vertical align on the issues and merge requests pages" +merge_request: 17072 +author: Laszlo Karpati +type: fixed diff --git a/changelogs/unreleased/40623-fix-404-when-listing-archived-projects-in-a-group-where-all-projects-have-been-archived.yml b/changelogs/unreleased/40623-fix-404-when-listing-archived-projects-in-a-group-where-all-projects-have-been-archived.yml new file mode 100644 index 00000000000..543fd7c5e8d --- /dev/null +++ b/changelogs/unreleased/40623-fix-404-when-listing-archived-projects-in-a-group-where-all-projects-have-been-archived.yml @@ -0,0 +1,4 @@ +title: Fix 404 when listing archived projects in a group where all projects have been archived +merge_request: 17077 +author: Ashley Dumaine +type: fixed diff --git a/changelogs/unreleased/41899-api-endpoint-for-importing-a-project-export.yml b/changelogs/unreleased/41899-api-endpoint-for-importing-a-project-export.yml new file mode 100644 index 00000000000..29ab7cc7cab --- /dev/null +++ b/changelogs/unreleased/41899-api-endpoint-for-importing-a-project-export.yml @@ -0,0 +1,5 @@ +--- +title: API endpoint for importing a project export +merge_request: 17025 +author: +type: added diff --git a/changelogs/unreleased/42160-error-500-loading-merge-request-undefined-method-index-for-nil-nilclass.yml b/changelogs/unreleased/42160-error-500-loading-merge-request-undefined-method-index-for-nil-nilclass.yml deleted file mode 100644 index 64340ab08cd..00000000000 --- a/changelogs/unreleased/42160-error-500-loading-merge-request-undefined-method-index-for-nil-nilclass.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix 500 error when loading a merge request with an invalid comment -merge_request: 16795 -author: -type: fixed diff --git a/changelogs/unreleased/42591-update-nokogiri.yml b/changelogs/unreleased/42591-update-nokogiri.yml deleted file mode 100644 index 5f9587d2d92..00000000000 --- a/changelogs/unreleased/42591-update-nokogiri.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Update nokogiri to 1.8.2 -merge_request: 16807 -author: -type: security diff --git a/changelogs/unreleased/42696-gitlab-import-leaves-group_id-on-projectlabel.yml b/changelogs/unreleased/42696-gitlab-import-leaves-group_id-on-projectlabel.yml deleted file mode 100644 index c46cc86c99b..00000000000 --- a/changelogs/unreleased/42696-gitlab-import-leaves-group_id-on-projectlabel.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix GitLab import leaving group_id on ProjectLabel -merge_request: 16877 -author: -type: fixed diff --git a/changelogs/unreleased/42929-hide-new-variable-values.yml b/changelogs/unreleased/42929-hide-new-variable-values.yml new file mode 100644 index 00000000000..68decd25b5a --- /dev/null +++ b/changelogs/unreleased/42929-hide-new-variable-values.yml @@ -0,0 +1,5 @@ +--- +title: Hide CI secret variable values after saving +merge_request: 17044 +author: +type: changed diff --git a/changelogs/unreleased/43201-rename-repository-submit-button-disabled.yml b/changelogs/unreleased/43201-rename-repository-submit-button-disabled.yml new file mode 100644 index 00000000000..b527000332e --- /dev/null +++ b/changelogs/unreleased/43201-rename-repository-submit-button-disabled.yml @@ -0,0 +1,5 @@ +--- +title: Allows project rename after validation error +merge_request: 17150 +author: +type: fixed diff --git a/changelogs/unreleased/asciidoc_inter_document_cross_references.yml b/changelogs/unreleased/asciidoc_inter_document_cross_references.yml new file mode 100644 index 00000000000..34b26753312 --- /dev/null +++ b/changelogs/unreleased/asciidoc_inter_document_cross_references.yml @@ -0,0 +1,5 @@ +--- +title: Asciidoc now support inter-document cross references between files in repository +merge_request: 17125 +author: Turo Soisenniemi +type: changed diff --git a/changelogs/unreleased/bvl-fix-500-on-fork-without-restricted-visibility-levels.yml b/changelogs/unreleased/bvl-fix-500-on-fork-without-restricted-visibility-levels.yml deleted file mode 100644 index 378f0ef7ce9..00000000000 --- a/changelogs/unreleased/bvl-fix-500-on-fork-without-restricted-visibility-levels.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix forking projects when no restricted visibility levels are defined applicationwide -merge_request: 16881 -author: -type: fixed diff --git a/changelogs/unreleased/change-strip-whitespace-from-username-input-42637.yml b/changelogs/unreleased/change-strip-whitespace-from-username-input-42637.yml new file mode 100644 index 00000000000..a51781396ee --- /dev/null +++ b/changelogs/unreleased/change-strip-whitespace-from-username-input-42637.yml @@ -0,0 +1,5 @@ +--- +title: Remove whitespace from the username/email sign in form field +merge_request: 17020 +author: Peter lauck +type: changed diff --git a/changelogs/unreleased/dm-escape-commit-message.yml b/changelogs/unreleased/dm-escape-commit-message.yml new file mode 100644 index 00000000000..89af2da3484 --- /dev/null +++ b/changelogs/unreleased/dm-escape-commit-message.yml @@ -0,0 +1,5 @@ +--- +title: Escape HTML entities in commit messages +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 deleted file mode 100644 index 36615e5b976..00000000000 --- a/changelogs/unreleased/dm-user-namespace-route-path-validation.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Validate user namespace before saving so that errors persist on model -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/fix-validation-of-environment-scope-for-variables.yml b/changelogs/unreleased/fix-validation-of-environment-scope-for-variables.yml new file mode 100644 index 00000000000..5424c15a8ae --- /dev/null +++ b/changelogs/unreleased/fix-validation-of-environment-scope-for-variables.yml @@ -0,0 +1,5 @@ +--- +title: Fix validation of environment scope of variables +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/fj-42910-unauthenticated-limit-via-ssh.yml b/changelogs/unreleased/fj-42910-unauthenticated-limit-via-ssh.yml new file mode 100644 index 00000000000..cef339ef787 --- /dev/null +++ b/changelogs/unreleased/fj-42910-unauthenticated-limit-via-ssh.yml @@ -0,0 +1,5 @@ +--- +title: Fixed bug with unauthenticated requests through git ssh +merge_request: 17149 +author: +type: fixed diff --git a/changelogs/unreleased/issue-39885.yml b/changelogs/unreleased/issue-39885.yml new file mode 100644 index 00000000000..75bf9434152 --- /dev/null +++ b/changelogs/unreleased/issue-39885.yml @@ -0,0 +1,5 @@ +--- +title: 'Ensure users cannot create environments with leading or trailing slashes (Fixes #39885)' +merge_request: 15273 +author: +type: fixed diff --git a/changelogs/unreleased/jej-fix-slow-lfs-object-check.yml b/changelogs/unreleased/jej-fix-slow-lfs-object-check.yml new file mode 100644 index 00000000000..09112fba85e --- /dev/null +++ b/changelogs/unreleased/jej-fix-slow-lfs-object-check.yml @@ -0,0 +1,5 @@ +--- +title: Only check LFS integrity for first ref in a push to avoid timeout +merge_request: 17098 +author: +type: performance diff --git a/changelogs/unreleased/mk-fix-no-untracked-upload-files-error.yml b/changelogs/unreleased/mk-fix-no-untracked-upload-files-error.yml deleted file mode 100644 index fddfba94192..00000000000 --- a/changelogs/unreleased/mk-fix-no-untracked-upload-files-error.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Resolve PrepareUntrackedUploads PostgreSQL syntax error -merge_request: 17019 -author: -type: fixed diff --git a/changelogs/unreleased/remove_ldap_person_validation.yml b/changelogs/unreleased/remove_ldap_person_validation.yml deleted file mode 100644 index da7f0a52886..00000000000 --- a/changelogs/unreleased/remove_ldap_person_validation.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: LDAP Person no longer throws exception on invalid entry -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/sh-fix-jira-trailing-slash.yml b/changelogs/unreleased/sh-fix-jira-trailing-slash.yml deleted file mode 100644 index 786f6cd3727..00000000000 --- a/changelogs/unreleased/sh-fix-jira-trailing-slash.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix JIRA not working when a trailing slash is included -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/winh-new-branch-dropdown-style.yml b/changelogs/unreleased/winh-new-branch-dropdown-style.yml deleted file mode 100644 index 007e9e9f453..00000000000 --- a/changelogs/unreleased/winh-new-branch-dropdown-style.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Cleanup new branch/merge request form in issues -merge_request: 16854 -author: -type: fixed diff --git a/changelogs/unreleased/winh-new-modal-component.yml b/changelogs/unreleased/winh-new-modal-component.yml new file mode 100644 index 00000000000..bcc0d489c88 --- /dev/null +++ b/changelogs/unreleased/winh-new-modal-component.yml @@ -0,0 +1,5 @@ +--- +title: Add new modal Vue component +merge_request: 17108 +author: +type: changed diff --git a/config/application.rb b/config/application.rb index c914e34b9c3..918bd4d57cf 100644 --- a/config/application.rb +++ b/config/application.rb @@ -69,6 +69,7 @@ module Gitlab # - Webhook URLs (:hook) # - Sentry DSN (:sentry_dsn) # - Deploy keys (:key) + # - Secret variable values (:value) config.filter_parameters += [/token$/, /password/, /secret/] config.filter_parameters += %i( certificate @@ -80,6 +81,7 @@ module Gitlab sentry_dsn trace variables + value ) # Enable escaping HTML in JSON. diff --git a/config/initializers/rack_attack_global.rb b/config/initializers/rack_attack_global.rb index 9453df2ec5a..a90516eee7d 100644 --- a/config/initializers/rack_attack_global.rb +++ b/config/initializers/rack_attack_global.rb @@ -26,6 +26,7 @@ class Rack::Attack throttle('throttle_unauthenticated', Gitlab::Throttle.unauthenticated_options) do |req| Gitlab::Throttle.settings.throttle_unauthenticated_enabled && req.unauthenticated? && + !req.api_internal_request? && req.ip end @@ -54,6 +55,10 @@ class Rack::Attack path.start_with?('/api') end + def api_internal_request? + path =~ %r{^/api/v\d+/internal/} + end + def web_request? !api_request? end diff --git a/config/webpack.config.js b/config/webpack.config.js index a4e6c64fce5..5ec33533b4f 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -68,7 +68,6 @@ var config = { help: './help/help.js', how_to_merge: './how_to_merge.js', issue_show: './issue_show/index.js', - integrations: './integrations', job_details: './jobs/job_details_bundle.js', locale: './locale/index.js', main: './main.js', @@ -79,19 +78,14 @@ var config = { notes: './notes/index.js', pdf_viewer: './blob/pdf_viewer.js', pipelines: './pipelines/pipelines_bundle.js', - pipelines_charts: './pipelines/pipelines_charts.js', pipelines_details: './pipelines/pipeline_details_bundle.js', - pipelines_times: './pipelines/pipelines_times.js', profile: './profile/profile_bundle.js', project_import_gl: './projects/project_import_gitlab_project.js', - prometheus_metrics: './prometheus_metrics', protected_branches: './protected_branches', protected_tags: './protected_tags', registry_list: './registry/index.js', ide: './ide/index.js', sidebar: './sidebar/sidebar_bundle.js', - schedule_form: './pipeline_schedules/pipeline_schedule_form_bundle.js', - schedules_index: './pipeline_schedules/pipeline_schedules_index_bundle.js', snippet: './snippet/snippet_bundle.js', sketch_viewer: './blob/sketch_viewer.js', stl_viewer: './blob/stl_viewer.js', @@ -102,7 +96,6 @@ var config = { vue_merge_request_widget: './vue_merge_request_widget/index.js', test: './test.js', two_factor_auth: './two_factor_auth.js', - users: './users/index.js', webpack_runtime: './webpack.js', }, @@ -158,7 +151,7 @@ var config = { include: /node_modules\/katex\/dist/, use: [ { loader: 'style-loader' }, - { + { loader: 'css-loader', options: { name: '[name].[hash].[ext]' diff --git a/db/fixtures/development/01_admin.rb b/db/fixtures/development/01_admin.rb index 6f241f6fa4a..dfb50c195c1 100644 --- a/db/fixtures/development/01_admin.rb +++ b/db/fixtures/development/01_admin.rb @@ -9,7 +9,6 @@ Gitlab::Seeder.quiet do s.username = 'root' s.password = '5iveL!fe' s.admin = true - s.projects_limit = 100 s.confirmed_at = DateTime.now end end diff --git a/doc/administration/integration/plantuml.md b/doc/administration/integration/plantuml.md index 65f59b72690..d978d1dca53 100644 --- a/doc/administration/integration/plantuml.md +++ b/doc/administration/integration/plantuml.md @@ -9,7 +9,19 @@ created in snippets, wikis, and repos. ## PlantUML Server Before you can enable PlantUML in GitLab; you need to set up your own PlantUML -server that will generate the diagrams. Installing and configuring your +server that will generate the diagrams. + +### Docker + +With Docker, you can just run a container like this: + +`docker run -d --name plantuml -p 8080:8080 plantuml/plantuml-server:tomcat` + +The **PlantUML URL** will be the hostname of the server running the container. + +### Debian/Ubuntu + +Installing and configuring your own PlantUML server is easy in Debian/Ubuntu distributions using Tomcat. First you need to create a `plantuml.war` file from the source code: diff --git a/doc/administration/operations/fast_ssh_key_lookup.md b/doc/administration/operations/fast_ssh_key_lookup.md index 9d1589d84aa..a795d5116ea 100644 --- a/doc/administration/operations/fast_ssh_key_lookup.md +++ b/doc/administration/operations/fast_ssh_key_lookup.md @@ -56,7 +56,7 @@ new one, and attempting to pull a repo. > **Warning:** Do not disable writes until SSH is confirmed to be working perfectly, because the file will quickly become out-of-date. -In the case of lookup failures (which are not uncommon), the `authorized_keys` +In the case of lookup failures (which are common), the `authorized_keys` file will still be scanned. So git SSH performance will still be slow for many users as long as a large file exists. diff --git a/doc/administration/pages/index.md b/doc/administration/pages/index.md index 7d47aaac299..edb3e4c961e 100644 --- a/doc/administration/pages/index.md +++ b/doc/administration/pages/index.md @@ -61,6 +61,21 @@ Before proceeding with the Pages configuration, you will need to: NOTE: **Note:** If your GitLab instance and the Pages daemon are deployed in a private network or behind a firewall, your GitLab Pages websites will only be accessible to devices/users that have access to the private network. +### Add the domain to the Public Suffix List + +The [Public Suffix List](https://publicsuffix.org) is used by browsers to +decide how to treat subdomains. If your GitLab instance allows members of the +public to create GitLab Pages sites, it also allows those users to create +subdomains on the pages domain (`example.io`). Adding the domain to the Public +Suffix List prevents browsers from accepting +[supercookies](https://en.wikipedia.org/wiki/HTTP_cookie#Supercookie), +among other things. + +Follow [these instructions](https://publicsuffix.org/submit/) to submit your +GitLab Pages subdomain. For instance, if your domain is `example.io`, you should +request that `*.example.io` is added to the Public Suffix List. GitLab.com +added `*.gitlab.io` [in 2016](https://gitlab.com/gitlab-com/infrastructure/issues/230). + ### DNS configuration GitLab Pages expect to run on their own virtual host. In your DNS server/provider diff --git a/doc/api/README.md b/doc/api/README.md index 88710eae4fe..b193ef4ab7f 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -43,6 +43,7 @@ following locations: - [Pipeline Schedules](pipeline_schedules.md) - [Projects](projects.md) including setting Webhooks - [Project Access Requests](access_requests.md) +- [Project import/export](project_import_export.md) - [Project Members](members.md) - [Project Snippets](project_snippets.md) - [Protected Branches](protected_branches.md) diff --git a/doc/api/namespaces.md b/doc/api/namespaces.md index 25cae5ce1f9..1f0dc700640 100644 --- a/doc/api/namespaces.md +++ b/doc/api/namespaces.md @@ -39,7 +39,7 @@ Example response: "path": "group1", "kind": "group", "full_path": "group1", - "parent_id": "null", + "parent_id": null, "members_count_with_descendants": 2 }, { @@ -48,7 +48,7 @@ Example response: "path": "bar", "kind": "group", "full_path": "foo/bar", - "parent_id": "9", + "parent_id": 9, "members_count_with_descendants": 5 } ] @@ -84,7 +84,7 @@ Example response: "path": "twitter", "kind": "group", "full_path": "twitter", - "parent_id": "null", + "parent_id": null, "members_count_with_descendants": 2 } ] @@ -117,7 +117,7 @@ Example response: "path": "group1", "kind": "group", "full_path": "group1", - "parent_id": "null", + "parent_id": null, "members_count_with_descendants": 2 } ``` @@ -137,7 +137,7 @@ Example response: "path": "group1", "kind": "group", "full_path": "group1", - "parent_id": "null", + "parent_id": null, "members_count_with_descendants": 2 } ``` diff --git a/doc/api/project_import_export.md b/doc/api/project_import_export.md new file mode 100644 index 00000000000..e442442c750 --- /dev/null +++ b/doc/api/project_import_export.md @@ -0,0 +1,74 @@ +# Project import API + +[Introduced][ce-41899] in GitLab 10.6 + +[See also the project import/export documentation](../user/project/settings/import_export.md) + +## Import a file + +```http +POST /projects/import +``` + +| Attribute | Type | Required | Description | +| --------- | -------------- | -------- | ---------------------------------------- | +| `namespace` | integer/string | no | The ID or path of the namespace that the project will be imported to. Defaults to the current user's namespace | +| `file` | string | yes | The file to be uploaded | +| `path` | string | yes | Name and path for new project | + +To upload a file from your filesystem, use the `--form` argument. This causes +cURL to post data using the header `Content-Type: multipart/form-data`. +The `file=` parameter must point to a file on your filesystem and be preceded +by `@`. For example: + +```console +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --form "path=api-project" --form "file=@/path/to/file" https://gitlab.example.com/api/v4/projects/import +``` + +```json +{ + "id": 1, + "description": null, + "name": "api-project", + "name_with_namespace": "Administrator / api-project", + "path": "api-project", + "path_with_namespace": "root/api-project", + "created_at": "2018-02-13T09:05:58.023Z", + "import_status": "scheduled" +} +``` + +## Import status + +Get the status of an import. + +```http +GET /projects/:id/import +``` + +| 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 | + +```console +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/import +``` + +Status can be one of `none`, `scheduled`, `failed`, `started`, or `finished`. + +If the status is `failed`, it will include the import error message under `import_error`. + +```json +{ + "id": 1, + "description": "Itaque perspiciatis minima aspernatur corporis consequatur.", + "name": "Gitlab Test", + "name_with_namespace": "Gitlab Org / Gitlab Test", + "path": "gitlab-test", + "path_with_namespace": "gitlab-org/gitlab-test", + "created_at": "2017-08-29T04:36:44.383Z", + "import_status": "started" +} +``` + +[ce-41899]: https://gitlab.com/gitlab-org/gitlab-ce/issues/41899 diff --git a/doc/api/projects.md b/doc/api/projects.md index 05d2f2af00b..9e649efea9c 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -1330,6 +1330,10 @@ POST /projects/:id/housekeeping Read more in the [Branches](branches.md) documentation. +## Project Import/Export + +Read more in the [Project import/export](project_import_export.md) documentation. + ## Project members Read more in the [Project members](members.md) documentation. diff --git a/doc/ci/README.md b/doc/ci/README.md index eabeb4510db..532ae52a184 100644 --- a/doc/ci/README.md +++ b/doc/ci/README.md @@ -70,6 +70,8 @@ learn how to leverage its potential even more. - [Use SSH keys in your build environment](ssh_keys/README.md) - [Trigger pipelines through the GitLab API](triggers/README.md) - [Trigger pipelines on a schedule](../user/project/pipelines/schedules.md) +- [Kubernetes clusters](../user/project/clusters/index.md) - Integrate one or + more Kubernetes clusters to your project ## GitLab CI/CD for Docker diff --git a/doc/development/fe_guide/components.md b/doc/development/fe_guide/components.md new file mode 100644 index 00000000000..66a8abe42f7 --- /dev/null +++ b/doc/development/fe_guide/components.md @@ -0,0 +1,61 @@ +# Components + +## Contents +* [Dropdowns](#dropdowns) +* [Modals](#modals) + +## Dropdowns + +See also the [corresponding UX guide](../ux_guide/components.md#dropdowns). + +### How to style a bootstrap dropdown +1. Use the HTML structure provided by the [docs][bootstrap-dropdowns] +1. Add a specific class to the top level `.dropdown` element + + + ```Haml + .dropdown.my-dropdown + %button{ type: 'button', data: { toggle: 'dropdown' }, 'aria-haspopup': true, 'aria-expanded': false } + %span.dropdown-toggle-text + Toggle Dropdown + = icon('chevron-down') + + %ul.dropdown-menu + %li + %a + item! + ``` + + Or use the helpers + ```Haml + .dropdown.my-dropdown + = dropdown_toggle('Toogle!', { toggle: 'dropdown' }) + = dropdown_content + %li + %a + item! + ``` + +[bootstrap-dropdowns]: https://getbootstrap.com/docs/3.3/javascript/#dropdowns + +## Modals + +See also the [corresponding UX guide](../ux_guide/components.md#modals). + +We have a reusable Vue component for modals: [vue_shared/components/gl-modal.vue](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/assets/javascripts/vue_shared/components/gl-modal.vue) + +Here is an example of how to use it: + +```html + <gl-modal + id="dogs-out-modal" + :header-title-text="s__('ModalExample|Let the dogs out?')" + footer-primary-button-variant="danger" + :footer-primary-button-text="s__('ModalExample|Let them out')" + @submit="letOut(theDogs)" + > + {{ s__('ModalExample|You’re about to let the dogs out.') }} + </gl-modal> +``` + + diff --git a/doc/development/fe_guide/dropdowns.md b/doc/development/fe_guide/dropdowns.md index 6314f8f38d2..e9d6244355c 100644 --- a/doc/development/fe_guide/dropdowns.md +++ b/doc/development/fe_guide/dropdowns.md @@ -1,32 +1 @@ -# Dropdowns - - -## How to style a bootstrap dropdown -1. Use the HTML structure provided by the [docs][bootstrap-dropdowns] -1. Add a specific class to the top level `.dropdown` element - - - ```Haml - .dropdown.my-dropdown - %button{ type: 'button', data: { toggle: 'dropdown' }, 'aria-haspopup': true, 'aria-expanded': false } - %span.dropdown-toggle-text - Toggle Dropdown - = icon('chevron-down') - - %ul.dropdown-menu - %li - %a - item! - ``` - - Or use the helpers - ```Haml - .dropdown.my-dropdown - = dropdown_toggle('Toogle!', { toggle: 'dropdown' }) - = dropdown_content - %li - %a - item! - ``` - -[bootstrap-dropdowns]: https://getbootstrap.com/docs/3.3/javascript/#dropdowns +This page has moved [here](components.md#dropdowns). diff --git a/doc/development/fe_guide/img/gl-modal.png b/doc/development/fe_guide/img/gl-modal.png Binary files differnew file mode 100644 index 00000000000..47302e857bc --- /dev/null +++ b/doc/development/fe_guide/img/gl-modal.png diff --git a/doc/development/fe_guide/index.md b/doc/development/fe_guide/index.md index 72cb557d054..12dfc10812b 100644 --- a/doc/development/fe_guide/index.md +++ b/doc/development/fe_guide/index.md @@ -21,6 +21,8 @@ Working with our frontend assets requires Node (v4.3 or greater) and Yarn [jQuery][jquery] is used throughout the application's JavaScript, with [Vue.js][vue] for particularly advanced, dynamic elements. +We also use [Axios][axios] to handle all of our network requests. + ### Browser Support For our currently-supported browsers, see our [requirements][requirements]. @@ -77,8 +79,10 @@ Axios specific practices and gotchas. ## [Icons](icons.md) How we use SVG for our Icons. -## [Dropdowns](dropdowns.md) -How we use dropdowns. +## [Components](components.md) + +How we use UI components. + --- ## Style Guides @@ -122,6 +126,7 @@ The [externalization part of the guide](../i18n/externalization.md) explains the [webpack]: https://webpack.js.org/ [jquery]: https://jquery.com/ [vue]: http://vuejs.org/ +[axios]: https://github.com/axios/axios [airbnb-js-style-guide]: https://github.com/airbnb/javascript [scss-lint]: https://github.com/brigade/scss-lint [install]: ../../install/installation.md#4-node diff --git a/doc/development/feature_flags.md b/doc/development/feature_flags.md index 59e8a087e02..5d1f657015c 100644 --- a/doc/development/feature_flags.md +++ b/doc/development/feature_flags.md @@ -1,6 +1,6 @@ # Manage feature flags -Starting from GitLab 9.3 we support feature flags via +Starting from GitLab 9.3 we support feature flags for features in GitLab via [Flipper](https://github.com/jnunemaker/flipper/). You should use the `Feature` class (defined in `lib/feature.rb`) in your code to get, set and list feature flags. @@ -19,3 +19,8 @@ dynamic (querying the DB etc.). Once defined in `lib/feature.rb`, you will be able to activate a feature for a given feature group via the [`feature_group` param of the features API](../api/features.md#set-or-create-a-feature) + +## Feature flags for user applications + +GitLab does not yet support the use of feature flags in deployed user applications. +You can follow the progress on that [in the issue on our issue tracker](https://gitlab.com/gitlab-org/gitlab-ee/issues/779).
\ No newline at end of file diff --git a/doc/development/profiling.md b/doc/development/profiling.md index 97c997e0568..11878b4009b 100644 --- a/doc/development/profiling.md +++ b/doc/development/profiling.md @@ -27,6 +27,17 @@ Gitlab::Profiler.profile('/my-user') # Returns a RubyProf::Profile where 100 seconds is spent in UsersController#show ``` +For routes that require authorization you will need to provide a user to +`Gitlab::Profiler`. You can do this like so: + +```ruby +Gitlab::Profiler.profile('/gitlab-org/gitlab-test', user: User.first) +``` + +The user you provide will need to have a [personal access +token](https://docs.gitlab.com/ce/user/profile/personal_access_tokens.html) in +the GitLab instance. + Passing a `logger:` keyword argument to `Gitlab::Profiler.profile` will send ActiveRecord and ActionController log output to that logger. Further options are documented with the method source. diff --git a/doc/install/google_cloud_platform/img/boot_disk.png b/doc/install/google_cloud_platform/img/boot_disk.png Binary files differnew file mode 100644 index 00000000000..37b2d9eaae7 --- /dev/null +++ b/doc/install/google_cloud_platform/img/boot_disk.png diff --git a/doc/install/google_cloud_platform/img/change_admin_passwd_email.png b/doc/install/google_cloud_platform/img/change_admin_passwd_email.png Binary files differdeleted file mode 100644 index 1ffe14f60ff..00000000000 --- a/doc/install/google_cloud_platform/img/change_admin_passwd_email.png +++ /dev/null diff --git a/doc/install/google_cloud_platform/img/chrome_not_secure_page.png b/doc/install/google_cloud_platform/img/chrome_not_secure_page.png Binary files differdeleted file mode 100644 index e732066908f..00000000000 --- a/doc/install/google_cloud_platform/img/chrome_not_secure_page.png +++ /dev/null diff --git a/doc/install/google_cloud_platform/img/first_signin.png b/doc/install/google_cloud_platform/img/first_signin.png Binary files differnew file mode 100644 index 00000000000..6eb3392d674 --- /dev/null +++ b/doc/install/google_cloud_platform/img/first_signin.png diff --git a/doc/install/google_cloud_platform/img/gcp_gitlab_being_deployed.png b/doc/install/google_cloud_platform/img/gcp_gitlab_being_deployed.png Binary files differdeleted file mode 100644 index 2a1859da6e3..00000000000 --- a/doc/install/google_cloud_platform/img/gcp_gitlab_being_deployed.png +++ /dev/null diff --git a/doc/install/google_cloud_platform/img/gcp_gitlab_overview.png b/doc/install/google_cloud_platform/img/gcp_gitlab_overview.png Binary files differdeleted file mode 100644 index 1c4c870dbc9..00000000000 --- a/doc/install/google_cloud_platform/img/gcp_gitlab_overview.png +++ /dev/null diff --git a/doc/install/google_cloud_platform/img/gcp_landing.png b/doc/install/google_cloud_platform/img/gcp_landing.png Binary files differindex 6398d247ba0..d6390c4dd4f 100644 --- a/doc/install/google_cloud_platform/img/gcp_landing.png +++ b/doc/install/google_cloud_platform/img/gcp_landing.png diff --git a/doc/install/google_cloud_platform/img/gcp_launcher_console_home_page.png b/doc/install/google_cloud_platform/img/gcp_launcher_console_home_page.png Binary files differdeleted file mode 100644 index f492888ea4a..00000000000 --- a/doc/install/google_cloud_platform/img/gcp_launcher_console_home_page.png +++ /dev/null diff --git a/doc/install/google_cloud_platform/img/gcp_search_for_gitlab.png b/doc/install/google_cloud_platform/img/gcp_search_for_gitlab.png Binary files differdeleted file mode 100644 index b38af3966e2..00000000000 --- a/doc/install/google_cloud_platform/img/gcp_search_for_gitlab.png +++ /dev/null diff --git a/doc/install/google_cloud_platform/img/gitlab_deployed_page.png b/doc/install/google_cloud_platform/img/gitlab_deployed_page.png Binary files differdeleted file mode 100644 index fef9ae45f32..00000000000 --- a/doc/install/google_cloud_platform/img/gitlab_deployed_page.png +++ /dev/null diff --git a/doc/install/google_cloud_platform/img/gitlab_first_sign_in.png b/doc/install/google_cloud_platform/img/gitlab_first_sign_in.png Binary files differdeleted file mode 100644 index 381c0fe48a5..00000000000 --- a/doc/install/google_cloud_platform/img/gitlab_first_sign_in.png +++ /dev/null diff --git a/doc/install/google_cloud_platform/img/gitlab_launch_button.png b/doc/install/google_cloud_platform/img/gitlab_launch_button.png Binary files differdeleted file mode 100644 index 50f66f66118..00000000000 --- a/doc/install/google_cloud_platform/img/gitlab_launch_button.png +++ /dev/null diff --git a/doc/install/google_cloud_platform/img/launch_vm.png b/doc/install/google_cloud_platform/img/launch_vm.png Binary files differnew file mode 100644 index 00000000000..3fd13f232bb --- /dev/null +++ b/doc/install/google_cloud_platform/img/launch_vm.png diff --git a/doc/install/google_cloud_platform/img/new_gitlab_deployment_settings.png b/doc/install/google_cloud_platform/img/new_gitlab_deployment_settings.png Binary files differdeleted file mode 100644 index 00060841619..00000000000 --- a/doc/install/google_cloud_platform/img/new_gitlab_deployment_settings.png +++ /dev/null diff --git a/doc/install/google_cloud_platform/img/ssh_terminal.png b/doc/install/google_cloud_platform/img/ssh_terminal.png Binary files differnew file mode 100644 index 00000000000..6a1a418d8e9 --- /dev/null +++ b/doc/install/google_cloud_platform/img/ssh_terminal.png diff --git a/doc/install/google_cloud_platform/img/ssh_via_button.png b/doc/install/google_cloud_platform/img/ssh_via_button.png Binary files differdeleted file mode 100644 index 26106f159ad..00000000000 --- a/doc/install/google_cloud_platform/img/ssh_via_button.png +++ /dev/null diff --git a/doc/install/google_cloud_platform/img/vm_created.png b/doc/install/google_cloud_platform/img/vm_created.png Binary files differnew file mode 100644 index 00000000000..fb467f40838 --- /dev/null +++ b/doc/install/google_cloud_platform/img/vm_created.png diff --git a/doc/install/google_cloud_platform/img/vm_details.png b/doc/install/google_cloud_platform/img/vm_details.png Binary files differnew file mode 100644 index 00000000000..2d230416a4b --- /dev/null +++ b/doc/install/google_cloud_platform/img/vm_details.png diff --git a/doc/install/google_cloud_platform/index.md b/doc/install/google_cloud_platform/index.md index c6b767fff02..3389f0260f9 100644 --- a/doc/install/google_cloud_platform/index.md +++ b/doc/install/google_cloud_platform/index.md @@ -2,12 +2,7 @@  -The fastest way to get started on [Google Cloud Platform (GCP)][gcp] is through -the [Google Cloud Launcher][launcher] program. - -GitLab's official Google Launcher apps: -1. [GitLab Community Edition](https://console.cloud.google.com/launcher/details/gitlab-public/gitlab-community-edition?project=gitlab-public) -2. [GitLab Enterprise Edition](https://console.cloud.google.com/launcher/details/gitlab-public/gitlab-enterprise-edition?project=gitlab-public) +Gettung started with GitLab on a [Google Cloud Platform (GCP)][gcp] instance is quick and easy. ## Prerequisites @@ -17,84 +12,52 @@ There are only two prerequisites in order to install GitLab on GCP: 1. You need to sign up for the GCP program. If this is your first time, Google gives you [$300 credit for free][freetrial] to consume over a 60-day period. -Once you have performed those two steps, you can visit the -[GCP launcher console][console] which has a list of all the things you can -deploy on GCP. - - - -The next step is to find and install GitLab. +Once you have performed those two steps, you can [create a VM](#creating-the-vm). -## Configuring and deploying the VM +## Creating the VM To deploy GitLab on GCP you need to follow five simple steps: -1. Go to https://cloud.google.com/launcher and login with your Google credentials -1. Search for GitLab from GitLab Inc. (not the same as Bitnami) and click on - the tile. +1. Go to https://console.cloud.google.com/compute/instances and login with your Google credentials. -  +1. Click on **Create** -1. In the next page, you can see an overview of the GitLab VM as well as some - estimated costs. Click the **Launch on Compute Engine** button to choose the - hardware and network settings. +  -  +1. On the next page, you can select the type of VM as well as the + estimated costs. Provide the name of the instance, desired datacenter, and machine type. Note that GitLab recommends at least 2 vCPU's and 4GB of RAM. -1. In the settings page you can choose things like the datacenter where your GitLab - server will be hosted, the number of CPUs and amount of RAM, the disk size - and type, etc. Read GitLab's [requirements documentation][req] for more - details on what to choose depending on your needs. +  -  +1. Click **Change** under Boot disk to select the size, type, and desired operating system. GitLab supports a [variety of linux operating systems][req], including Ubuntu and Debian. Click **Select** when finished. -1. As a last step, hit **Deploy** when ready. The process will finish in a few - seconds. +  -  +1. As a last step allow HTTP and HTTPS traffic, then click **Create**. The process will finish in a few seconds. +## Installing GitLab -## Visiting GitLab for the first time +After a few seconds, the instance will be created and available to log in. The next step is to install GitLab onto the instance. -After a few seconds, GitLab will be successfully deployed and you should be -able to see the IP address that Google assigned to the VM, as well as the -credentials to the GitLab admin account. + - +1. Make a note of the IP address of the instance, as you will need that in a later step. +1. Click on the SSH button to connect to the instance. +1. A new window will appear, with you logged into the instance. -1. Click on the IP under **Site address** to visit GitLab. -1. Accept the self-signed certificate that Google automatically deployed in - order to securely reach GitLab's login page. -1. Use the username and password that are present in the Google console page - to login into GitLab and click **Sign in**. +  -  +1. Next, follow the instructions for installing GitLab for the operating system you choose, at https://about.gitlab.com/installation/. You can use the IP address from the step above, as the hostname. -Congratulations! GitLab is now installed and you can access it via your browser, -but we're not done yet. There are some steps you need to take in order to have -a fully functional GitLab installation. +1. Congratulations! GitLab is now installed and you can access it via your browser. To finish installation, open the URL in your browser and provide the initial administrator password. The username for this account is `root`. + +  ## Next steps These are the most important next steps to take after you installed GitLab for the first time. -### Changing the admin password and email - -Google assigned a random password for the GitLab admin account and you should -change it ASAP: - -1. Visit the GitLab admin page through the link in the Google console under - **Admin URL**. -1. Find the Administrator user under the **Users** page and hit **Edit**. -1. Change the email address to a real one and enter a new password. - -  - -1. Hit **Save changes** for the changes to take effect. -1. After changing the password, you will be signed out from GitLab. Use the - new credentials to login again. - ### Assigning a static IP By default, Google assigns an ephemeral IP to your instance. It is strongly @@ -112,7 +75,7 @@ here's how you configure GitLab to be aware of the change: 1. SSH into the VM. You can easily use the **SSH** button in the Google console and a new window will pop up. -  +  In the future you might want to set up [connecting with an SSH key][ssh] instead. @@ -161,7 +124,6 @@ Kerberos, etc. Here are some documents you might be interested in reading: - [GitLab Pages configuration](https://docs.gitlab.com/ce/administration/pages/index.html) - [GitLab Container Registry configuration](https://docs.gitlab.com/ce/administration/container_registry.html) -[console]: https://console.cloud.google.com/launcher "GCP launcher console" [freetrial]: https://console.cloud.google.com/freetrial "GCP free trial" [ip]: https://cloud.google.com/compute/docs/configure-instance-ip-addresses#promote_ephemeral_ip "Configuring an Instance's IP Addresses" [gcp]: https://cloud.google.com/ "Google Cloud Platform" diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index 01bd925bd6f..5f5ba2b69bc 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -95,7 +95,9 @@ Auto Deploy, and Auto Monitoring will be silently skipped. The Auto DevOps base domain is required if you want to make use of [Auto Review Apps](#auto-review-apps) and [Auto Deploy](#auto-deploy). It is defined -under the project's CI/CD settings while [enabling Auto DevOps](#enabling-auto-devops). +either under the project's CI/CD settings while +[enabling Auto DevOps](#enabling-auto-devops) or in instance-wide settings in +the CI/CD section. It can also be set at the project or group level as a variable, `AUTO_DEVOPS_DOMAIN`. A wildcard DNS A record matching the base domain is required, for example, diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md index 50a8e0d5ec5..bbe25c2d911 100644 --- a/doc/user/project/clusters/index.md +++ b/doc/user/project/clusters/index.md @@ -5,20 +5,23 @@ Connect your project to Google Kubernetes Engine (GKE) or an existing Kubernetes cluster in a few steps. -With a cluster associated to your project, you can use Review Apps, deploy your -applications, run your pipelines, and much more, in an easy way. +## Overview + +With a Kubernetes cluster associated to your project, you can use +[Review Apps](../../../ci/review_apps/index.md), deploy your applications, run +your pipelines, and much more, in an easy way. There are two options when adding a new cluster to your project; either associate your account with Google Kubernetes Engine (GKE) so that you can [create new clusters](#adding-and-creating-a-new-gke-cluster-via-gitlab) from within GitLab, or provide the credentials to an [existing Kubernetes cluster](#adding-an-existing-kubernetes-cluster). -## Prerequisites +## Adding and creating a new GKE cluster via GitLab -In order to be able to manage your Kubernetes cluster through GitLab, the -following prerequisites must be met. +NOTE: **Note:** +You need Master [permissions] and above to access the Kubernetes page. -**For a cluster hosted on GKE:** +Before proceeding, make sure the following requirements are met: - The [Google authentication integration](../../../integration/google.md) must be enabled in GitLab at the instance level. If that's not the case, ask your @@ -28,30 +31,16 @@ following prerequisites must be met. account](https://cloud.google.com/billing/docs/how-to/manage-billing-account) must be set up and that you have to have permissions to access it. - You must have Master [permissions] in order to be able to access the - **Cluster** page. + **Kubernetes** page. - You must have [Cloud Billing API](https://cloud.google.com/billing/) enabled - You must have [Resource Manager API](https://cloud.google.com/resource-manager/) -**For an existing Kubernetes cluster:** - -- Since the cluster is already created, there are no prerequisites. - ---- - -If all of the above requirements are met, you can proceed to add a new Kubernetes -cluster. - -## Adding and creating a new GKE cluster via GitLab - -NOTE: **Note:** -You need Master [permissions] and above to access the Clusters page. - -Before proceeding, make sure all [prerequisites](#prerequisites) are met. -To add a new cluster hosted on GKE to your project: +If all of the above requirements are met, you can proceed to create and add a +new Kubernetes cluster that will be hosted on GKE to your project: -1. Navigate to your project's **CI/CD > Clusters** page. -1. Click on **Add cluster**. +1. Navigate to your project's **CI/CD > Kubernetes** page. +1. Click on **Add Kubernetes cluster**. 1. Click on **Create with GKE**. 1. Connect your Google account if you haven't done already by clicking the **Sign in with Google** button. @@ -66,7 +55,7 @@ To add a new cluster hosted on GKE to your project: - **Machine type** - The [machine type](https://cloud.google.com/compute/docs/machine-types) of the Virtual Machine instance that the cluster will be based on. - **Environment scope** - The [associated environment](#setting-the-environment-scope) to this cluster. -1. Finally, click the **Create cluster** button. +1. Finally, click the **Create Kubernetes cluster** button. After a few moments, your cluster should be created. If something goes wrong, you will be notified. @@ -77,14 +66,14 @@ enable the Cluster integration. ## Adding an existing Kubernetes cluster NOTE: **Note:** -You need Master [permissions] and above to access the Clusters page. +You need Master [permissions] and above to access the Kubernetes page. To add an existing Kubernetes cluster to your project: -1. Navigate to your project's **CI/CD > Clusters** page. -1. Click on **Add cluster**. -1. Click on **Add an existing cluster** and fill in the details: - - **Cluster name** (required) - The name you wish to give the cluster. +1. Navigate to your project's **CI/CD > Kubernetes** page. +1. Click on **Add Kuberntes cluster**. +1. Click on **Add an existing Kubernetes cluster** and fill in the details: + - **Kubernetes cluster name** (required) - The name you wish to give the cluster. - **Environment scope** (required)- The [associated environment](#setting-the-environment-scope) to this cluster. - **API URL** (required) - @@ -112,15 +101,13 @@ To add an existing Kubernetes cluster to your project: - If you or someone created a secret specifically for the project, usually with limited permissions, the secret's namespace and project namespace may be the same. -1. Finally, click the **Create cluster** button. - -The Kubernetes service takes the following parameters: +1. Finally, click the **Create Kuberntes cluster** button. After a few moments, your cluster should be created. If something goes wrong, you will be notified. You can now proceed to install some pre-defined applications and then -enable the Cluster integration. +enable the Kubernetes cluster integration. ## Installing applications @@ -139,7 +126,7 @@ added directly to your configured cluster. Those applications are needed for 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. +[**Ingress** application](#installing-appplications). In order to publish your web application, you first need to find the external IP address associated to your load balancer. @@ -153,7 +140,8 @@ 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: +If you installed the Ingress [via the **Applications**](#installing-applications), +run the following command: ```bash kubectl get svc --namespace=gitlab-managed-apps ingress-nginx-ingress-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip} ' @@ -171,9 +159,14 @@ your deployed applications. ## Setting the environment scope -When adding more than one clusters, you need to differentiate them with an -environment scope. The environment scope associates clusters and -[environments](../../../ci/environments.md) in an 1:1 relationship similar to how the +NOTE: **Note:** +This is only available for [GitLab Premium][ee] where you can add more than +one Kubernetes cluster. + +When adding more than one Kubernetes clusters to your project, you need to +differentiate them with an environment scope. The environment scope associates +clusters and [environments](../../../ci/environments.md) in an 1:1 relationship +similar to how the [environment-specific variables](../../../ci/variables/README.md#limiting-environment-scopes-of-secret-variables) work. @@ -183,7 +176,7 @@ cluster in a project, and a validation error will occur if otherwise. --- -For example, let's say the following clusters exist in a project: +For example, let's say the following Kubernetes clusters exist in a project: | Cluster | Environment scope | | ---------- | ------------------- | @@ -231,8 +224,7 @@ 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. -To add another cluster, follow the same steps as described in [adding a -Kubernetes cluster](#adding-a-kubernetes-cluster) and make sure to +Simply add another cluster, like you did the first time, and make sure to [set an environment scope](#setting-the-environment-scope) that will differentiate the new cluster with the rest. @@ -240,45 +232,42 @@ differentiate the new cluster with the rest. The Kubernetes cluster integration exposes the following [deployment variables](../../../ci/variables/README.md#deployment-variables) in the -GitLab CI/CD build environment: - -- `KUBE_URL` - Equal to the API URL. -- `KUBE_TOKEN` - The Kubernetes token. -- `KUBE_NAMESPACE` - The Kubernetes namespace is auto-generated if not specified. - The default value is `<project_name>-<project_id>`. You can overwrite it to - use different one if needed, otherwise the `KUBE_NAMESPACE` variable will - receive the default value. -- `KUBE_CA_PEM_FILE` - Only present if a custom CA bundle was specified. Path - to a file containing PEM data. -- `KUBE_CA_PEM` (deprecated) - Only if a custom CA bundle was specified. Raw PEM data. -- `KUBECONFIG` - Path to a file containing `kubeconfig` for this deployment. - CA bundle would be embedded if specified. - -## Enabling or disabling the Cluster integration +GitLab CI/CD build environment. + +| Variable | Description | +| -------- | ----------- | +| `KUBE_URL` | Equal to the API URL. | +| `KUBE_TOKEN` | The Kubernetes token. | +| `KUBE_NAMESPACE` | The Kubernetes namespace is auto-generated if not specified. The default value is `<project_name>-<project_id>`. You can overwrite it to use different one if needed, otherwise the `KUBE_NAMESPACE` variable will receive the default value. | +| `KUBE_CA_PEM_FILE` | Only present if a custom CA bundle was specified. Path to a file containing PEM data. | +| `KUBE_CA_PEM` | (**deprecated**) Only if a custom CA bundle was specified. Raw PEM data. | +| `KUBECONFIG` | Path to a file containing `kubeconfig` for this deployment. CA bundle would be embedded if specified. | + +## Enabling or disabling the Kubernetes cluster integration After you have successfully added your cluster information, you can enable the -Cluster integration: +Kubernetes cluster integration: 1. Click the "Enabled/Disabled" switch 1. Hit **Save** for the changes to take effect You can now start using your Kubernetes cluster for your deployments. -To disable the Cluster integration, follow the same procedure. +To disable the Kubernetes cluster integration, follow the same procedure. -## Removing the Cluster integration +## Removing the Kubernetes cluster integration NOTE: **Note:** -You need Master [permissions] and above to remove a cluster integration. +You need Master [permissions] and above to remove a Kubernetes cluster integration. NOTE: **Note:** When you remove a cluster, you only remove its relation to GitLab, not the cluster itself. To remove the cluster, you can do so by visiting the GKE dashboard or using `kubectl`. -To remove the Cluster integration from your project, simply click on the +To remove the Kubernetes cluster integration from your project, simply click on the **Remove integration** button. You will then be able to follow the procedure -and [add a cluster](#adding-a-cluster) again. +and add a Kubernetes cluster again. ## What you can get with the Kubernetes integration diff --git a/doc/user/project/repository/index.md b/doc/user/project/repository/index.md index ce081cedd71..da3c30a8eaf 100644 --- a/doc/user/project/repository/index.md +++ b/doc/user/project/repository/index.md @@ -18,7 +18,7 @@ documentation. > **Important:** For security reasons, when using the command line, we strongly recommend -you to [connect with GitLab via SSH](../../../ssh/README.md). +that you [connect with GitLab via SSH](../../../ssh/README.md). ## Files diff --git a/doc/user/project/settings/import_export.md b/doc/user/project/settings/import_export.md index b8f865679a2..dedf102fc37 100644 --- a/doc/user/project/settings/import_export.md +++ b/doc/user/project/settings/import_export.md @@ -22,6 +22,7 @@ > in the import side is required to map the users, based on email or username. > Otherwise, a supplementary comment is left to mention the original author and > the MRs, notes or issues will be owned by the importer. +> - Control project Import/Export with the [API](../../../api/project_import_export.md). Existing projects running on any GitLab instance or GitLab.com can be exported with all their related data and be moved into a new GitLab instance. diff --git a/features/group/members.feature b/features/group/members.feature deleted file mode 100644 index 49a44f57cbb..00000000000 --- a/features/group/members.feature +++ /dev/null @@ -1,12 +0,0 @@ -Feature: Group Members - Background: - Given I sign in as "John Doe" - And "John Doe" is owner of group "Owned" - And "John Doe" is guest of group "Guest" - - Scenario: Search member by name - Given "Mary Jane" is guest of group "Guest" - And I visit group "Guest" members page - When I search for 'Mary' member - Then I should see user "Mary Jane" in team list - Then I should not see user "John Doe" in team list diff --git a/features/group/milestones.feature b/features/group/milestones.feature deleted file mode 100644 index 2211acfee20..00000000000 --- a/features/group/milestones.feature +++ /dev/null @@ -1,48 +0,0 @@ -Feature: Group Milestones - Background: - Given I sign in as "John Doe" - And "John Doe" is owner of group "Owned" - - Scenario: I should see group "Owned" milestone index page with no milestones - When I visit group "Owned" page - And I click on group milestones - Then I should see group milestones index page has no milestones - - Scenario: I should see group "Owned" milestone index page with milestones - Given Group has projects with milestones - When I visit group "Owned" page - And I click on group milestones - Then I should see group milestones index page with milestones - - Scenario: I should see group "Owned" milestone show page - Given Group has projects with milestones - When I visit group "Owned" page - And I click on group milestones - And I click on one group milestone - Then I should see group milestone with descriptions and expiry date - And I should see group milestone with all issues and MRs assigned to that milestone - - Scenario: Create group milestones - Given I visit group "Owned" milestones page - And I click new milestone button - And I fill milestone name - When I press create mileston button - Then group milestone should be created - - Scenario: I should see Issues listed with labels - Given Group has projects with milestones - When I visit group "Owned" page - And I click on group milestones - And I click on one group milestone - Then I should see the "bug" label - And I should see the "feature" label - And I should see the project name in the Issue row - - @javascript - Scenario: I should see the Labels tab - Given Group has projects with milestones - When I visit group "Owned" page - And I click on group milestones - And I click on one group milestone - And I click on the "Labels" tab - Then I should see the list of labels diff --git a/features/profile/profile.feature b/features/profile/profile.feature deleted file mode 100644 index 3263d3e212b..00000000000 --- a/features/profile/profile.feature +++ /dev/null @@ -1,85 +0,0 @@ -@profile -Feature: Profile - Background: - Given I sign in as a user - - Scenario: I look at my profile - Given I visit profile page - Then I should see my profile info - - @javascript - Scenario: I can see groups I belong to - Given I have group with projects - When I visit profile page - And I click on my profile picture - Then I should see my user page - And I should see groups I belong to - - Scenario: I edit profile - Given I visit profile page - Then I change my profile info - And I should see new profile info - - Scenario: I change my password without old one - Given I visit profile password page - When I try change my password w/o old one - Then I should see a missing password error message - And I should be redirected to password page - - Scenario: I change my password - Given I visit profile password page - Then I change my password - And I should be redirected to sign in page - - Scenario: I edit my avatar - Given I visit profile page - Then I change my avatar - And I should see new avatar - And I should see the "Remove avatar" button - And I should see the gravatar host link - - Scenario: I remove my avatar - Given I visit profile page - And I have an avatar - When I remove my avatar - Then I should see my gravatar - And I should not see the "Remove avatar" button - And I should see the gravatar host link - - Scenario: My password is expired - Given my password is expired - And I am not an ldap user - Given I visit profile password page - Then I redirected to expired password page - And I submit new password - And I redirected to sign in page - - Scenario: I unsuccessfully change my password - Given I visit profile password page - When I unsuccessfully change my password - Then I should see a password error message - - Scenario: I visit history tab - Given I logout - And I sign in via the UI - And I have activity - When I visit Authentication log page - Then I should see my activity - - Scenario: I visit my user page - When I visit profile page - And I click on my profile picture - Then I should see my user page - - Scenario: I can manage application - Given I visit profile applications page - Then I should see application form - Then I fill application form out and submit - And I see application - Then I click edit - And I see edit application form - Then I change name of application and submit - And I see that application was changed - Then I visit profile applications page - And I click to remove application - Then I see that application is removed diff --git a/features/steps/group/members.rb b/features/steps/group/members.rb index 0ab1012660c..97bcca7730b 100644 --- a/features/steps/group/members.rb +++ b/features/steps/group/members.rb @@ -9,14 +9,6 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps expect(group_members_list).to have_content("John Doe") end - step 'I should not see user "John Doe" in team list' do - expect(group_members_list).not_to have_content("John Doe") - end - - step 'I should see user "Mary Jane" in team list' do - expect(group_members_list).to have_content("Mary Jane") - end - step 'I should not see user "Mary Jane" in team list' do expect(group_members_list).not_to have_content("Mary Jane") end @@ -41,13 +33,6 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps # poltergeist always confirms popups. end - step 'I search for \'Mary\' member' do - page.within '.member-search-form' do - fill_in 'search', with: 'Mary' - find('.member-search-btn').click - end - end - step 'I change the "Mary Jane" role to "Developer"' do member = mary_jane_member diff --git a/features/steps/group/milestones.rb b/features/steps/group/milestones.rb deleted file mode 100644 index 818bbb50d0e..00000000000 --- a/features/steps/group/milestones.rb +++ /dev/null @@ -1,135 +0,0 @@ -class Spinach::Features::GroupMilestones < Spinach::FeatureSteps - include WaitForRequests - include SharedAuthentication - include SharedPaths - include SharedGroup - include SharedUser - - step 'I click on group milestones' do - visit group_milestones_path('owned') - end - - step 'I should see group milestones index page has no milestones' do - expect(page).to have_content('No milestones to show') - end - - step 'Group has projects with milestones' do - group_milestone - end - - step 'I should see group milestones index page with milestones' do - expect(page).to have_content('Version 7.2') - expect(page).to have_content('GL-113') - expect(page).to have_link('3 Issues', href: issues_group_path("owned", milestone_title: "Version 7.2")) - expect(page).to have_link('0 Merge Requests', href: merge_requests_group_path("owned", milestone_title: "GL-113")) - end - - step 'I click on one group milestone' do - milestones = Milestone.where(title: 'GL-113') - @global_milestone = GlobalMilestone.new('GL-113', milestones) - - click_link 'GL-113' - end - - step 'I should see group milestone with descriptions and expiry date' do - expect(page).to have_content('expires on Aug 20, 2114') - end - - step 'I should see group milestone with all issues and MRs assigned to that milestone' do - expect(page).to have_content('Milestone GL-113') - expect(page).to have_content('Issues 3 Open: 3 Closed: 0') - issue = Milestone.find_by(name: 'GL-113').issues.first - expect(page).to have_link(issue.title, href: project_issue_path(issue.project, issue)) - end - - step 'I fill milestone name' do - fill_in 'milestone_title', with: 'v2.9.0' - end - - step 'I click new milestone button' do - page.within('.nav-controls') do - click_link "New milestone" - end - end - - step 'I press create mileston button' do - click_button "Create milestone" - end - - step 'group milestone should be created' do - group = Group.find_by(name: 'Owned') - expect(page).to have_content group.milestones.find_by_title('v2.9.0').title - end - - step 'I should see the "bug" label' do - page.within('#tab-issues') do - expect(page).to have_content 'bug' - end - end - - step 'I should see the "feature" label' do - page.within('#tab-issues') do - expect(page).to have_content 'bug' - end - end - - step 'I should see the project name in the Issue row' do - page.within('#tab-issues') do - @global_milestone.projects.each do |project| - expect(page).to have_content project.name - end - end - end - - step 'I click on the "Labels" tab' do - page.within('.content .nav-links') do - page.find(:xpath, "//a[@href='#tab-labels']").click - end - end - - step 'I should see the list of labels' do - wait_for_requests - - page.within('#tab-labels') do - expect(page).to have_content 'bug' - expect(page).to have_content 'feature' - end - end - - private - - def group_milestone - group = owned_group - - %w(gitlabhq gitlab-ci cookbook-gitlab).each do |path| - project = create(:project, path: path, group: group) - milestone = create :milestone, title: "Version 7.2", project: project - - create(:label, project: project, title: 'bug') - create(:label, project: project, title: 'feature') - - create :issue, - project: project, - assignees: [current_user], - author: current_user, - milestone: milestone - - milestone = create :milestone, - title: "GL-113", - project: project, - due_date: '2114-08-20', - description: 'Lorem Ipsum is simply dummy text' - - issue = create :issue, - project: project, - assignees: [current_user], - author: current_user, - milestone: milestone - - issue.labels << project.labels.find_by(title: 'bug') - issue.labels << project.labels.find_by(title: 'feature') - end - - current_user.refresh_authorized_projects - end -end diff --git a/features/steps/profile/profile.rb b/features/steps/profile/profile.rb deleted file mode 100644 index d3b88ae8d2a..00000000000 --- a/features/steps/profile/profile.rb +++ /dev/null @@ -1,226 +0,0 @@ -class Spinach::Features::Profile < Spinach::FeatureSteps - include SharedAuthentication - include SharedPaths - - step 'I should see my profile info' do - expect(page).to have_content "This information will appear on your profile" - end - - step 'I change my profile info' do - fill_in 'user_skype', with: 'testskype' - fill_in 'user_linkedin', with: 'testlinkedin' - fill_in 'user_twitter', with: 'testtwitter' - fill_in 'user_website_url', with: 'testurl' - fill_in 'user_location', with: 'Ukraine' - fill_in 'user_bio', with: 'I <3 GitLab' - fill_in 'user_organization', with: 'GitLab' - click_button 'Update profile settings' - @user.reload - end - - step 'I should see new profile info' do - expect(@user.skype).to eq 'testskype' - expect(@user.linkedin).to eq 'testlinkedin' - expect(@user.twitter).to eq 'testtwitter' - expect(@user.website_url).to eq 'testurl' - expect(@user.bio).to eq 'I <3 GitLab' - expect(@user.organization).to eq 'GitLab' - expect(find('#user_location').value).to eq 'Ukraine' - end - - step 'I change my avatar' do - attach_file(:user_avatar, File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif')) - click_button "Update profile settings" - @user.reload - end - - step 'I should see new avatar' do - expect(@user.avatar).to be_instance_of AvatarUploader - expect(@user.avatar.url).to eq "/uploads/-/system/user/avatar/#{@user.id}/banana_sample.gif" - end - - step 'I should see the "Remove avatar" button' do - expect(page).to have_link("Remove avatar") - end - - step 'I have an avatar' do - attach_file(:user_avatar, File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif')) - click_button "Update profile settings" - @user.reload - end - - step 'I remove my avatar' do - click_link "Remove avatar" - @user.reload - end - - step 'I should see my gravatar' do - expect(@user.avatar?).to eq false - end - - step 'I should not see the "Remove avatar" button' do - expect(page).not_to have_link("Remove avatar") - end - - step 'I should see the gravatar host link' do - expect(page).to have_link("gravatar.com") - end - - step 'I try change my password w/o old one' do - page.within '.update-password' do - fill_in "user_password", with: "22233344" - fill_in "user_password_confirmation", with: "22233344" - click_button "Save password" - end - end - - step 'I change my password' do - page.within '.update-password' do - fill_in "user_current_password", with: "12345678" - fill_in "user_password", with: "22233344" - fill_in "user_password_confirmation", with: "22233344" - click_button "Save password" - end - end - - step 'I unsuccessfully change my password' do - page.within '.update-password' do - fill_in "user_current_password", with: "12345678" - fill_in "user_password", with: "password" - fill_in "user_password_confirmation", with: "confirmation" - click_button "Save password" - end - end - - step "I should see a missing password error message" do - page.within ".flash-container" do - expect(page).to have_content "You must provide a valid current password" - end - end - - step "I should see a password error message" do - page.within '.alert-danger' do - expect(page).to have_content "Password confirmation doesn't match" - end - end - - step 'I have activity' do - create(:closed_issue_event, author: current_user) - end - - step 'I should see my activity' do - expect(page).to have_content "Signed in with standard authentication" - end - - step 'my password is expired' do - current_user.update_attributes(password_expires_at: Time.now - 1.hour) - end - - step "I am not an ldap user" do - current_user.identities.delete - expect(current_user.ldap_user?).to eq false - end - - step 'I redirected to expired password page' do - expect(current_path).to eq new_profile_password_path - end - - step 'I submit new password' do - fill_in :user_current_password, with: '12345678' - fill_in :user_password, with: '12345678' - fill_in :user_password_confirmation, with: '12345678' - click_button "Set new password" - end - - step 'I redirected to sign in page' do - expect(current_path).to eq new_user_session_path - end - - step 'I should be redirected to password page' do - expect(current_path).to eq edit_profile_password_path - end - - step 'I should be redirected to account page' do - expect(current_path).to eq profile_account_path - end - - step 'I click on my profile picture' do - find(:css, '.header-user-dropdown-toggle').click - - page.within ".header-user" do - click_link "Profile" - end - end - - step 'I should see my user page' do - page.within ".cover-block" do - expect(page).to have_content current_user.name - expect(page).to have_content current_user.username - end - end - - step 'I have group with projects' do - @group = create(:group) - @group.add_owner(current_user) - @project = create(:project, :repository, namespace: @group) - @event = create(:closed_issue_event, project: @project) - - @project.add_master(current_user) - end - - step 'I should see groups I belong to' do - page.within ".content" do - click_link "Groups" - end - - page.within "#groups" do - expect(page).to have_content @group.name - end - end - - step 'I should see application form' do - expect(page).to have_content "Add new application" - end - - step 'I fill application form out and submit' do - fill_in :doorkeeper_application_name, with: 'test' - fill_in :doorkeeper_application_redirect_uri, with: 'https://test.com' - click_on "Save application" - end - - step 'I see application' do - expect(page).to have_content "Application: test" - expect(page).to have_content "Application Id" - expect(page).to have_content "Secret" - end - - step 'I click edit' do - click_on "Edit" - end - - step 'I see edit application form' do - expect(page).to have_content "Edit application" - end - - step 'I change name of application and submit' do - expect(page).to have_content "Edit application" - fill_in :doorkeeper_application_name, with: 'test_changed' - click_on "Save application" - end - - step 'I see that application was changed' do - expect(page).to have_content "test_changed" - expect(page).to have_content "Application Id" - expect(page).to have_content "Secret" - end - - step 'I click to remove application' do - page.within '.oauth-applications' do - click_on "Destroy" - end - end - - step "I see that application is removed" do - expect(page.find(".oauth-applications")).not_to have_content "test_changed" - end -end diff --git a/features/support/env.rb b/features/support/env.rb index 7f5b4c1c11b..15211995918 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -20,15 +20,16 @@ Dir["#{Rails.root}/features/steps/shared/*.rb"].each { |file| require file } Spinach.hooks.before_run do include RSpec::Mocks::ExampleMethods + include ActiveJob::TestHelper + include FactoryBot::Syntax::Methods + include GitlabRoutingHelper + RSpec::Mocks.setup TestEnv.init(mailer: false) # skip pre-receive hook check so we can use # web editor and merge TestEnv.disable_pre_receive - - include FactoryBot::Syntax::Methods - include GitlabRoutingHelper end Spinach.hooks.after_scenario do |scenario_data, step_definitions| diff --git a/lib/api/api.rb b/lib/api/api.rb index e953f3d2eca..754549f72f0 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -138,6 +138,7 @@ module API mount ::API::PagesDomains mount ::API::Pipelines mount ::API::PipelineSchedules + mount ::API::ProjectImport mount ::API::ProjectHooks mount ::API::Projects mount ::API::ProjectMilestones diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 03abc1b95c5..45c737c6c29 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -91,6 +91,13 @@ module API expose :created_at end + class ProjectImportStatus < ProjectIdentity + expose :import_status + + # TODO: Use `expose_nil` once we upgrade the grape-entity gem + expose :import_error, if: lambda { |status, _ops| status.import_error } + end + class BasicProjectDetails < ProjectIdentity include ::API::ProjectsRelationBuilder diff --git a/lib/api/project_import.rb b/lib/api/project_import.rb new file mode 100644 index 00000000000..a509c1f32c1 --- /dev/null +++ b/lib/api/project_import.rb @@ -0,0 +1,69 @@ +module API + class ProjectImport < Grape::API + include PaginationParams + + helpers do + def import_params + declared_params(include_missing: false) + end + + def file_is_valid? + import_params[:file] && import_params[:file]['tempfile'].respond_to?(:read) + end + + def validate_file! + render_api_error!('The file is invalid', 400) unless file_is_valid? + end + end + + before do + forbidden! unless Gitlab::CurrentSettings.import_sources.include?('gitlab_project') + end + + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + params do + requires :path, type: String, desc: 'The new project path and name' + requires :file, type: File, desc: 'The project export file to be imported' + optional :namespace, type: String, desc: "The ID or name of the namespace that the project will be imported into. Defaults to the current user's namespace." + end + desc 'Create a new project import' do + detail 'This feature was introduced in GitLab 10.6.' + success Entities::ProjectImportStatus + end + post 'import' do + validate_file! + + Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42437') + + namespace = if import_params[:namespace] + find_namespace!(import_params[:namespace]) + else + current_user.namespace + end + + project_params = { + path: import_params[:path], + namespace_id: namespace.id, + file: import_params[:file]['tempfile'] + } + + project = ::Projects::GitlabProjectsImportService.new(current_user, project_params).execute + + render_api_error!(project.errors.full_messages&.first, 400) unless project.saved? + + present project, with: Entities::ProjectImportStatus + end + + params do + requires :id, type: String, desc: 'The ID of a project' + end + desc 'Get a project export status' do + detail 'This feature was introduced in GitLab 10.6.' + success Entities::ProjectImportStatus + end + get ':id/import' do + present user_project, with: Entities::ProjectImportStatus + end + end + end +end diff --git a/lib/banzai/filter/html_entity_filter.rb b/lib/banzai/filter/html_entity_filter.rb index f3bd587c28b..e008fd428b0 100644 --- a/lib/banzai/filter/html_entity_filter.rb +++ b/lib/banzai/filter/html_entity_filter.rb @@ -5,7 +5,7 @@ module Banzai # Text filter that escapes these HTML entities: & " < > class HtmlEntityFilter < HTML::Pipeline::TextFilter def call - ERB::Util.html_escape_once(text) + ERB::Util.html_escape(text) end end end diff --git a/lib/gitlab/asciidoc.rb b/lib/gitlab/asciidoc.rb index ee7f4be6b9f..62c41801d75 100644 --- a/lib/gitlab/asciidoc.rb +++ b/lib/gitlab/asciidoc.rb @@ -8,7 +8,8 @@ module Gitlab module Asciidoc DEFAULT_ADOC_ATTRS = [ 'showtitle', 'idprefix=user-content-', 'idseparator=-', 'env=gitlab', - 'env-gitlab', 'source-highlighter=html-pipeline', 'icons=font' + 'env-gitlab', 'source-highlighter=html-pipeline', 'icons=font', + 'outfilesuffix=.adoc' ].freeze # Public: Converts the provided Asciidoc markup into HTML. diff --git a/lib/gitlab/checks/change_access.rb b/lib/gitlab/checks/change_access.rb index d75e73dac10..521680b8708 100644 --- a/lib/gitlab/checks/change_access.rb +++ b/lib/gitlab/checks/change_access.rb @@ -16,11 +16,11 @@ module Gitlab lfs_objects_missing: 'LFS objects are missing. Ensure LFS is properly set up or try a manual "git lfs push --all".' }.freeze - attr_reader :user_access, :project, :skip_authorization, :protocol, :oldrev, :newrev, :ref, :branch_name, :tag_name + attr_reader :user_access, :project, :skip_authorization, :skip_lfs_integrity_check, :protocol, :oldrev, :newrev, :ref, :branch_name, :tag_name def initialize( change, user_access:, project:, skip_authorization: false, - protocol: + skip_lfs_integrity_check: false, protocol: ) @oldrev, @newrev, @ref = change.values_at(:oldrev, :newrev, :ref) @branch_name = Gitlab::Git.branch_name(@ref) @@ -28,6 +28,7 @@ module Gitlab @user_access = user_access @project = project @skip_authorization = skip_authorization + @skip_lfs_integrity_check = skip_lfs_integrity_check @protocol = protocol end @@ -37,7 +38,7 @@ module Gitlab push_checks branch_checks tag_checks - lfs_objects_exist_check + lfs_objects_exist_check unless skip_lfs_integrity_check commits_check unless skip_commits_check true diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb index 8ec3386184a..9ec3858b493 100644 --- a/lib/gitlab/git_access.rb +++ b/lib/gitlab/git_access.rb @@ -238,19 +238,22 @@ module Gitlab changes_list = Gitlab::ChangesList.new(changes) # Iterate over all changes to find if user allowed all of them to be applied - changes_list.each do |change| + changes_list.each.with_index do |change, index| + first_change = index == 0 + # If user does not have access to make at least one change, cancel all # push by allowing the exception to bubble up - check_single_change_access(change) + check_single_change_access(change, skip_lfs_integrity_check: !first_change) end end - def check_single_change_access(change) + def check_single_change_access(change, skip_lfs_integrity_check: false) Checks::ChangeAccess.new( change, user_access: user_access, project: project, skip_authorization: deploy_key?, + skip_lfs_integrity_check: skip_lfs_integrity_check, protocol: protocol ).exec end diff --git a/lib/gitlab/git_access_wiki.rb b/lib/gitlab/git_access_wiki.rb index 1c9477e84b2..84d6e1490c3 100644 --- a/lib/gitlab/git_access_wiki.rb +++ b/lib/gitlab/git_access_wiki.rb @@ -13,7 +13,7 @@ module Gitlab authentication_abilities.include?(:download_code) && user_access.can_do_action?(:download_wiki_code) end - def check_single_change_access(change) + def check_single_change_access(change, _options = {}) unless user_access.can_do_action?(:create_wiki) raise UnauthorizedError, ERROR_MESSAGES[:write_to_wiki] end diff --git a/lib/gitlab/ldap/config.rb b/lib/gitlab/ldap/config.rb index 47b3fce3e7a..a6bea98d631 100644 --- a/lib/gitlab/ldap/config.rb +++ b/lib/gitlab/ldap/config.rb @@ -15,7 +15,7 @@ module Gitlab end def self.servers - Gitlab.config.ldap.servers.values + Gitlab.config.ldap['servers']&.values || [] end def self.available_servers diff --git a/lib/gitlab/o_auth/user.rb b/lib/gitlab/o_auth/user.rb index a3e1c66c19f..28ebac1776e 100644 --- a/lib/gitlab/o_auth/user.rb +++ b/lib/gitlab/o_auth/user.rb @@ -198,9 +198,11 @@ module Gitlab end def update_profile + clear_user_synced_attributes_metadata + return unless sync_profile_from_provider? || creating_linked_ldap_user? - metadata = gl_user.user_synced_attributes_metadata || gl_user.build_user_synced_attributes_metadata + metadata = gl_user.build_user_synced_attributes_metadata if sync_profile_from_provider? UserSyncedAttributesMetadata::SYNCABLE_ATTRIBUTES.each do |key| @@ -221,6 +223,10 @@ module Gitlab end end + def clear_user_synced_attributes_metadata + gl_user&.user_synced_attributes_metadata&.destroy + end + def log Gitlab::AppLogger end diff --git a/lib/gitlab/profiler.rb b/lib/gitlab/profiler.rb index 95d94b3cc68..98a168b43bb 100644 --- a/lib/gitlab/profiler.rb +++ b/lib/gitlab/profiler.rb @@ -45,6 +45,7 @@ module Gitlab if user private_token ||= user.personal_access_tokens.active.pluck(:token).first + raise 'Your user must have a personal_access_token' unless private_token end headers['Private-Token'] = private_token if private_token diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index 7ab85e1c35c..ac3de2a8f71 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -40,12 +40,16 @@ module Gitlab 'a-zA-Z0-9_/\\$\\{\\}\\. \\-' end + def environment_name_regex_chars_without_slash + 'a-zA-Z0-9_\\$\\{\\}\\. -' + end + def environment_name_regex - @environment_name_regex ||= /\A[#{environment_name_regex_chars}]+\z/.freeze + @environment_name_regex ||= /\A[#{environment_name_regex_chars_without_slash}]([#{environment_name_regex_chars}]*[#{environment_name_regex_chars_without_slash}])?\z/.freeze end def environment_name_regex_message - "can contain only letters, digits, '-', '_', '/', '$', '{', '}', '.', and spaces" + "can contain only letters, digits, '-', '_', '/', '$', '{', '}', '.', and spaces, but it cannot start or end with '/'" end def kubernetes_namespace_regex diff --git a/qa/qa/git/repository.rb b/qa/qa/git/repository.rb index 8eb7031f609..4c4ef3ef477 100644 --- a/qa/qa/git/repository.rb +++ b/qa/qa/git/repository.rb @@ -33,7 +33,7 @@ module QA end def clone(opts = '') - `git clone #{opts} #{@uri.to_s} ./` + `git clone #{opts} #{@uri.to_s} ./ #{suppress_output}` end def shallow_clone @@ -61,12 +61,22 @@ module QA end def push_changes(branch = 'master') - `git push #{@uri.to_s} #{branch}` + `git push #{@uri.to_s} #{branch} #{suppress_output}` end def commits `git log --oneline`.split("\n") end + + private + + def suppress_output + # If we're running as the default user, it's probably a temporary + # instance and output can be useful for debugging + return if @username == Runtime::User.default_name + + "&> #{File::NULL}" + end end end end diff --git a/qa/qa/page/main/login.rb b/qa/qa/page/main/login.rb index a8a5601dbe6..596205fe540 100644 --- a/qa/qa/page/main/login.rb +++ b/qa/qa/page/main/login.rb @@ -53,8 +53,8 @@ module QA click_link 'LDAP' - fill_in :username, with: Runtime::User.name - fill_in :password, with: Runtime::User.password + fill_in :username, with: Runtime::User.ldap_username + fill_in :password, with: Runtime::User.ldap_password click_button 'Sign in' end end diff --git a/qa/qa/page/project/show.rb b/qa/qa/page/project/show.rb index b603557f59c..0c7ad46d36b 100644 --- a/qa/qa/page/project/show.rb +++ b/qa/qa/page/project/show.rb @@ -45,6 +45,10 @@ module QA end def new_merge_request + wait(reload: true) do + has_css?(element_selector_css(:create_merge_request)) + end + click_element :create_merge_request end diff --git a/qa/qa/runtime/env.rb b/qa/qa/runtime/env.rb index 5401372e225..fe432edfa2a 100644 --- a/qa/qa/runtime/env.rb +++ b/qa/qa/runtime/env.rb @@ -35,6 +35,14 @@ module QA ENV['GITLAB_PASSWORD'] end + def ldap_username + ENV['GITLAB_LDAP_USERNAME'] + end + + def ldap_password + ENV['GITLAB_LDAP_PASSWORD'] + end + def sandbox_name ENV['GITLAB_SANDBOX_NAME'] end diff --git a/qa/qa/runtime/user.rb b/qa/qa/runtime/user.rb index 39e6adf9522..c80ee6d4d96 100644 --- a/qa/qa/runtime/user.rb +++ b/qa/qa/runtime/user.rb @@ -3,8 +3,12 @@ module QA module User extend self + def default_name + 'root' + end + def name - Runtime::Env.user_username || 'root' + Runtime::Env.user_username || default_name end def password @@ -14,6 +18,14 @@ module QA def ldap_user? Runtime::Env.user_type == 'ldap' end + + def ldap_username + Runtime::Env.ldap_username || name + end + + def ldap_password + Runtime::Env.ldap_password || password + end end end end diff --git a/spec/features/groups/members/search_members_spec.rb b/spec/features/groups/members/search_members_spec.rb new file mode 100644 index 00000000000..31fbbcf562c --- /dev/null +++ b/spec/features/groups/members/search_members_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +describe 'Search group member' do + let(:user) { create :user } + let(:member) { create :user } + + let!(:guest_group) do + create(:group) do |group| + group.add_guest(user) + group.add_guest(member) + end + end + + before do + sign_in(user) + visit group_group_members_path(guest_group) + end + + it 'renders member users' do + page.within '.member-search-form' do + fill_in 'search', with: member.name + find('.member-search-btn').click + end + + group_members_list = find(".panel .content-list") + expect(group_members_list).to have_content(member.name) + expect(group_members_list).not_to have_content(user.name) + end +end diff --git a/spec/features/groups/milestone_spec.rb b/spec/features/groups/milestone_spec.rb index 1b41b3842c8..20337f1d3b0 100644 --- a/spec/features/groups/milestone_spec.rb +++ b/spec/features/groups/milestone_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'Group milestones', :js do +feature 'Group milestones' do let(:group) { create(:group) } let!(:project) { create(:project_empty_repo, group: group) } let(:user) { create(:group_member, :master, user: create(:user), group: group ).user } @@ -13,7 +13,7 @@ feature 'Group milestones', :js do sign_in(user) end - context 'create a milestone' do + context 'create a milestone', :js do before do visit new_group_milestone_path(group) end @@ -61,55 +61,132 @@ feature 'Group milestones', :js do end context 'milestones list' do - let!(:other_project) { create(:project_empty_repo, group: group) } - - let!(:active_project_milestone1) { create(:milestone, project: project, state: 'active', title: 'v1.0') } - let!(:active_project_milestone2) { create(:milestone, project: other_project, state: 'active', title: 'v1.0') } - let!(:closed_project_milestone1) { create(:milestone, project: project, state: 'closed', title: 'v2.0') } - let!(:closed_project_milestone2) { create(:milestone, project: other_project, state: 'closed', title: 'v2.0') } - let!(:active_group_milestone) { create(:milestone, group: group, state: 'active') } - let!(:closed_group_milestone) { create(:milestone, group: group, state: 'closed') } - - before do - visit group_milestones_path(group) + context 'when no milestones' do + it 'renders no milestones text' do + visit group_milestones_path(group) + expect(page).to have_content('No milestones to show') + end end - it 'counts milestones correctly' do - expect(find('.top-area .active .badge').text).to eq("2") - expect(find('.top-area .closed .badge').text).to eq("2") - expect(find('.top-area .all .badge').text).to eq("4") - end + context 'when milestones exists' do + let!(:other_project) { create(:project_empty_repo, group: group) } + + let!(:active_project_milestone1) do + create( + :milestone, + project: project, + state: 'active', + title: 'v1.0', + due_date: '2114-08-20', + description: 'Lorem Ipsum is simply dummy text' + ) + end + let!(:active_project_milestone2) { create(:milestone, project: other_project, state: 'active', title: 'v1.0') } + let!(:closed_project_milestone1) { create(:milestone, project: project, state: 'closed', title: 'v2.0') } + let!(:closed_project_milestone2) { create(:milestone, project: other_project, state: 'closed', title: 'v2.0') } + let!(:active_group_milestone) { create(:milestone, group: group, state: 'active', title: 'GL-113') } + let!(:closed_group_milestone) { create(:milestone, group: group, state: 'closed') } + let!(:issue) do + create :issue, project: project, assignees: [user], author: user, milestone: active_project_milestone1 + end - it 'lists legacy group milestones and group milestones' do - legacy_milestone = GroupMilestone.build_collection(group, group.projects, { state: 'active' }).first + before do + visit group_milestones_path(group) + end - expect(page).to have_selector("#milestone_#{active_group_milestone.id}", count: 1) - expect(page).to have_selector("#milestone_#{legacy_milestone.milestones.first.id}", count: 1) - end + it 'counts milestones correctly' do + expect(find('.top-area .active .badge').text).to eq("2") + expect(find('.top-area .closed .badge').text).to eq("2") + expect(find('.top-area .all .badge').text).to eq("4") + end - it 'updates milestone' do - page.within(".milestones #milestone_#{active_group_milestone.id}") do - click_link('Edit') + it 'lists legacy group milestones and group milestones' do + legacy_milestone = GroupMilestone.build_collection(group, group.projects, { state: 'active' }).first + + expect(page).to have_selector("#milestone_#{active_group_milestone.id}", count: 1) + expect(page).to have_selector("#milestone_#{legacy_milestone.milestones.first.id}", count: 1) end - page.within('.milestone-form') do - fill_in 'milestone_title', with: 'new title' - click_button('Update milestone') + it 'updates milestone' do + page.within(".milestones #milestone_#{active_group_milestone.id}") do + click_link('Edit') + end + + page.within('.milestone-form') do + fill_in 'milestone_title', with: 'new title' + click_button('Update milestone') + end + + expect(find('#content-body h2')).to have_content('new title') end - expect(find('#content-body h2')).to have_content('new title') - end + it 'shows milestone detail and supports its edit' do + page.within(".milestones #milestone_#{active_group_milestone.id}") do + click_link(active_group_milestone.title) + end + + page.within('.detail-page-header') do + click_link('Edit') + end - it 'shows milestone detail and supports its edit' do - page.within(".milestones #milestone_#{active_group_milestone.id}") do - click_link(active_group_milestone.title) + expect(page).to have_selector('.milestone-form') end - page.within('.detail-page-header') do - click_link('Edit') + it 'renders milestones' do + expect(page).to have_content('v1.0') + expect(page).to have_content('GL-113') + expect(page).to have_link( + '1 Issue', + href: issues_group_path(group, milestone_title: 'v1.0') + ) + expect(page).to have_link( + '0 Merge Requests', + href: merge_requests_group_path(group, milestone_title: 'v1.0') + ) end - expect(page).to have_selector('.milestone-form') + it 'renders group milestone details' do + click_link 'v1.0' + + expect(page).to have_content('expires on Aug 20, 2114') + expect(page).to have_content('v1.0') + expect(page).to have_content('Issues 1 Open: 1 Closed: 0') + expect(page).to have_link(issue.title, href: project_issue_path(issue.project, issue)) + end + + describe 'labels' do + before do + create(:label, project: project, title: 'bug') do |label| + issue.labels << label + end + + create(:label, project: project, title: 'feature') do |label| + issue.labels << label + end + end + + it 'renders labels' do + click_link 'v1.0' + + page.within('#tab-issues') do + expect(page).to have_content 'bug' + expect(page).to have_content 'feature' + end + end + + it 'renders labels list', :js do + click_link 'v1.0' + + page.within('.content .nav-links') do + page.find(:xpath, "//a[@href='#tab-labels']").click + end + + page.within('#tab-labels') do + expect(page).to have_content 'bug' + expect(page).to have_content 'feature' + end + end + end end end end diff --git a/spec/features/profiles/password_spec.rb b/spec/features/profiles/password_spec.rb index 4665626f114..1d7700b6767 100644 --- a/spec/features/profiles/password_spec.rb +++ b/spec/features/profiles/password_spec.rb @@ -1,6 +1,15 @@ require 'spec_helper' describe 'Profile > Password' do + let(:user) { create(:user) } + + def fill_passwords(password, confirmation) + fill_in 'New password', with: password + fill_in 'Password confirmation', with: confirmation + + click_button 'Save password' + end + context 'Password authentication enabled' do let(:user) { create(:user, password_automatically_set: true) } @@ -9,13 +18,6 @@ describe 'Profile > Password' do visit edit_profile_password_path end - def fill_passwords(password, confirmation) - fill_in 'New password', with: password - fill_in 'Password confirmation', with: confirmation - - click_button 'Save password' - end - context 'User with password automatically set' do describe 'User puts different passwords in the field and in the confirmation' do it 'shows an error message' do @@ -73,4 +75,64 @@ describe 'Profile > Password' do end end end + + context 'Change passowrd' do + before do + sign_in(user) + visit(edit_profile_password_path) + end + + it 'does not change user passowrd without old one' do + page.within '.update-password' do + fill_passwords('22233344', '22233344') + end + + page.within '.flash-container' do + expect(page).to have_content 'You must provide a valid current password' + end + end + + it 'does not change password with invalid old password' do + page.within '.update-password' do + fill_in 'user_current_password', with: 'invalid' + fill_passwords('password', 'confirmation') + end + + page.within '.flash-container' do + expect(page).to have_content 'You must provide a valid current password' + end + end + + it 'changes user password' do + page.within '.update-password' do + fill_in "user_current_password", with: user.password + fill_passwords('22233344', '22233344') + end + + expect(current_path).to eq new_user_session_path + end + end + + context 'when password is expired' do + before do + sign_in(user) + + user.update_attributes(password_expires_at: 1.hour.ago) + user.identities.delete + expect(user.ldap_user?).to eq false + end + + it 'needs change user password' do + visit edit_profile_password_path + + expect(current_path).to eq new_profile_password_path + + fill_in :user_current_password, with: user.password + fill_in :user_password, with: '12345678' + fill_in :user_password_confirmation, with: '12345678' + click_button 'Set new password' + + expect(current_path).to eq new_user_session_path + end + end end diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb new file mode 100644 index 00000000000..0b5eacbe916 --- /dev/null +++ b/spec/features/profiles/user_edit_profile_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +describe 'User edit profile' do + let(:user) { create(:user) } + + before do + sign_in(user) + visit(profile_path) + end + + it 'changes user profile' do + fill_in 'user_skype', with: 'testskype' + fill_in 'user_linkedin', with: 'testlinkedin' + fill_in 'user_twitter', with: 'testtwitter' + fill_in 'user_website_url', with: 'testurl' + fill_in 'user_location', with: 'Ukraine' + fill_in 'user_bio', with: 'I <3 GitLab' + fill_in 'user_organization', with: 'GitLab' + click_button 'Update profile settings' + + expect(user.reload).to have_attributes( + skype: 'testskype', + linkedin: 'testlinkedin', + twitter: 'testtwitter', + website_url: 'testurl', + bio: 'I <3 GitLab', + organization: 'GitLab' + ) + + expect(find('#user_location').value).to eq 'Ukraine' + expect(page).to have_content('Profile was successfully updated') + end + + context 'user avatar' do + before do + attach_file(:user_avatar, Rails.root.join('spec', 'fixtures', 'banana_sample.gif')) + click_button 'Update profile settings' + end + + it 'changes user avatar' do + expect(page).to have_link('Remove avatar') + + user.reload + expect(user.avatar).to be_instance_of AvatarUploader + expect(user.avatar.url).to eq "/uploads/-/system/user/avatar/#{user.id}/banana_sample.gif" + end + + it 'removes user avatar' do + click_link 'Remove avatar' + + user.reload + + expect(user.avatar?).to eq false + expect(page).not_to have_link('Remove avatar') + expect(page).to have_link('gravatar.com') + end + end +end diff --git a/spec/features/profiles/user_manages_applications_spec.rb b/spec/features/profiles/user_manages_applications_spec.rb new file mode 100644 index 00000000000..387584fef62 --- /dev/null +++ b/spec/features/profiles/user_manages_applications_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +describe 'User manages applications' do + let(:user) { create(:user) } + + before do + sign_in(user) + visit applications_profile_path + end + + it 'manages applications' do + expect(page).to have_content 'Add new application' + + fill_in :doorkeeper_application_name, with: 'test' + fill_in :doorkeeper_application_redirect_uri, with: 'https://test.com' + click_on 'Save application' + + expect(page).to have_content 'Application: test' + expect(page).to have_content 'Application Id' + expect(page).to have_content 'Secret' + + click_on 'Edit' + + expect(page).to have_content 'Edit application' + fill_in :doorkeeper_application_name, with: 'test_changed' + click_on 'Save application' + + expect(page).to have_content 'test_changed' + expect(page).to have_content 'Application Id' + expect(page).to have_content 'Secret' + + visit applications_profile_path + + page.within '.oauth-applications' do + click_on 'Destroy' + end + expect(page.find('.oauth-applications')).not_to have_content 'test_changed' + end +end diff --git a/spec/features/profiles/user_visits_profile_authentication_log_spec.rb b/spec/features/profiles/user_visits_profile_authentication_log_spec.rb index a50ebb29e01..0f419c3c2c0 100644 --- a/spec/features/profiles/user_visits_profile_authentication_log_spec.rb +++ b/spec/features/profiles/user_visits_profile_authentication_log_spec.rb @@ -3,13 +3,28 @@ require 'spec_helper' describe 'User visits the authentication log' do let(:user) { create(:user) } - before do - sign_in(user) + context 'when user signed in' do + before do + sign_in(user) + end - visit(audit_log_profile_path) + it 'shows correct menu item' do + visit(audit_log_profile_path) + + expect(page).to have_active_navigation('Authentication log') + end end - it 'shows correct menu item' do - expect(page).to have_active_navigation('Authentication log') + context 'when user has activity' do + before do + create(:closed_issue_event, author: user) + gitlab_sign_in(user) + end + + it 'shows user activity' do + visit(audit_log_profile_path) + + expect(page).to have_content 'Signed in with standard authentication' + end end end diff --git a/spec/features/profiles/user_visits_profile_spec.rb b/spec/features/profiles/user_visits_profile_spec.rb index a5d80439143..713112477c8 100644 --- a/spec/features/profiles/user_visits_profile_spec.rb +++ b/spec/features/profiles/user_visits_profile_spec.rb @@ -5,20 +5,58 @@ describe 'User visits their profile' do before do sign_in(user) - - visit(profile_path) end it 'shows correct menu item' do + visit(profile_path) + 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' + it 'shows profile info' do + visit(profile_path) + + expect(page).to have_content "This information will appear on your profile" + end + + context 'when user has groups' do + let(:group) do + create :group do |group| + group.add_owner(user) + end + end + + let!(:project) do + create(:project, :repository, namespace: group) do |project| + create(:closed_issue_event, project: project) + project.add_master(user) + end + end + + def click_on_profile_picture + find(:css, '.header-user-dropdown-toggle').click + + page.within ".header-user" do + click_link "Profile" + end + end + + it 'shows user groups', :js do + visit(profile_path) + click_on_profile_picture + + page.within ".cover-block" do + expect(page).to have_content user.name + expect(page).to have_content user.username + end + + page.within ".content" do + click_link "Groups" + end - expect(page).to have_content('Profile was successfully updated') + page.within "#groups" do + expect(page).to have_content group.name + end end end end diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb index 02dbd3380b3..4d47cdb500c 100644 --- a/spec/features/projects/clusters/gcp_spec.rb +++ b/spec/features/projects/clusters/gcp_spec.rb @@ -25,7 +25,7 @@ feature 'Gcp Cluster', :js do context 'when user has a GCP project with billing enabled' do before do allow_any_instance_of(Projects::Clusters::GcpController).to receive(:authorize_google_project_billing) - allow_any_instance_of(Projects::Clusters::GcpController).to receive(:google_project_billing_status).and_return('true') + allow_any_instance_of(Projects::Clusters::GcpController).to receive(:google_project_billing_status).and_return(true) end context 'when user does not have a cluster and visits cluster index page' do @@ -134,7 +134,7 @@ feature 'Gcp Cluster', :js do context 'when user does not have a GCP project with billing enabled' do before do allow_any_instance_of(Projects::Clusters::GcpController).to receive(:authorize_google_project_billing) - allow_any_instance_of(Projects::Clusters::GcpController).to receive(:google_project_billing_status).and_return('false') + allow_any_instance_of(Projects::Clusters::GcpController).to receive(:google_project_billing_status).and_return(false) visit project_clusters_path(project) diff --git a/spec/fixtures/api/schemas/variable.json b/spec/fixtures/api/schemas/variable.json index 78977118b0a..6f6b044115b 100644 --- a/spec/fixtures/api/schemas/variable.json +++ b/spec/fixtures/api/schemas/variable.json @@ -10,7 +10,8 @@ "id": { "type": "integer" }, "key": { "type": "string" }, "value": { "type": "string" }, - "protected": { "type": "boolean" } + "protected": { "type": "boolean" }, + "environment_scope": { "type": "string", "optional": true } }, "additionalProperties": false } diff --git a/spec/helpers/events_helper_spec.rb b/spec/helpers/events_helper_spec.rb index 8a80b88da5d..fccde8b7eba 100644 --- a/spec/helpers/events_helper_spec.rb +++ b/spec/helpers/events_helper_spec.rb @@ -20,5 +20,9 @@ describe EventsHelper do it 'handles nil values' do expect(helper.event_commit_title(nil)).to eq('') end + + it 'does not escape HTML entities' do + expect(helper.event_commit_title("foo & bar")).to eq("foo & bar") + end end end diff --git a/spec/javascripts/awards_handler_spec.js b/spec/javascripts/awards_handler_spec.js index 268b5b83b73..1c1b3f3ced3 100644 --- a/spec/javascripts/awards_handler_spec.js +++ b/spec/javascripts/awards_handler_spec.js @@ -79,7 +79,7 @@ import '~/lib/utils/common_utils'; return expect($emojiMenu.length).toBe(1); }); }); - return it('should remove emoji menu when body is clicked', function(done) { + it('should remove emoji menu when body is clicked', function(done) { $('.js-add-award').eq(0).click(); return lazyAssert(done, function() { var $emojiMenu; @@ -90,6 +90,17 @@ import '~/lib/utils/common_utils'; return expect($('.js-awards-block.current').length).toBe(0); }); }); + it('should not remove emoji menu when search is clicked', function(done) { + $('.js-add-award').eq(0).click(); + return lazyAssert(done, function() { + var $emojiMenu; + $emojiMenu = $('.emoji-menu'); + $('.emoji-search').click(); + expect($emojiMenu.length).toBe(1); + expect($emojiMenu.hasClass('is-visible')).toBe(true); + return expect($('.js-awards-block.current').length).toBe(1); + }); + }); }); describe('::addAwardToEmojiBar', function() { it('should add emoji to votes block', function() { diff --git a/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js b/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js index 5b9cdceee71..ee457a9c48c 100644 --- a/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js +++ b/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js @@ -1,8 +1,10 @@ +import $ from 'jquery'; 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'; +const HIDE_CLASS = 'hide'; describe('AjaxFormVariableList', () => { preloadFixtures('projects/ci_cd_settings.html.raw'); @@ -45,16 +47,16 @@ describe('AjaxFormVariableList', () => { const loadingIcon = saveButton.querySelector('.js-secret-variables-save-loading-icon'); mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => { - expect(loadingIcon.classList.contains('hide')).toEqual(false); + expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(false); return [200, {}]; }); - expect(loadingIcon.classList.contains('hide')).toEqual(true); + expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(true); ajaxVariableList.onSaveClicked() .then(() => { - expect(loadingIcon.classList.contains('hide')).toEqual(true); + expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(true); }) .then(done) .catch(done.fail); @@ -78,11 +80,11 @@ describe('AjaxFormVariableList', () => { it('hides any previous error box', (done) => { mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200); - expect(errorBox.classList.contains('hide')).toEqual(true); + expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); ajaxVariableList.onSaveClicked() .then(() => { - expect(errorBox.classList.contains('hide')).toEqual(true); + expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); }) .then(done) .catch(done.fail); @@ -103,17 +105,39 @@ describe('AjaxFormVariableList', () => { .catch(done.fail); }); + it('hides secret values', (done) => { + mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200, {}); + + const row = container.querySelector('.js-row:first-child'); + const valueInput = row.querySelector('.js-ci-variable-input-value'); + const valuePlaceholder = row.querySelector('.js-secret-value-placeholder'); + + valueInput.value = 'bar'; + $(valueInput).trigger('input'); + + expect(valuePlaceholder.classList.contains(HIDE_CLASS)).toBe(true); + expect(valueInput.classList.contains(HIDE_CLASS)).toBe(false); + + ajaxVariableList.onSaveClicked() + .then(() => { + expect(valuePlaceholder.classList.contains(HIDE_CLASS)).toBe(false); + expect(valueInput.classList.contains(HIDE_CLASS)).toBe(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); + expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); ajaxVariableList.onSaveClicked() .then(() => { - expect(errorBox.classList.contains('hide')).toEqual(false); + expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(false); expect(errorBox.textContent.trim().replace(/\n+\s+/m, ' ')).toEqual(`Validation failed ${validationError}`); }) .then(done) @@ -123,11 +147,11 @@ describe('AjaxFormVariableList', () => { it('shows flash message when request fails', (done) => { mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(500); - expect(errorBox.classList.contains('hide')).toEqual(true); + expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); ajaxVariableList.onSaveClicked() .then(() => { - expect(errorBox.classList.contains('hide')).toEqual(true); + expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); }) .then(done) .catch(done.fail); @@ -170,9 +194,9 @@ describe('AjaxFormVariableList', () => { const valueInput = row.querySelector('.js-ci-variable-input-value'); keyInput.value = 'foo'; - keyInput.dispatchEvent(new Event('input')); + $(keyInput).trigger('input'); valueInput.value = 'bar'; - valueInput.dispatchEvent(new Event('input')); + $(valueInput).trigger('input'); expect(idInput.value).toEqual(''); 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 8acb346901f..cac785fd3c6 100644 --- a/spec/javascripts/ci_variable_list/ci_variable_list_spec.js +++ b/spec/javascripts/ci_variable_list/ci_variable_list_spec.js @@ -1,6 +1,8 @@ import VariableList from '~/ci_variable_list/ci_variable_list'; import getSetTimeoutPromise from '../helpers/set_timeout_promise_helper'; +const HIDE_CLASS = 'hide'; + describe('VariableList', () => { preloadFixtures('pipeline_schedules/edit.html.raw'); preloadFixtures('pipeline_schedules/edit_with_variables.html.raw'); @@ -92,14 +94,14 @@ describe('VariableList', () => { const $inputValue = $row.find('.js-ci-variable-input-value'); const $placeholder = $row.find('.js-secret-value-placeholder'); - expect($placeholder.hasClass('hide')).toBe(false); - expect($inputValue.hasClass('hide')).toBe(true); + expect($placeholder.hasClass(HIDE_CLASS)).toBe(false); + expect($inputValue.hasClass(HIDE_CLASS)).toBe(true); // Reveal values $wrapper.find('.js-secret-value-reveal-button').click(); - expect($placeholder.hasClass('hide')).toBe(true); - expect($inputValue.hasClass('hide')).toBe(false); + expect($placeholder.hasClass(HIDE_CLASS)).toBe(true); + expect($inputValue.hasClass(HIDE_CLASS)).toBe(false); }); }); }); @@ -179,4 +181,35 @@ describe('VariableList', () => { expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3); }); }); + + describe('hideValues', () => { + beforeEach(() => { + loadFixtures('projects/ci_cd_settings.html.raw'); + $wrapper = $('.js-ci-variable-list-section'); + + variableList = new VariableList({ + container: $wrapper, + formField: 'variables', + }); + variableList.init(); + }); + + it('should hide value input and show placeholder stars', () => { + const $row = $wrapper.find('.js-row'); + const $inputValue = $row.find('.js-ci-variable-input-value'); + const $placeholder = $row.find('.js-secret-value-placeholder'); + + $row.find('.js-ci-variable-input-value') + .val('foo') + .trigger('input'); + + expect($placeholder.hasClass(HIDE_CLASS)).toBe(true); + expect($inputValue.hasClass(HIDE_CLASS)).toBe(false); + + variableList.hideValues(); + + expect($placeholder.hasClass(HIDE_CLASS)).toBe(false); + expect($inputValue.hasClass(HIDE_CLASS)).toBe(true); + }); + }); }); diff --git a/spec/javascripts/commits_spec.js b/spec/javascripts/commits_spec.js index 44ec9e4eabf..1daccc8dd02 100644 --- a/spec/javascripts/commits_spec.js +++ b/spec/javascripts/commits_spec.js @@ -4,6 +4,8 @@ import axios from '~/lib/utils/axios_utils'; import CommitsList from '~/commits'; describe('Commits List', () => { + let commitsList; + beforeEach(() => { setFixtures(` <form class="commits-search-form" action="/h5bp/html5-boilerplate/commits/master"> @@ -11,6 +13,7 @@ describe('Commits List', () => { </form> <ol id="commits-list"></ol> `); + commitsList = new CommitsList(25); }); it('should be defined', () => { @@ -19,7 +22,7 @@ describe('Commits List', () => { describe('processCommits', () => { it('should join commit headers', () => { - CommitsList.$contentList = $(` + commitsList.$contentList = $(` <div> <li class="commit-header" data-day="2016-09-20"> <span class="day">20 Sep, 2016</span> @@ -39,7 +42,7 @@ describe('Commits List', () => { // The last commit header should be removed // since the previous one has the same data-day value. - expect(CommitsList.processCommits(data).find('li.commit-header').length).toBe(0); + expect(commitsList.processCommits(data).find('li.commit-header').length).toBe(0); }); }); @@ -48,8 +51,7 @@ describe('Commits List', () => { let mock; beforeEach(() => { - CommitsList.init(25); - CommitsList.searchField.val(''); + commitsList.searchField.val(''); spyOn(history, 'replaceState').and.stub(); mock = new MockAdapter(axios); @@ -66,11 +68,11 @@ describe('Commits List', () => { }); it('should save the last search string', (done) => { - CommitsList.searchField.val('GitLab'); - CommitsList.filterResults() + commitsList.searchField.val('GitLab'); + commitsList.filterResults() .then(() => { expect(ajaxSpy).toHaveBeenCalled(); - expect(CommitsList.lastSearch).toEqual('GitLab'); + expect(commitsList.lastSearch).toEqual('GitLab'); done(); }) @@ -78,10 +80,10 @@ describe('Commits List', () => { }); it('should not make ajax call if the input does not change', (done) => { - CommitsList.filterResults() + commitsList.filterResults() .then(() => { expect(ajaxSpy).not.toHaveBeenCalled(); - expect(CommitsList.lastSearch).toEqual(''); + expect(commitsList.lastSearch).toEqual(''); done(); }) diff --git a/spec/javascripts/importer_status_spec.js b/spec/javascripts/importer_status_spec.js index bb49c576e91..71a2cd51f63 100644 --- a/spec/javascripts/importer_status_spec.js +++ b/spec/javascripts/importer_status_spec.js @@ -3,9 +3,18 @@ import axios from '~/lib/utils/axios_utils'; import MockAdapter from 'axios-mock-adapter'; describe('Importer Status', () => { + let instance; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + describe('addToImport', () => { - let instance; - let mock; const importUrl = '/import_url'; beforeEach(() => { @@ -21,11 +30,6 @@ describe('Importer Status', () => { spyOn(ImporterStatus.prototype, 'initStatusPage').and.callFake(() => {}); spyOn(ImporterStatus.prototype, 'setAutoUpdate').and.callFake(() => {}); instance = new ImporterStatus('', importUrl); - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); }); it('sets table row to active after post request', (done) => { @@ -44,4 +48,60 @@ describe('Importer Status', () => { .catch(done.fail); }); }); + + describe('autoUpdate', () => { + const jobsUrl = '/jobs_url'; + + beforeEach(() => { + const div = document.createElement('div'); + div.innerHTML = ` + <div id="project_1"> + <div class="job-status"> + </div> + </div> + `; + + document.body.appendChild(div); + + spyOn(ImporterStatus.prototype, 'initStatusPage').and.callFake(() => {}); + spyOn(ImporterStatus.prototype, 'setAutoUpdate').and.callFake(() => {}); + instance = new ImporterStatus(jobsUrl); + }); + + function setupMock(importStatus) { + mock.onGet(jobsUrl).reply(200, [{ + id: 1, + import_status: importStatus, + }]); + } + + function expectJobStatus(done, status) { + instance.autoUpdate() + .then(() => { + expect(document.querySelector('#project_1').innerText.trim()).toEqual(status); + done(); + }) + .catch(done.fail); + } + + it('sets the job status to done', (done) => { + setupMock('finished'); + expectJobStatus(done, 'done'); + }); + + it('sets the job status to scheduled', (done) => { + setupMock('scheduled'); + expectJobStatus(done, 'scheduled'); + }); + + it('sets the job status to started', (done) => { + setupMock('started'); + expectJobStatus(done, 'started'); + }); + + it('sets the job status to custom status', (done) => { + setupMock('custom status'); + expectJobStatus(done, 'custom status'); + }); + }); }); diff --git a/spec/javascripts/pipeline_schedules/interval_pattern_input_spec.js b/spec/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js index 040d14efed2..4655e29eed0 100644 --- a/spec/javascripts/pipeline_schedules/interval_pattern_input_spec.js +++ b/spec/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; -import IntervalPatternInput from '~/pipeline_schedules/components/interval_pattern_input.vue'; +import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue'; Vue.use(Translate); diff --git a/spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js b/spec/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js index ed481cb60a1..f95a7cef18a 100644 --- a/spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js +++ b/spec/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import Cookies from 'js-cookie'; -import PipelineSchedulesCallout from '~/pipeline_schedules/components/pipeline_schedules_callout.vue'; +import PipelineSchedulesCallout from '~/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue'; const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout); const cookieKey = 'pipeline_schedules_callout_dismissed'; diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js index 33f20ab132d..c89e863d904 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js @@ -1,17 +1,24 @@ import Vue from 'vue'; -import notAllowedComponent from '~/vue_merge_request_widget/components/states/mr_widget_not_allowed'; +import notAllowedComponent from '~/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue'; +import mountComponent from '../../../helpers/vue_mount_component_helper'; describe('MRWidgetNotAllowed', () => { - describe('template', () => { + let vm; + beforeEach(() => { const Component = Vue.extend(notAllowedComponent); - const vm = new Component({ - el: document.createElement('div'), - }); - it('should have correct elements', () => { - expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); - expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy(); - expect(vm.$el.innerText).toContain('Ready to be merged automatically.'); - expect(vm.$el.innerText).toContain('Ask someone with write access to this repository to merge this request'); - }); + vm = mountComponent(Component); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders success icon', () => { + expect(vm.$el.querySelector('.ci-status-icon-success')).not.toBe(null); + }); + + it('renders informative text', () => { + expect(vm.$el.innerText).toContain('Ready to be merged automatically.'); + expect(vm.$el.innerText).toContain('Ask someone with write access to this repository to merge this request'); }); }); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js index d0702f9f503..edab26286bc 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js @@ -1,16 +1,23 @@ import Vue from 'vue'; -import pipelineBlockedComponent from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked'; +import pipelineBlockedComponent from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue'; +import mountComponent from '../../../helpers/vue_mount_component_helper'; describe('MRWidgetPipelineBlocked', () => { - describe('template', () => { + let vm; + beforeEach(() => { const Component = Vue.extend(pipelineBlockedComponent); - const vm = new Component({ - el: document.createElement('div'), - }); - it('should have correct elements', () => { - expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); - expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy(); - expect(vm.$el.innerText).toContain('Pipeline blocked. The pipeline for this merge request requires a manual action to proceed'); - }); + vm = mountComponent(Component); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders warning icon', () => { + expect(vm.$el.querySelector('.ci-status-icon-warning')).not.toBe(null); + }); + + it('renders information text', () => { + expect(vm.$el.textContent.trim().replace(/[\r\n]+/g, ' ')).toContain('Pipeline blocked. The pipeline for this merge request requires a manual action to proceed'); }); }); diff --git a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js index cd00d0a39a3..45035effe81 100644 --- a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js @@ -295,6 +295,15 @@ describe('mrWidgetOptions', () => { expect(notify.notifyMe).not.toHaveBeenCalled(); }); + + it('should not notify if no pipeline provided', () => { + vm.handleNotification({ + ...data, + pipeline: undefined, + }); + + expect(notify.notifyMe).not.toHaveBeenCalled(); + }); }); describe('resumePolling', () => { diff --git a/spec/javascripts/vue_shared/components/gl_modal_spec.js b/spec/javascripts/vue_shared/components/gl_modal_spec.js new file mode 100644 index 00000000000..d6148cb785b --- /dev/null +++ b/spec/javascripts/vue_shared/components/gl_modal_spec.js @@ -0,0 +1,192 @@ +import Vue from 'vue'; +import GlModal from '~/vue_shared/components/gl_modal.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const modalComponent = Vue.extend(GlModal); + +describe('GlModal', () => { + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('props', () => { + describe('with id', () => { + const props = { + id: 'my-modal', + }; + + beforeEach(() => { + vm = mountComponent(modalComponent, props); + }); + + it('assigns the id to the modal', () => { + expect(vm.$el.id).toBe(props.id); + }); + }); + + describe('without id', () => { + beforeEach(() => { + vm = mountComponent(modalComponent, { }); + }); + + it('does not add an id attribute to the modal', () => { + expect(vm.$el.hasAttribute('id')).toBe(false); + }); + }); + + describe('with headerTitleText', () => { + const props = { + headerTitleText: 'my title text', + }; + + beforeEach(() => { + vm = mountComponent(modalComponent, props); + }); + + it('sets the modal title', () => { + const modalTitle = vm.$el.querySelector('.modal-title'); + expect(modalTitle.innerHTML.trim()).toBe(props.headerTitleText); + }); + }); + + describe('with footerPrimaryButtonVariant', () => { + const props = { + footerPrimaryButtonVariant: 'danger', + }; + + beforeEach(() => { + vm = mountComponent(modalComponent, props); + }); + + it('sets the primary button class', () => { + const primaryButton = vm.$el.querySelector('.modal-footer button:last-of-type'); + expect(primaryButton).toHaveClass(`btn-${props.footerPrimaryButtonVariant}`); + }); + }); + + describe('with footerPrimaryButtonText', () => { + const props = { + footerPrimaryButtonText: 'my button text', + }; + + beforeEach(() => { + vm = mountComponent(modalComponent, props); + }); + + it('sets the primary button text', () => { + const primaryButton = vm.$el.querySelector('.modal-footer button:last-of-type'); + expect(primaryButton.innerHTML.trim()).toBe(props.footerPrimaryButtonText); + }); + }); + }); + + it('works with data-toggle="modal"', (done) => { + setFixtures(` + <button id="modal-button" data-toggle="modal" data-target="#my-modal"></button> + <div id="modal-container"></div> + `); + + const modalContainer = document.getElementById('modal-container'); + const modalButton = document.getElementById('modal-button'); + vm = mountComponent(modalComponent, { + id: 'my-modal', + }, modalContainer); + $(vm.$el).on('shown.bs.modal', () => done()); + + modalButton.click(); + }); + + describe('methods', () => { + const dummyEvent = 'not really an event'; + + beforeEach(() => { + vm = mountComponent(modalComponent, { }); + spyOn(vm, '$emit'); + }); + + describe('emitCancel', () => { + it('emits a cancel event', () => { + vm.emitCancel(dummyEvent); + + expect(vm.$emit).toHaveBeenCalledWith('cancel', dummyEvent); + }); + }); + + describe('emitSubmit', () => { + it('emits a submit event', () => { + vm.emitSubmit(dummyEvent); + + expect(vm.$emit).toHaveBeenCalledWith('submit', dummyEvent); + }); + }); + }); + + describe('slots', () => { + const slotContent = 'this should go into the slot'; + const modalWithSlot = (slotName) => { + let template; + if (slotName) { + template = ` + <gl-modal> + <template slot="${slotName}">${slotContent}</template> + </gl-modal> + `; + } else { + template = `<gl-modal>${slotContent}</gl-modal>`; + } + + return Vue.extend({ + components: { + GlModal, + }, + template, + }); + }; + + describe('default slot', () => { + beforeEach(() => { + vm = mountComponent(modalWithSlot()); + }); + + it('sets the modal body', () => { + const modalBody = vm.$el.querySelector('.modal-body'); + expect(modalBody.innerHTML).toBe(slotContent); + }); + }); + + describe('header slot', () => { + beforeEach(() => { + vm = mountComponent(modalWithSlot('header')); + }); + + it('sets the modal header', () => { + const modalHeader = vm.$el.querySelector('.modal-header'); + expect(modalHeader.innerHTML).toBe(slotContent); + }); + }); + + describe('title slot', () => { + beforeEach(() => { + vm = mountComponent(modalWithSlot('title')); + }); + + it('sets the modal title', () => { + const modalTitle = vm.$el.querySelector('.modal-title'); + expect(modalTitle.innerHTML).toBe(slotContent); + }); + }); + + describe('footer slot', () => { + beforeEach(() => { + vm = mountComponent(modalWithSlot('footer')); + }); + + it('sets the modal footer', () => { + const modalFooter = vm.$el.querySelector('.modal-footer'); + expect(modalFooter.innerHTML).toBe(slotContent); + }); + }); + }); +}); diff --git a/spec/lib/banzai/filter/html_entity_filter_spec.rb b/spec/lib/banzai/filter/html_entity_filter_spec.rb index 91e18d876d5..1d98fc0d5db 100644 --- a/spec/lib/banzai/filter/html_entity_filter_spec.rb +++ b/spec/lib/banzai/filter/html_entity_filter_spec.rb @@ -3,17 +3,12 @@ require 'spec_helper' describe Banzai::Filter::HtmlEntityFilter do include FilterSpecHelper - let(:unescaped) { 'foo <strike attr="foo">&&&</strike>' } - let(:escaped) { 'foo <strike attr="foo">&&&</strike>' } + let(:unescaped) { 'foo <strike attr="foo">&&&</strike>' } + let(:escaped) { 'foo <strike attr="foo">&&amp;&</strike>' } it 'converts common entities to their HTML-escaped equivalents' do output = filter(unescaped) expect(output).to eq(escaped) end - - it 'does not double-escape' do - escaped = ERB::Util.html_escape("Merge branch 'blabla' into 'master'") - expect(filter(escaped)).to eq(escaped) - end end diff --git a/spec/lib/gitlab/asciidoc_spec.rb b/spec/lib/gitlab/asciidoc_spec.rb index f668f78c2b8..2a0e19ae796 100644 --- a/spec/lib/gitlab/asciidoc_spec.rb +++ b/spec/lib/gitlab/asciidoc_spec.rb @@ -95,6 +95,14 @@ module Gitlab expect(render(input, context)).to include('<p><code data-math-style="inline" class="code math js-render-math">2+2</code> is 4</p>') end end + + context 'outfilesuffix' do + it 'defaults to adoc' do + output = render("Inter-document reference <<README.adoc#>>", context) + + expect(output).to include("a href=\"README.adoc\"") + end + end end def render(*args) diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index 3c3697e7aa9..19d3f55501e 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -18,8 +18,9 @@ describe Gitlab::GitAccess do 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') } + let(:changes) { '_any' } + let(:push_access_check) { access.check('git-receive-pack', changes) } + let(:pull_access_check) { access.check('git-upload-pack', changes) } describe '#check with single protocols allowed' do def disable_protocol(protocol) @@ -646,6 +647,20 @@ describe Gitlab::GitAccess do end end + describe 'check LFS integrity' do + let(:changes) { ['6f6d7e7ed 570e7b2ab refs/heads/master', '6f6d7e7ed 570e7b2ab refs/heads/feature'] } + + before do + project.add_developer(user) + end + + it 'checks LFS integrity only for first change' do + expect_any_instance_of(Gitlab::Checks::LfsIntegrity).to receive(:objects_missing?).exactly(1).times + + push_access_check + end + end + describe '#check_push_access!' do before do merge_into_protected_branch diff --git a/spec/lib/gitlab/ldap/config_spec.rb b/spec/lib/gitlab/ldap/config_spec.rb index ca2213cd112..e10837578a8 100644 --- a/spec/lib/gitlab/ldap/config_spec.rb +++ b/spec/lib/gitlab/ldap/config_spec.rb @@ -5,6 +5,14 @@ describe Gitlab::LDAP::Config do let(:config) { described_class.new('ldapmain') } + describe '.servers' do + it 'returns empty array if no server information is available' do + allow(Gitlab.config).to receive(:ldap).and_return('enabled' => false) + + expect(described_class.servers).to eq [] + end + end + describe '#initialize' do it 'requires a provider' do expect { described_class.new }.to raise_error ArgumentError diff --git a/spec/lib/gitlab/o_auth/user_spec.rb b/spec/lib/gitlab/o_auth/user_spec.rb index 03e0a9e2a03..b8455403bdb 100644 --- a/spec/lib/gitlab/o_auth/user_spec.rb +++ b/spec/lib/gitlab/o_auth/user_spec.rb @@ -724,6 +724,10 @@ describe Gitlab::OAuth::User do it "does not update the user location" do expect(gl_user.location).not_to eq(info_hash[:address][:country]) end + + it 'does not create associated user synced attributes metadata' do + expect(gl_user.user_synced_attributes_metadata).to be_nil + end end end diff --git a/spec/lib/gitlab/profiler_spec.rb b/spec/lib/gitlab/profiler_spec.rb index 4a43dbb2371..f02b1cf55fb 100644 --- a/spec/lib/gitlab/profiler_spec.rb +++ b/spec/lib/gitlab/profiler_spec.rb @@ -53,6 +53,15 @@ describe Gitlab::Profiler do described_class.profile('/', user: user) end + context 'when providing a user without a personal access token' do + it 'raises an error' do + user = double(:user) + allow(user).to receive_message_chain(:personal_access_tokens, :active, :pluck).and_return([]) + + expect { described_class.profile('/', user: user) }.to raise_error('Your user must have a personal_access_token') + end + end + it 'uses the private_token for auth if both it and user are set' do user = double(:user) user_token = 'user' diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb index 8b54d72d6f7..a1079e54975 100644 --- a/spec/lib/gitlab/regex_spec.rb +++ b/spec/lib/gitlab/regex_spec.rb @@ -18,6 +18,7 @@ describe Gitlab::Regex do subject { described_class.environment_name_regex } it { is_expected.to match('foo') } + it { is_expected.to match('a') } it { is_expected.to match('foo-1') } it { is_expected.to match('FOO') } it { is_expected.to match('foo/1') } @@ -25,6 +26,10 @@ describe Gitlab::Regex do it { is_expected.not_to match('9&foo') } it { is_expected.not_to match('foo-^') } it { is_expected.not_to match('!!()()') } + it { is_expected.not_to match('/foo') } + it { is_expected.not_to match('foo/') } + it { is_expected.not_to match('/foo/') } + it { is_expected.not_to match('/') } end describe '.environment_slug_regex' do diff --git a/spec/models/identity_spec.rb b/spec/models/identity_spec.rb index 7c66c98231b..a5ce245c21d 100644 --- a/spec/models/identity_spec.rb +++ b/spec/models/identity_spec.rb @@ -70,5 +70,38 @@ describe Identity do end end end + + context 'after_destroy' do + let!(:user) { create(:user) } + let(:ldap_identity) { create(:identity, provider: 'ldapmain', extern_uid: 'uid=john smith,ou=people,dc=example,dc=com', user: user) } + let(:ldap_user_synced_attributes) { { provider: 'ldapmain', name_synced: true, email_synced: true } } + let(:other_provider_user_synced_attributes) { { provider: 'other', name_synced: true, email_synced: true } } + + describe 'if user synced attributes metadada provider' do + context 'matches the identity provider ' do + it 'removes the user synced attributes' do + user.create_user_synced_attributes_metadata(ldap_user_synced_attributes) + + expect(user.user_synced_attributes_metadata.provider).to eq 'ldapmain' + + ldap_identity.destroy + + expect(user.reload.user_synced_attributes_metadata).to be_nil + end + end + + context 'does not matche the identity provider' do + it 'does not remove the user synced attributes' do + user.create_user_synced_attributes_metadata(other_provider_user_synced_attributes) + + expect(user.user_synced_attributes_metadata.provider).to eq 'other' + + ldap_identity.destroy + + expect(user.reload.user_synced_attributes_metadata.provider).to eq 'other' + end + end + end + end end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index a6d48e369ac..0bc07dc7a85 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -873,6 +873,18 @@ describe Repository do expect(repository.license_key).to be_nil end + it 'returns nil when the commit SHA does not exist' do + allow(repository.head_commit).to receive(:sha).and_return('1' * 40) + + expect(repository.license_key).to be_nil + end + + it 'returns nil when master does not exist' do + repository.rm_branch(user, 'master') + + expect(repository.license_key).to be_nil + end + it 'returns the license key' do repository.create_file(user, 'LICENSE', Licensee::License.new('mit').content, diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 76a6aef39cc..1815696a8a0 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -893,6 +893,14 @@ describe User do end end + describe '.find_for_database_authentication' do + it 'strips whitespace from login' do + user = create(:user) + + expect(described_class.find_for_database_authentication({ login: " #{user.username} " })).to eq user + end + end + describe '.find_by_any_email' do it 'finds by primary email' do user = create(:user, email: 'foo@example.com') diff --git a/spec/requests/api/project_import_spec.rb b/spec/requests/api/project_import_spec.rb new file mode 100644 index 00000000000..987f6e26971 --- /dev/null +++ b/spec/requests/api/project_import_spec.rb @@ -0,0 +1,102 @@ +require 'spec_helper' + +describe API::ProjectImport do + let(:export_path) { "#{Dir.tmpdir}/project_export_spec" } + let(:user) { create(:user) } + let(:file) { File.join(Rails.root, 'spec', 'features', 'projects', 'import_export', 'test_project_export.tar.gz') } + let(:namespace) { create(:group) } + before do + allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path) + + namespace.add_owner(user) + end + + after do + FileUtils.rm_rf(export_path, secure: true) + end + + describe 'POST /projects/import' do + it 'schedules an import using a namespace' do + stub_import(namespace) + + post api('/projects/import', user), path: 'test-import', file: fixture_file_upload(file), namespace: namespace.id + + expect(response).to have_gitlab_http_status(201) + end + + it 'schedules an import using the namespace path' do + stub_import(namespace) + + post api('/projects/import', user), path: 'test-import', file: fixture_file_upload(file), namespace: namespace.full_path + + expect(response).to have_gitlab_http_status(201) + end + + it 'schedules an import at the user namespace level' do + stub_import(user.namespace) + + post api('/projects/import', user), path: 'test-import2', file: fixture_file_upload(file) + + expect(response).to have_gitlab_http_status(201) + end + + it 'schedules an import at the user namespace level' do + expect_any_instance_of(Project).not_to receive(:import_schedule) + expect(::Projects::CreateService).not_to receive(:new) + + post api('/projects/import', user), namespace: 'nonexistent', path: 'test-import2', file: fixture_file_upload(file) + + expect(response).to have_gitlab_http_status(404) + expect(json_response['message']).to eq('404 Namespace Not Found') + end + + it 'does not schedule an import if the user has no permission to the namespace' do + expect_any_instance_of(Project).not_to receive(:import_schedule) + + post(api('/projects/import', create(:user)), + path: 'test-import3', + file: fixture_file_upload(file), + namespace: namespace.full_path) + + expect(response).to have_gitlab_http_status(404) + expect(json_response['message']).to eq('404 Namespace Not Found') + end + + it 'does not schedule an import if the user uploads no valid file' do + expect_any_instance_of(Project).not_to receive(:import_schedule) + + post api('/projects/import', user), path: 'test-import3', file: './random/test' + + expect(response).to have_gitlab_http_status(400) + expect(json_response['error']).to eq('file is invalid') + end + + def stub_import(namespace) + expect_any_instance_of(Project).to receive(:import_schedule) + expect(::Projects::CreateService).to receive(:new).with(user, hash_including(namespace_id: namespace.id)).and_call_original + end + end + + describe 'GET /projects/:id/import' do + it 'returns the import status' do + project = create(:project, import_status: 'started') + project.add_master(user) + + get api("/projects/#{project.id}/import", user) + + expect(response).to have_gitlab_http_status(200) + expect(json_response).to include('import_status' => 'started') + end + + it 'returns the import status and the error if failed' do + project = create(:project, import_status: 'failed', import_error: 'error') + project.add_master(user) + + get api("/projects/#{project.id}/import", user) + + expect(response).to have_gitlab_http_status(200) + expect(json_response).to include('import_status' => 'failed', + 'import_error' => 'error') + end + end +end diff --git a/spec/requests/openid_connect_spec.rb b/spec/requests/openid_connect_spec.rb index 5d349f45a33..de829011e58 100644 --- a/spec/requests/openid_connect_spec.rb +++ b/spec/requests/openid_connect_spec.rb @@ -81,7 +81,7 @@ describe 'OpenID Connect requests' do it 'includes all user information and group memberships' do request_user_info - expect(json_response).to eq({ + expect(json_response).to match(a_hash_including({ 'sub' => hashed_subject, 'name' => 'Alice', 'nickname' => 'alice', @@ -90,13 +90,12 @@ describe 'OpenID Connect requests' do 'website' => 'https://example.com', 'profile' => 'http://localhost/alice', 'picture' => "http://localhost/uploads/-/system/user/avatar/#{user.id}/dk.png", - 'groups' => - if Group.supports_nested_groups? - ['group1', 'group2/group3', 'group2/group3/group4'] - else - ['group1', 'group2/group3'] - end - }) + 'groups' => anything + })) + + expected_groups = %w[group1 group2/group3] + expected_groups << 'group2/group3/group4' if Group.supports_nested_groups? + expect(json_response['groups']).to match_array(expected_groups) end end diff --git a/spec/requests/rack_attack_global_spec.rb b/spec/requests/rack_attack_global_spec.rb index 0fec14d0cce..b18e922b063 100644 --- a/spec/requests/rack_attack_global_spec.rb +++ b/spec/requests/rack_attack_global_spec.rb @@ -22,6 +22,7 @@ describe 'Rack Attack global throttles' do let(:url_that_does_not_require_authentication) { '/users/sign_in' } let(:url_that_requires_authentication) { '/dashboard/snippets' } + let(:url_api_internal) { '/api/v4/internal/check' } let(:api_partial_url) { '/todos' } around do |example| @@ -172,6 +173,15 @@ describe 'Rack Attack global throttles' do get url_that_does_not_require_authentication expect(response).to have_http_status 200 end + + context 'when the request is to the api internal endpoints' do + it 'allows requests over the rate limit' do + (1 + requests_per_period).times do + get url_api_internal, secret_token: Gitlab::Shell.secret_token + expect(response).to have_http_status 200 + end + end + end end context 'when the throttle is disabled' do diff --git a/spec/services/merge_requests/build_service_spec.rb b/spec/services/merge_requests/build_service_spec.rb index a0d0a4fd81b..3a935d98540 100644 --- a/spec/services/merge_requests/build_service_spec.rb +++ b/spec/services/merge_requests/build_service_spec.rb @@ -1,6 +1,7 @@ require 'spec_helper' describe MergeRequests::BuildService do + using RSpec::Parameterized::TableSyntax include RepoHelpers let(:project) { create(:project, :repository) } @@ -111,6 +112,7 @@ describe MergeRequests::BuildService do context 'one commit in the diff' do let(:commits) { Commit.decorate([commit_1], project) } + let(:commit_description) { commit_1.safe_message.split(/\n+/, 2).last } before do stub_compare @@ -125,7 +127,7 @@ describe MergeRequests::BuildService do end it 'uses the description of the commit as the description of the merge request' do - expect(merge_request.description).to eq(commit_1.safe_message.split(/\n+/, 2).last) + expect(merge_request.description).to eq(commit_description) end context 'merge request already has a description set' do @@ -148,68 +150,32 @@ describe MergeRequests::BuildService do end end - context 'branch starts with issue IID followed by a hyphen' do - let(:source_branch) { "#{issue.iid}-fix-issue" } - - it 'appends "Closes #$issue-iid" to the description' do - expect(merge_request.description).to eq("#{commit_1.safe_message.split(/\n+/, 2).last}\n\nCloses ##{issue.iid}") + context 'when the source branch matches an issue' do + where(:issue_tracker, :source_branch, :closing_message) do + :jira | 'FOO-123-fix-issue' | 'Closes FOO-123' + :jira | 'fix-issue' | nil + :custom_issue_tracker | '123-fix-issue' | 'Closes #123' + :custom_issue_tracker | 'fix-issue' | nil + :internal | '123-fix-issue' | 'Closes #123' + :internal | 'fix-issue' | nil end - context 'merge request already has a description set' do - let(:description) { 'Merge request description' } - - it 'appends "Closes #$issue-iid" to the description' do - expect(merge_request.description).to eq("#{description}\n\nCloses ##{issue.iid}") + with_them do + before do + if issue_tracker == :internal + issue.update!(iid: 123) + else + create(:"#{issue_tracker}_service", project: project) + end end - end - context 'commit has no description' do - let(:commits) { Commit.decorate([commit_2], project) } + it 'appends the closing description' do + expected_description = [commit_description, closing_message].compact.join("\n\n") - it 'sets the description to "Closes #$issue-iid"' do - expect(merge_request.description).to eq("Closes ##{issue.iid}") + expect(merge_request.description).to eq(expected_description) end end end - - context 'branch starts with numeric characters followed by a hyphen with no issue tracker' do - let(:source_branch) { '12345-fix-issue' } - - before do - allow(project).to receive(:external_issue_tracker).and_return(false) - allow(project).to receive(:issues_enabled?).and_return(false) - end - - it 'uses the title of the commit as the title of the merge request' do - expect(merge_request.title).to eq(commit_1.safe_message.split("\n").first) - end - - it 'uses the description of the commit as the description of the merge request' do - commit_description = commit_1.safe_message.split(/\n+/, 2).last - - expect(merge_request.description).to eq("#{commit_description}") - end - end - - context 'branch starts with JIRA-formatted external issue IID followed by a hyphen' do - let(:source_branch) { 'EXMPL-12345-fix-issue' } - - before do - allow(project).to receive(:external_issue_tracker).and_return(true) - allow(project).to receive(:issues_enabled?).and_return(false) - allow(project).to receive(:external_issue_reference_pattern).and_return(IssueTrackerService.reference_pattern) - end - - it 'uses the title of the commit as the title of the merge request' do - expect(merge_request.title).to eq(commit_1.safe_message.split("\n").first) - end - - it 'uses the description of the commit as the description of the merge request and appends the closes text' do - commit_description = commit_1.safe_message.split(/\n+/, 2).last - - expect(merge_request.description).to eq("#{commit_description}\n\nCloses EXMPL-12345") - end - end end context 'more than one commit in the diff' do @@ -239,90 +205,62 @@ describe MergeRequests::BuildService do end end - context 'branch starts with GitLab issue IID followed by a hyphen' do - let(:source_branch) { "#{issue.iid}-fix-issue" } - - it 'sets the title to: Resolves "$issue-title"' do - expect(merge_request.title).to eq("Resolve \"#{issue.title}\"") + context 'when the source branch matches an issue' do + where(:issue_tracker, :source_branch, :title, :closing_message) do + :jira | 'FOO-123-fix-issue' | 'Resolve FOO-123 "Fix issue"' | 'Closes FOO-123' + :jira | 'fix-issue' | 'Fix issue' | nil + :custom_issue_tracker | '123-fix-issue' | 'Resolve #123 "Fix issue"' | 'Closes #123' + :custom_issue_tracker | 'fix-issue' | 'Fix issue' | nil + :internal | '123-fix-issue' | 'Resolve "A bug"' | 'Closes #123' + :internal | 'fix-issue' | 'Fix issue' | nil + :internal | '124-fix-issue' | '124 fix issue' | nil end - context 'when issue is not accessible to user' do + with_them do before do - project.team.truncate - end - - it 'uses branch title as the merge request title' do - expect(merge_request.title).to eq("#{issue.iid} fix issue") + if issue_tracker == :internal + issue.update!(iid: 123) + else + create(:"#{issue_tracker}_service", project: project) + end end - end - - context 'issue does not exist' do - let(:source_branch) { "#{issue.iid.succ}-fix-issue" } - it 'uses the title of the branch as the merge request title' do - expect(merge_request.title).to eq("#{issue.iid.succ} fix issue") + it 'sets the correct title' do + expect(merge_request.title).to eq(title) end - end - - context 'issue is confidential' do - let(:issue_confidential) { true } - it 'uses the title of the branch as the merge request title' do - expect(merge_request.title).to eq("#{issue.iid} fix issue") + it 'sets the closing description' do + expect(merge_request.description).to eq(closing_message) end end end - context 'branch starts with numeric characters followed by a hyphen with no issue tracker' do - let(:source_branch) { '12345-fix-issue' } + context 'when the issue is not accessible to user' do + let(:source_branch) { "#{issue.iid}-fix-issue" } before do - allow(project).to receive(:external_issue_tracker).and_return(false) - allow(project).to receive(:issues_enabled?).and_return(false) + project.team.truncate end - it 'sets the title to the humanized branch title' do - expect(merge_request.title).to eq('12345 fix issue') + it 'uses branch title as the merge request title' do + expect(merge_request.title).to eq("#{issue.iid} fix issue") end - end - describe 'with JIRA enabled' do - before do - allow(project).to receive(:external_issue_tracker).and_return(true) - allow(project).to receive(:issues_enabled?).and_return(false) - allow(project).to receive(:external_issue_reference_pattern).and_return(IssueTrackerService.reference_pattern) + it 'does not set a description' do + expect(merge_request.description).to be_nil end + end - context 'branch does not start with JIRA-formatted external issue IID' do - let(:source_branch) { 'test-branch' } + context 'when the issue is confidential' do + let(:source_branch) { "#{issue.iid}-fix-issue" } + let(:issue_confidential) { true } - it 'sets the title to the humanized branch title' do - expect(merge_request.title).to eq('Test branch') - end + it 'uses the title of the branch as the merge request title' do + expect(merge_request.title).to eq("#{issue.iid} fix issue") end - context 'branch starts with JIRA-formatted external issue IID' do - let(:source_branch) { 'EXMPL-12345' } - - it 'sets the title to the humanized branch title' do - expect(merge_request.title).to eq('Resolve EXMPL-12345') - end - - it 'appends the closes text' do - expect(merge_request.description).to eq('Closes EXMPL-12345') - end - - context 'followed by hyphenated text' do - let(:source_branch) { 'EXMPL-12345-fix-issue' } - - it 'sets the title to the humanized branch title' do - expect(merge_request.title).to eq('Resolve EXMPL-12345 "Fix issue"') - end - - it 'appends the closes text' do - expect(merge_request.description).to eq('Closes EXMPL-12345') - end - end + it 'does not set a description' do + expect(merge_request.description).to be_nil end end end diff --git a/spec/support/factory_girl.rb b/spec/support/factory_bot.rb index c7890e49c66..c7890e49c66 100644 --- a/spec/support/factory_girl.rb +++ b/spec/support/factory_bot.rb diff --git a/spec/support/features/variable_list_shared_examples.rb b/spec/support/features/variable_list_shared_examples.rb index 4315bf5d037..0d8f7a7aae6 100644 --- a/spec/support/features/variable_list_shared_examples.rb +++ b/spec/support/features/variable_list_shared_examples.rb @@ -263,7 +263,7 @@ shared_examples 'variable list' do # 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') + expect(find('.js-ci-variable-error-box')).to have_content(/Validation failed Variables have duplicate values \(.+\)/) end end end diff --git a/spec/support/fixture_helpers.rb b/spec/support/fixture_helpers.rb index 128aaaf25fe..8854382dc6b 100644 --- a/spec/support/fixture_helpers.rb +++ b/spec/support/fixture_helpers.rb @@ -1,12 +1,12 @@ module FixtureHelpers - def fixture_file(filename) + def fixture_file(filename, dir: '') return '' if filename.blank? - File.read(expand_fixture_path(filename)) + File.read(expand_fixture_path(filename, dir: dir)) end - def expand_fixture_path(filename) - File.expand_path(Rails.root.join('spec/fixtures/', filename)) + def expand_fixture_path(filename, dir: '') + File.expand_path(Rails.root.join(dir, 'spec', 'fixtures', filename)) end end diff --git a/spec/validators/variable_duplicates_validator_spec.rb b/spec/validators/variable_duplicates_validator_spec.rb new file mode 100644 index 00000000000..0b71a67f94d --- /dev/null +++ b/spec/validators/variable_duplicates_validator_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' + +describe VariableDuplicatesValidator do + let(:validator) { described_class.new(attributes: [:variables], **options) } + + describe '#validate_each' do + let(:project) { build(:project) } + + subject { validator.validate_each(project, :variables, project.variables) } + + context 'with no scope' do + let(:options) { {} } + let(:variables) { build_list(:ci_variable, 2, project: project) } + + before do + project.variables << variables + end + + it 'does not have any errors' do + subject + + expect(project.errors.empty?).to be true + end + + context 'with duplicates' do + before do + project.variables.build(key: variables.first.key, value: 'dummy_value') + end + + it 'has a duplicate key error' do + subject + + expect(project.errors).to have_key(:variables) + end + end + end + + context 'with a scope attribute' do + let(:options) { { scope: :environment_scope } } + let(:first_variable) { build(:ci_variable, key: 'test_key', environment_scope: '*', project: project) } + let(:second_variable) { build(:ci_variable, key: 'test_key', environment_scope: 'prod', project: project) } + + before do + project.variables << first_variable + project.variables << second_variable + end + + it 'does not have any errors' do + subject + + expect(project.errors.empty?).to be true + end + + context 'with duplicates' do + before do + project.variables.build(key: second_variable.key, value: 'dummy_value', environment_scope: second_variable.environment_scope) + end + + it 'has a duplicate key error' do + subject + + expect(project.errors).to have_key(:variables) + end + end + end + end +end diff --git a/spec/workers/check_gcp_project_billing_worker_spec.rb b/spec/workers/check_gcp_project_billing_worker_spec.rb index 7b7a7c1bc44..526ecf75921 100644 --- a/spec/workers/check_gcp_project_billing_worker_spec.rb +++ b/spec/workers/check_gcp_project_billing_worker_spec.rb @@ -6,6 +6,11 @@ describe CheckGcpProjectBillingWorker do subject { described_class.new.perform('token_key') } + before do + allow(described_class).to receive(:get_billing_state) + allow_any_instance_of(described_class).to receive(:update_billing_change_counter) + end + context 'when there is a token in redis' do before do allow(described_class).to receive(:get_session_token).and_return(token) @@ -23,11 +28,8 @@ describe CheckGcpProjectBillingWorker do end it 'stores billing status in redis' do - redis_double = double - expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([double]) - expect(Gitlab::Redis::SharedState).to receive(:with).and_yield(redis_double) - expect(redis_double).to receive(:set).with(described_class.redis_shared_state_key_for(token), anything, anything) + expect(described_class).to receive(:set_billing_state).with(token, true) subject end @@ -48,7 +50,7 @@ describe CheckGcpProjectBillingWorker do context 'when there is no token in redis' do before do - allow_any_instance_of(described_class).to receive(:get_session_token).and_return(nil) + allow(described_class).to receive(:get_session_token).and_return(nil) end it 'does not call the service' do @@ -58,4 +60,57 @@ describe CheckGcpProjectBillingWorker do end end end + + describe 'billing change counter' do + subject { described_class.new.perform('token_key') } + + before do + allow(described_class).to receive(:get_session_token).and_return('bogustoken') + allow_any_instance_of(described_class).to receive(:try_obtain_lease_for).and_return('randomuuid') + allow(described_class).to receive(:set_billing_state) + end + + context 'when previous state was false' do + before do + expect(described_class).to receive(:get_billing_state).and_return(false) + end + + context 'when the current state is false' do + before do + expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([]) + end + + it 'increments the billing change counter' do + expect_any_instance_of(described_class).to receive_message_chain(:billing_changed_counter, :increment) + + subject + end + end + + context 'when the current state is true' do + before do + expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([double]) + end + + it 'increments the billing change counter' do + expect_any_instance_of(described_class).to receive_message_chain(:billing_changed_counter, :increment) + + subject + end + end + end + + context 'when previous state was true' do + before do + expect(described_class).to receive(:get_billing_state).and_return(true) + expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([double]) + end + + it 'increment the billing change counter' do + expect_any_instance_of(described_class).to receive_message_chain(:billing_changed_counter, :increment) + + subject + end + 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 094d6791505..0ac8885405e 100644 --- a/vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml @@ -321,7 +321,15 @@ production: # Extract "MAJOR.MINOR" from CI_SERVER_VERSION and generate "MAJOR-MINOR-stable" SAST_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/') - docker run --volume "$PWD:/code" \ + # Deprecation notice for CONFIDENCE_LEVEL variable + if [ -z "$SAST_CONFIDENCE_LEVEL" -a "$CONFIDENCE_LEVEL" ]; then + SAST_CONFIDENCE_LEVEL="$CONFIDENCE_LEVEL" + echo "WARNING: CONFIDENCE_LEVEL is deprecated and MUST be replaced with SAST_CONFIDENCE_LEVEL" + fi + + docker run --env SAST_CONFIDENCE_LEVEL="${SAST_CONFIDENCE_LEVEL:-3}" \ + --env SAST_DISABLE_REMOTE_CHECKS="${SAST_DISABLE_REMOTE_CHECKS:-false}" \ + --volume "$PWD:/code" \ --volume /var/run/docker.sock:/var/run/docker.sock \ "registry.gitlab.com/gitlab-org/security-products/sast:$SAST_VERSION" /app/bin/run /code ;; |