diff options
author | Robert Speicher <robert@gitlab.com> | 2018-03-12 16:00:48 +0000 |
---|---|---|
committer | Robert Speicher <robert@gitlab.com> | 2018-03-12 16:00:48 +0000 |
commit | b80c2287958d10626b49a483c8cc9ea306e7aea0 (patch) | |
tree | c80be7682dd6134ab5c8044e8aec8837ba550af4 | |
parent | 52fa89e8d1872b909c98ddd4bcacc2ab00ac6e57 (diff) | |
parent | 7f3e6473ca8e25ee7b860cb6ed6416fc1c98f8b2 (diff) | |
download | gitlab-ce-b80c2287958d10626b49a483c8cc9ea306e7aea0.tar.gz |
Merge branch '10-6-stable-frozen' into '10-6-stable'
Merge 10-6-stable-frozen into 10-6-stable
See merge request gitlab-org/gitlab-ce!17644
246 files changed, 4304 insertions, 1315 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index c8d399b2b98..246a0fbc5f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 10.5.3 (2018-03-01) + +### Security (1 change) + +- Ensure that OTP backup codes are always invalidated. + + ## 10.5.2 (2018-02-25) ### Fixed (7 changes) @@ -219,6 +226,13 @@ entry. - Adds empty state illustration for pending job. +## 10.4.5 (2018-03-01) + +### Security (1 change) + +- Ensure that OTP backup codes are always invalidated. + + ## 10.4.4 (2018-02-16) ### Security (1 change) @@ -443,6 +457,13 @@ entry. - Use a background migration for issues.closed_at. +## 10.3.8 (2018-03-01) + +### Security (1 change) + +- Ensure that OTP backup codes are always invalidated. + + ## 10.3.7 (2018-02-05) ### Security (4 changes) diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION index ee6cdce3c29..faef31a4357 100644 --- a/GITLAB_PAGES_VERSION +++ b/GITLAB_PAGES_VERSION @@ -1 +1 @@ -0.6.1 +0.7.0 diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION index 090ea9dad19..1aa5e414fd3 100644 --- a/GITLAB_SHELL_VERSION +++ b/GITLAB_SHELL_VERSION @@ -1 +1 @@ -6.0.3 +6.0.4 diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION index 19811903a7f..fcdb2e109f6 100644 --- a/GITLAB_WORKHORSE_VERSION +++ b/GITLAB_WORKHORSE_VERSION @@ -1 +1 @@ -3.8.0 +4.0.0 @@ -126,6 +126,7 @@ gem 'html-pipeline', '~> 1.11.0' gem 'deckar01-task_list', '2.0.0' gem 'gitlab-markup', '~> 1.6.2' gem 'redcarpet', '~> 3.4' +gem 'commonmarker', '~> 0.17' gem 'RedCloth', '~> 4.3.2' gem 'rdoc', '~> 4.2' gem 'org-ruby', '~> 0.9.12' @@ -412,9 +413,7 @@ end # Gitaly GRPC client gem 'gitaly-proto', '~> 0.88.0', require: 'gitaly' -# Explicitly lock grpc as we know 1.9 is bad -# 1.10 is still being tested. See gitlab-org/gitaly#1059 -gem 'grpc', '~> 1.8.3' +gem 'grpc', '~> 1.10.0' # Locked until https://github.com/google/protobuf/issues/4210 is closed gem 'google-protobuf', '= 3.5.1' diff --git a/Gemfile.lock b/Gemfile.lock index 010d4f7b56a..fa99ec3febe 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -131,6 +131,8 @@ GEM coercible (1.0.0) descendants_tracker (~> 0.0.1) colorize (0.7.7) + commonmarker (0.17.8) + ruby-enum (~> 0.5) concord (0.1.5) adamantium (~> 0.2.0) equalizer (~> 0.0.9) @@ -343,9 +345,9 @@ GEM google-protobuf (3.5.1) googleapis-common-protos-types (1.0.1) google-protobuf (~> 3.0) - googleauth (0.5.3) + googleauth (0.6.2) faraday (~> 0.12) - jwt (~> 1.4) + jwt (>= 1.4, < 3.0) logging (~> 2.0) memoist (~> 0.12) multi_json (~> 1.11) @@ -369,7 +371,7 @@ GEM rake grape_logging (1.7.0) grape - grpc (1.8.3) + grpc (1.10.0) google-protobuf (~> 3.1) googleapis-common-protos-types (~> 1.0.0) googleauth (>= 0.5.1, < 0.7) @@ -503,7 +505,7 @@ GEM mini_portile2 (2.3.0) minitest (5.7.0) mousetrap-rails (1.4.6) - multi_json (1.12.2) + multi_json (1.13.1) multi_xml (0.6.0) multipart-post (2.0.0) mustermann (1.0.0) @@ -646,7 +648,7 @@ GEM pry (~> 0.10) pry-rails (0.3.5) pry (>= 0.9.10) - public_suffix (3.0.0) + public_suffix (3.0.2) pyu-ruby-sasl (0.0.3.3) rack (1.6.8) rack-accept (0.4.5) @@ -797,6 +799,8 @@ GEM rubocop (>= 0.51) rubocop-rspec (1.22.1) rubocop (>= 0.52.1) + ruby-enum (0.7.2) + i18n ruby-fogbugz (0.2.1) crack (~> 0.4) ruby-prof (0.16.2) @@ -858,10 +862,10 @@ GEM sidekiq (>= 4.2.1) sidekiq-limit_fetch (3.4.0) sidekiq (>= 4) - signet (0.7.3) + signet (0.8.1) addressable (~> 2.3) faraday (~> 0.9) - jwt (~> 1.5) + jwt (>= 1.5, < 3.0) multi_json (~> 1.10) simple_po_parser (1.1.2) simplecov (0.14.1) @@ -1019,6 +1023,7 @@ DEPENDENCIES charlock_holmes (~> 0.7.5) chronic (~> 0.10.2) chronic_duration (~> 0.10.6) + commonmarker (~> 0.17) concurrent-ruby (~> 1.0.5) connection_pool (~> 2.0) creole (~> 0.5.0) @@ -1073,7 +1078,7 @@ DEPENDENCIES grape-entity (~> 0.6.0) grape-route-helpers (~> 2.1.0) grape_logging (~> 1.7) - grpc (~> 1.8.3) + grpc (~> 1.10.0) haml_lint (~> 0.26.0) hamlit (~> 2.6.1) hashie-forbidden_attributes diff --git a/PROCESS.md b/PROCESS.md index c24210341e0..5ae191840ff 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -71,7 +71,7 @@ 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 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. +After 7th at 23:59 (Pacific 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. diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 27136c7289f..f8dcdf3f60a 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -186,7 +186,7 @@ <clipboard-button :text="ingressExternalIp" :title="s__('ClusterIntegration|Copy Ingress IP Address to clipboard')" - css-class="btn btn-default js-clipboard-btn" + class="js-clipboard-btn" /> </span> </div> diff --git a/app/assets/javascripts/commons/vue.js b/app/assets/javascripts/commons/vue.js index 8b62d78c043..798623b94fb 100644 --- a/app/assets/javascripts/commons/vue.js +++ b/app/assets/javascripts/commons/vue.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import '../vue_shared/vue_resource_interceptor'; if (process.env.NODE_ENV !== 'production') { Vue.config.productionTip = false; diff --git a/app/assets/javascripts/notes/components/diff_file_header.vue b/app/assets/javascripts/notes/components/diff_file_header.vue index fe5baa3537f..3bcde17f07c 100644 --- a/app/assets/javascripts/notes/components/diff_file_header.vue +++ b/app/assets/javascripts/notes/components/diff_file_header.vue @@ -35,6 +35,7 @@ <clipboard-button title="Copy file path to clipboard" :text="diffFile.submoduleLink" + css-class="btn-default btn-transparent btn-clipboard" /> </span> </div> @@ -79,6 +80,7 @@ <clipboard-button title="Copy file path to clipboard" :text="diffFile.filePath" + css-class="btn-default btn-transparent btn-clipboard" /> <small diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue new file mode 100644 index 00000000000..22248418c41 --- /dev/null +++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue @@ -0,0 +1,64 @@ +<script> + import axios from '~/lib/utils/axios_utils'; + import createFlash from '~/flash'; + import GlModal from '~/vue_shared/components/gl_modal.vue'; + import { s__, sprintf } from '~/locale'; + import { visitUrl } from '~/lib/utils/url_utility'; + import eventHub from '../event_hub'; + + export default { + components: { + GlModal, + }, + props: { + milestoneTitle: { + type: String, + required: true, + }, + url: { + type: String, + required: true, + }, + }, + computed: { + title() { + return sprintf(s__('Milestones|Promote %{milestoneTitle} to group milestone?'), { milestoneTitle: this.milestoneTitle }); + }, + text() { + return s__(`Milestones|Promoting this milestone will make it available for all projects inside the group. + Existing project milestones with the same title will be merged. + This action cannot be reversed.`); + }, + }, + methods: { + onSubmit() { + eventHub.$emit('promoteMilestoneModal.requestStarted', this.url); + return axios.post(this.url, { params: { format: 'json' } }) + .then((response) => { + eventHub.$emit('promoteMilestoneModal.requestFinished', { milestoneUrl: this.url, successful: true }); + visitUrl(response.data.url); + }) + .catch((error) => { + eventHub.$emit('promoteMilestoneModal.requestFinished', { milestoneUrl: this.url, successful: false }); + createFlash(error); + }); + }, + }, + }; +</script> +<template> + <gl-modal + id="promote-milestone-modal" + footer-primary-button-variant="warning" + :footer-primary-button-text="s__('Milestones|Promote Milestone')" + @submit="onSubmit" + > + <template + slot="title" + > + {{ title }} + </template> + {{ text }} + </gl-modal> +</template> + diff --git a/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js b/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js new file mode 100644 index 00000000000..d51b5c221e3 --- /dev/null +++ b/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js @@ -0,0 +1,84 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import deleteMilestoneModal from './components/delete_milestone_modal.vue'; +import eventHub from './event_hub'; + +export default () => { + Vue.use(Translate); + + const onRequestFinished = ({ milestoneUrl, successful }) => { + const button = document.querySelector(`.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`); + + if (!successful) { + button.removeAttribute('disabled'); + } + + button.querySelector('.js-loading-icon').classList.add('hidden'); + }; + + const onRequestStarted = (milestoneUrl) => { + const button = document.querySelector(`.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`); + button.setAttribute('disabled', ''); + button.querySelector('.js-loading-icon').classList.remove('hidden'); + eventHub.$once('deleteMilestoneModal.requestFinished', onRequestFinished); + }; + + const onDeleteButtonClick = (event) => { + const button = event.currentTarget; + const modalProps = { + milestoneId: parseInt(button.dataset.milestoneId, 10), + milestoneTitle: button.dataset.milestoneTitle, + milestoneUrl: button.dataset.milestoneUrl, + issueCount: parseInt(button.dataset.milestoneIssueCount, 10), + mergeRequestCount: parseInt(button.dataset.milestoneMergeRequestCount, 10), + }; + eventHub.$once('deleteMilestoneModal.requestStarted', onRequestStarted); + eventHub.$emit('deleteMilestoneModal.props', modalProps); + }; + + const deleteMilestoneButtons = document.querySelectorAll('.js-delete-milestone-button'); + deleteMilestoneButtons.forEach((button) => { + button.addEventListener('click', onDeleteButtonClick); + }); + + eventHub.$once('deleteMilestoneModal.mounted', () => { + deleteMilestoneButtons.forEach((button) => { + button.removeAttribute('disabled'); + }); + }); + + return new Vue({ + el: '#delete-milestone-modal', + components: { + deleteMilestoneModal, + }, + data() { + return { + modalProps: { + milestoneId: -1, + milestoneTitle: '', + milestoneUrl: '', + issueCount: -1, + mergeRequestCount: -1, + }, + }; + }, + mounted() { + eventHub.$on('deleteMilestoneModal.props', this.setModalProps); + eventHub.$emit('deleteMilestoneModal.mounted'); + }, + beforeDestroy() { + eventHub.$off('deleteMilestoneModal.props', this.setModalProps); + }, + methods: { + setModalProps(modalProps) { + this.modalProps = modalProps; + }, + }, + render(createElement) { + return createElement(deleteMilestoneModal, { + props: this.modalProps, + }); + }, + }); +}; diff --git a/app/assets/javascripts/pages/milestones/shared/index.js b/app/assets/javascripts/pages/milestones/shared/index.js index 327e2cf569c..dabfe32848b 100644 --- a/app/assets/javascripts/pages/milestones/shared/index.js +++ b/app/assets/javascripts/pages/milestones/shared/index.js @@ -1,88 +1,7 @@ -import Vue from 'vue'; - -import Translate from '~/vue_shared/translate'; - -import deleteMilestoneModal from './components/delete_milestone_modal.vue'; -import eventHub from './event_hub'; +import initDeleteMilestoneModal from './delete_milestone_modal_init'; +import initPromoteMilestoneModal from './promote_milestone_modal_init'; export default () => { - Vue.use(Translate); - - const onRequestFinished = ({ milestoneUrl, successful }) => { - const button = document.querySelector(`.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`); - - if (!successful) { - button.removeAttribute('disabled'); - } - - button.querySelector('.js-loading-icon').classList.add('hidden'); - }; - - const onRequestStarted = (milestoneUrl) => { - const button = document.querySelector(`.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`); - button.setAttribute('disabled', ''); - button.querySelector('.js-loading-icon').classList.remove('hidden'); - eventHub.$once('deleteMilestoneModal.requestFinished', onRequestFinished); - }; - - const onDeleteButtonClick = (event) => { - const button = event.currentTarget; - const modalProps = { - milestoneId: parseInt(button.dataset.milestoneId, 10), - milestoneTitle: button.dataset.milestoneTitle, - milestoneUrl: button.dataset.milestoneUrl, - issueCount: parseInt(button.dataset.milestoneIssueCount, 10), - mergeRequestCount: parseInt(button.dataset.milestoneMergeRequestCount, 10), - }; - eventHub.$once('deleteMilestoneModal.requestStarted', onRequestStarted); - eventHub.$emit('deleteMilestoneModal.props', modalProps); - }; - - const deleteMilestoneButtons = document.querySelectorAll('.js-delete-milestone-button'); - for (let i = 0; i < deleteMilestoneButtons.length; i += 1) { - const button = deleteMilestoneButtons[i]; - button.addEventListener('click', onDeleteButtonClick); - } - - eventHub.$once('deleteMilestoneModal.mounted', () => { - for (let i = 0; i < deleteMilestoneButtons.length; i += 1) { - const button = deleteMilestoneButtons[i]; - button.removeAttribute('disabled'); - } - }); - - return new Vue({ - el: '#delete-milestone-modal', - components: { - deleteMilestoneModal, - }, - data() { - return { - modalProps: { - milestoneId: -1, - milestoneTitle: '', - milestoneUrl: '', - issueCount: -1, - mergeRequestCount: -1, - }, - }; - }, - mounted() { - eventHub.$on('deleteMilestoneModal.props', this.setModalProps); - eventHub.$emit('deleteMilestoneModal.mounted'); - }, - beforeDestroy() { - eventHub.$off('deleteMilestoneModal.props', this.setModalProps); - }, - methods: { - setModalProps(modalProps) { - this.modalProps = modalProps; - }, - }, - render(createElement) { - return createElement(deleteMilestoneModal, { - props: this.modalProps, - }); - }, - }); + initDeleteMilestoneModal(); + initPromoteMilestoneModal(); }; diff --git a/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js b/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js new file mode 100644 index 00000000000..d00f81c9094 --- /dev/null +++ b/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js @@ -0,0 +1,82 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import PromoteMilestoneModal from './components/promote_milestone_modal.vue'; +import eventHub from './event_hub'; + +Vue.use(Translate); + +export default () => { + const onRequestFinished = ({ milestoneUrl, successful }) => { + const button = document.querySelector(`.js-promote-project-milestone-button[data-url="${milestoneUrl}"]`); + + if (!successful) { + button.removeAttribute('disabled'); + } + }; + + const onRequestStarted = (milestoneUrl) => { + const button = document.querySelector(`.js-promote-project-milestone-button[data-url="${milestoneUrl}"]`); + button.setAttribute('disabled', ''); + eventHub.$once('promoteMilestoneModal.requestFinished', onRequestFinished); + }; + + const onDeleteButtonClick = (event) => { + const button = event.currentTarget; + const modalProps = { + milestoneTitle: button.dataset.milestoneTitle, + url: button.dataset.url, + }; + eventHub.$once('promoteMilestoneModal.requestStarted', onRequestStarted); + eventHub.$emit('promoteMilestoneModal.props', modalProps); + }; + + const promoteMilestoneButtons = document.querySelectorAll('.js-promote-project-milestone-button'); + promoteMilestoneButtons.forEach((button) => { + button.addEventListener('click', onDeleteButtonClick); + }); + + eventHub.$once('promoteMilestoneModal.mounted', () => { + promoteMilestoneButtons.forEach((button) => { + button.removeAttribute('disabled'); + }); + }); + + const promoteMilestoneModal = document.getElementById('promote-milestone-modal'); + let promoteMilestoneComponent; + + if (promoteMilestoneModal) { + promoteMilestoneComponent = new Vue({ + el: promoteMilestoneModal, + components: { + PromoteMilestoneModal, + }, + data() { + return { + modalProps: { + milestoneTitle: '', + url: '', + }, + }; + }, + mounted() { + eventHub.$on('promoteMilestoneModal.props', this.setModalProps); + eventHub.$emit('promoteMilestoneModal.mounted'); + }, + beforeDestroy() { + eventHub.$off('promoteMilestoneModal.props', this.setModalProps); + }, + methods: { + setModalProps(modalProps) { + this.modalProps = modalProps; + }, + }, + render(createElement) { + return createElement('promote-milestone-modal', { + props: this.modalProps, + }); + }, + }); + } + + return promoteMilestoneComponent; +}; diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue new file mode 100644 index 00000000000..54695dfeb99 --- /dev/null +++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue @@ -0,0 +1,79 @@ +<script> + import axios from '~/lib/utils/axios_utils'; + import createFlash from '~/flash'; + import GlModal from '~/vue_shared/components/gl_modal.vue'; + import { s__, sprintf } from '~/locale'; + import { visitUrl } from '~/lib/utils/url_utility'; + import eventHub from '../event_hub'; + + export default { + components: { + GlModal, + }, + props: { + url: { + type: String, + required: true, + }, + labelTitle: { + type: String, + required: true, + }, + labelColor: { + type: String, + required: true, + }, + labelTextColor: { + type: String, + required: true, + }, + }, + computed: { + text() { + return s__(`Milestones|Promoting this label will make it available for all projects inside the group. + Existing project labels with the same title will be merged. This action cannot be reversed.`); + }, + title() { + const label = `<span + class="label color-label" + style="background-color: ${this.labelColor}; color: ${this.labelTextColor};" + >${this.labelTitle}</span>`; + + return sprintf(s__('Labels|Promote label %{labelTitle} to Group Label?'), { + labelTitle: label, + }, false); + }, + }, + methods: { + onSubmit() { + eventHub.$emit('promoteLabelModal.requestStarted', this.url); + return axios.post(this.url, { params: { format: 'json' } }) + .then((response) => { + eventHub.$emit('promoteLabelModal.requestFinished', { labelUrl: this.url, successful: true }); + visitUrl(response.data.url); + }) + .catch((error) => { + eventHub.$emit('promoteLabelModal.requestFinished', { labelUrl: this.url, successful: false }); + createFlash(error); + }); + }, + }, + }; +</script> +<template> + <gl-modal + id="promote-label-modal" + footer-primary-button-variant="warning" + :footer-primary-button-text="s__('Labels|Promote Label')" + @submit="onSubmit" + > + <div + slot="title" + v-html="title" + > + {{ title }} + </div> + + {{ text }} + </gl-modal> +</template> diff --git a/app/assets/javascripts/pages/projects/labels/event_hub.js b/app/assets/javascripts/pages/projects/labels/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/pages/projects/labels/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/pages/projects/labels/index/index.js b/app/assets/javascripts/pages/projects/labels/index/index.js index 6e45de2a724..2abcbfab1ed 100644 --- a/app/assets/javascripts/pages/projects/labels/index/index.js +++ b/app/assets/javascripts/pages/projects/labels/index/index.js @@ -1,3 +1,91 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; import initLabels from '~/init_labels'; +import eventHub from '../event_hub'; +import PromoteLabelModal from '../components/promote_label_modal.vue'; -document.addEventListener('DOMContentLoaded', initLabels); +Vue.use(Translate); + +const initLabelIndex = () => { + initLabels(); + + const onRequestFinished = ({ labelUrl, successful }) => { + const button = document.querySelector(`.js-promote-project-label-button[data-url="${labelUrl}"]`); + + if (!successful) { + button.removeAttribute('disabled'); + } + }; + + const onRequestStarted = (labelUrl) => { + const button = document.querySelector(`.js-promote-project-label-button[data-url="${labelUrl}"]`); + button.setAttribute('disabled', ''); + eventHub.$once('promoteLabelModal.requestFinished', onRequestFinished); + }; + + const onDeleteButtonClick = (event) => { + const button = event.currentTarget; + const modalProps = { + labelTitle: button.dataset.labelTitle, + labelColor: button.dataset.labelColor, + labelTextColor: button.dataset.labelTextColor, + url: button.dataset.url, + }; + eventHub.$once('promoteLabelModal.requestStarted', onRequestStarted); + eventHub.$emit('promoteLabelModal.props', modalProps); + }; + + const promoteLabelButtons = document.querySelectorAll('.js-promote-project-label-button'); + promoteLabelButtons.forEach((button) => { + button.addEventListener('click', onDeleteButtonClick); + }); + + eventHub.$once('promoteLabelModal.mounted', () => { + promoteLabelButtons.forEach((button) => { + button.removeAttribute('disabled'); + }); + }); + + const promoteLabelModal = document.getElementById('promote-label-modal'); + let promoteLabelModalComponent; + + if (promoteLabelModal) { + promoteLabelModalComponent = new Vue({ + el: promoteLabelModal, + components: { + PromoteLabelModal, + }, + data() { + return { + modalProps: { + labelTitle: '', + labelColor: '', + labelTextColor: '', + url: '', + }, + }; + }, + mounted() { + eventHub.$on('promoteLabelModal.props', this.setModalProps); + eventHub.$emit('promoteLabelModal.mounted'); + }, + beforeDestroy() { + eventHub.$off('promoteLabelModal.props', this.setModalProps); + }, + methods: { + setModalProps(modalProps) { + this.modalProps = modalProps; + }, + }, + render(createElement) { + return createElement('promote-label-modal', { + props: this.modalProps, + }); + }, + }); + } + + return promoteLabelModalComponent; +}; + +document.addEventListener('DOMContentLoaded', initLabelIndex); diff --git a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js index e8126ac573d..03bb281395a 100644 --- a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js +++ b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js @@ -1,3 +1,5 @@ +import _ from 'underscore'; +import { s__, n__, sprintf } from '~/locale'; import axios from '../lib/utils/axios_utils'; import PANEL_STATE from './constants'; import { backOff } from '../lib/utils/common_utils'; @@ -20,6 +22,7 @@ export default class PrometheusMetrics { this.$missingEnvVarMetricsList = this.$missingEnvVarPanel.find('.js-missing-var-metrics-list'); this.activeMetricsEndpoint = this.$monitoredMetricsPanel.data('activeMetrics'); + this.helpMetricsPath = this.$monitoredMetricsPanel.data('metrics-help-path'); this.$panelToggle.on('click', e => this.handlePanelToggle(e)); } @@ -59,23 +62,39 @@ export default class PrometheusMetrics { populateActiveMetrics(metrics) { let totalMonitoredMetrics = 0; let totalMissingEnvVarMetrics = 0; + let totalExporters = 0; metrics.forEach((metric) => { - this.$monitoredMetricsList.append(`<li>${metric.group}<span class="badge">${metric.active_metrics}</span></li>`); - totalMonitoredMetrics += metric.active_metrics; - if (metric.metrics_missing_requirements > 0) { - this.$missingEnvVarMetricsList.append(`<li>${metric.group}</li>`); - totalMissingEnvVarMetrics += 1; + if (metric.active_metrics > 0) { + totalExporters += 1; + this.$monitoredMetricsList.append(`<li>${_.escape(metric.group)}<span class="badge">${_.escape(metric.active_metrics)}</span></li>`); + totalMonitoredMetrics += metric.active_metrics; + if (metric.metrics_missing_requirements > 0) { + this.$missingEnvVarMetricsList.append(`<li>${_.escape(metric.group)}</li>`); + totalMissingEnvVarMetrics += 1; + } } }); - this.$monitoredMetricsCount.text(totalMonitoredMetrics); - this.showMonitoringMetricsPanelState(PANEL_STATE.LIST); + if (totalMonitoredMetrics === 0) { + const emptyCommonMetricsText = sprintf(s__('PrometheusService|<p class="text-tertiary">No <a href="%{docsUrl}">common metrics</a> were found</p>'), { + docsUrl: this.helpMetricsPath, + }, false); + this.$monitoredMetricsEmpty.empty(); + this.$monitoredMetricsEmpty.append(emptyCommonMetricsText); + this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY); + } else { + const metricsCountText = sprintf(s__('PrometheusService|%{exporters} with %{metrics} were found'), { + exporters: n__('%d exporter', '%d exporters', totalExporters), + metrics: n__('%d metric', '%d metrics', totalMonitoredMetrics), + }); + this.$monitoredMetricsCount.text(metricsCountText); + this.showMonitoringMetricsPanelState(PANEL_STATE.LIST); - if (totalMissingEnvVarMetrics > 0) { - this.$missingEnvVarPanel.removeClass('hidden'); - this.$missingEnvVarPanel.find('.flash-container').off('click'); - this.$missingEnvVarMetricCount.text(totalMissingEnvVarMetrics); + if (totalMissingEnvVarMetrics > 0) { + this.$missingEnvVarPanel.removeClass('hidden'); + this.$missingEnvVarMetricCount.text(totalMissingEnvVarMetrics); + } } } @@ -97,15 +116,15 @@ export default class PrometheusMetrics { }) .catch(stop); }) - .then((res) => { - if (res && res.data && res.data.length) { - this.populateActiveMetrics(res.data); - } else { + .then((res) => { + if (res && res.data && res.data.length) { + this.populateActiveMetrics(res.data); + } else { + this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY); + } + }) + .catch(() => { this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY); - } - }) - .catch(() => { - this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY); - }); + }); } } diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue index b4906ba4ee5..a03180e80e6 100644 --- a/app/assets/javascripts/registry/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/components/collapsible_container.vue @@ -86,6 +86,7 @@ v-if="repo.location" :text="clipboardText" :title="repo.location" + css-class="btn-default btn-transparent btn-clipboard" /> <div class="controls hidden-xs pull-right"> diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue index bef850eddc0..ee4eb3581f3 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -90,6 +90,7 @@ v-if="item.location" :title="item.location" :text="clipboardText(item.location)" + css-class="btn-default btn-transparent btn-clipboard" /> </td> <td> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 8269fe1281d..b58e04b5e60 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -1,3 +1,4 @@ +<script> import Flash from '../../../flash'; import AssigneeTitle from './assignee_title'; import Assignees from './assignees.vue'; @@ -6,11 +7,9 @@ import eventHub from '../../event_hub'; export default { name: 'SidebarAssignees', - data() { - return { - store: new Store(), - loading: false, - }; + components: { + AssigneeTitle, + Assignees, }, props: { mediator: { @@ -27,9 +26,28 @@ export default { default: false, }, }, - components: { - AssigneeTitle, - Assignees, + data() { + return { + store: new Store(), + loading: false, + }; + }, + created() { + this.removeAssignee = this.store.removeAssignee.bind(this.store); + this.addAssignee = this.store.addAssignee.bind(this.store); + this.removeAllAssignees = this.store.removeAllAssignees.bind(this.store); + + // Get events from glDropdown + eventHub.$on('sidebar.removeAssignee', this.removeAssignee); + eventHub.$on('sidebar.addAssignee', this.addAssignee); + eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$on('sidebar.saveAssignees', this.saveAssignees); + }, + beforeDestroy() { + eventHub.$off('sidebar.removeAssignee', this.removeAssignee); + eventHub.$off('sidebar.addAssignee', this.addAssignee); + eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$off('sidebar.saveAssignees', this.saveAssignees); }, methods: { assignSelf() { @@ -54,39 +72,24 @@ export default { }); }, }, - created() { - this.removeAssignee = this.store.removeAssignee.bind(this.store); - this.addAssignee = this.store.addAssignee.bind(this.store); - this.removeAllAssignees = this.store.removeAllAssignees.bind(this.store); - - // Get events from glDropdown - eventHub.$on('sidebar.removeAssignee', this.removeAssignee); - eventHub.$on('sidebar.addAssignee', this.addAssignee); - eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees); - eventHub.$on('sidebar.saveAssignees', this.saveAssignees); - }, - beforeDestroy() { - eventHub.$off('sidebar.removeAssignee', this.removeAssignee); - eventHub.$off('sidebar.addAssignee', this.addAssignee); - eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees); - eventHub.$off('sidebar.saveAssignees', this.saveAssignees); - }, - template: ` - <div> - <assignee-title - :number-of-assignees="store.assignees.length" - :loading="loading || store.isFetching.assignees" - :editable="store.editable" - :show-toggle="!signedIn" - /> - <assignees - v-if="!store.isFetching.assignees" - class="value" - :root-path="store.rootPath" - :users="store.assignees" - :editable="store.editable" - @assign-self="assignSelf" - /> - </div> - `, }; +</script> + +<template> + <div> + <assignee-title + :number-of-assignees="store.assignees.length" + :loading="loading || store.isFetching.assignees" + :editable="store.editable" + :show-toggle="!signedIn" + /> + <assignees + v-if="!store.isFetching.assignees" + class="value" + :root-path="store.rootPath" + :users="store.assignees" + :editable="store.editable" + @assign-self="assignSelf" + /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 56cc78ca0ca..ef748f18301 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking'; -import SidebarAssignees from './components/assignees/sidebar_assignees'; +import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue'; import SidebarMoveIssue from './lib/sidebar_move_issue'; import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue'; diff --git a/app/assets/javascripts/terminal/terminal.js b/app/assets/javascripts/terminal/terminal.js index 6b9422b1816..904b0093f7b 100644 --- a/app/assets/javascripts/terminal/terminal.js +++ b/app/assets/javascripts/terminal/terminal.js @@ -6,8 +6,14 @@ constructor(options) { this.options = options || {}; - this.options.cursorBlink = options.cursorBlink || true; - this.options.screenKeys = options.screenKeys || true; + if (!Object.prototype.hasOwnProperty.call(this.options, 'cursorBlink')) { + this.options.cursorBlink = true; + } + + if (!Object.prototype.hasOwnProperty.call(this.options, 'screenKeys')) { + this.options.screenKeys = true; + } + this.container = document.querySelector(options.selector); this.setSocketUrl(); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 18a3787857d..3d886e7d628 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -67,6 +67,7 @@ <clipboard-button :text="branchNameClipboardData" :title="__('Copy branch name to clipboard')" + css-class="btn-default btn-transparent btn-clipboard" /> {{ s__("mrWidget|into") }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue new file mode 100644 index 00000000000..f0298f732ea --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue @@ -0,0 +1,20 @@ +<script> + export default { + name: 'MRWidgetMaintainerEdit', + props: { + maintainerEditAllowed: { + type: Boolean, + default: false, + required: false, + }, + }, + }; +</script> + +<template> + <section class="mr-info-list mr-links"> + <p v-if="maintainerEditAllowed"> + {{ s__("mrWidget|Allows edits from maintainers") }} + </p> + </section> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index edb3baa39e4..a1bc28873df 100644 --- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -15,6 +15,7 @@ export { default as WidgetHeader } from './components/mr_widget_header.vue'; export { default as WidgetMergeHelp } from './components/mr_widget_merge_help.vue'; export { default as WidgetPipeline } from './components/mr_widget_pipeline.vue'; export { default as WidgetDeployment } from './components/mr_widget_deployment'; +export { default as WidgetMaintainerEdit } from './components/mr_widget_maintainer_edit.vue'; export { default as WidgetRelatedLinks } from './components/mr_widget_related_links.vue'; export { default as MergedState } from './components/states/mr_widget_merged.vue'; export { default as FailedToMerge } from './components/states/mr_widget_failed_to_merge.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 797f0f6ec0f..df3eb86f35c 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 @@ -6,6 +6,7 @@ import { WidgetMergeHelp, WidgetPipeline, WidgetDeployment, + WidgetMaintainerEdit, WidgetRelatedLinks, MergedState, ClosedState, @@ -211,6 +212,7 @@ export default { 'mr-widget-merge-help': WidgetMergeHelp, 'mr-widget-pipeline': WidgetPipeline, 'mr-widget-deployment': WidgetDeployment, + 'mr-widget-maintainer-edit': WidgetMaintainerEdit, 'mr-widget-related-links': WidgetRelatedLinks, 'mr-widget-merged': MergedState, 'mr-widget-closed': ClosedState, @@ -251,11 +253,12 @@ export default { :is="componentName" :mr="mr" :service="service" /> + <mr-widget-maintainer-edit + :maintainerEditAllowed="mr.maintainerEditAllowed" /> <mr-widget-related-links v-if="shouldRenderRelatedLinks" :state="mr.state" - :related-links="mr.relatedLinks" - /> + :related-links="mr.relatedLinks" /> </div> <div class="mr-widget-footer" diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 9a750ce42bd..5d07bcf1bb9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -76,6 +76,7 @@ export default class MergeRequestStore { this.canBeMerged = data.can_be_merged || false; this.isMergeAllowed = data.mergeable || false; this.mergeOngoing = data.merge_ongoing; + this.maintainerEditAllowed = data.allow_maintainer_to_push; // Cherry-pick and Revert actions related this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false; diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index 3b6c2da1664..cab126a7eca 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -31,7 +31,7 @@ cssClass: { type: String, required: false, - default: 'btn btn-default btn-transparent btn-clipboard', + default: 'btn-default', }, }, }; @@ -40,6 +40,7 @@ <template> <button type="button" + class="btn" :class="cssClass" :title="title" :data-clipboard-text="text" diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index ae517c41cb2..37d33320445 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -14,6 +14,10 @@ color: $gl-text-color-secondary; } +.text-tertiary { + color: $gl-text-color-tertiary; +} + .text-primary, .text-primary:hover { color: $brand-primary; diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 1d7b0b602cc..127583626cf 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -272,7 +272,7 @@ .divider { height: 1px; - margin: 6px 0; + margin: #{$grid-size / 2} 0; padding: 0; background-color: $dropdown-divider-color; diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index 88ce119ee3a..cb2f71b0033 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -12,6 +12,12 @@ margin: 0; } + .flash-warning { + @extend .alert; + @extend .alert-warning; + margin: 0; + } + .flash-alert { @extend .alert; @extend .alert-danger; diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index a6b1bf9b099..48b981dd31f 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -2,14 +2,17 @@ background-color: $modal-body-bg; padding: #{3 * $grid-size} #{2 * $grid-size}; - .page-title { - margin-top: 0; - + .page-title, + .modal-title { .color-label { font-size: $gl-font-size; padding: $gl-vert-padding $label-padding-modal; } } + + .page-title { + margin-top: 0; + } } .modal-body { diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 47672783d5a..a6ca8ed5016 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -205,7 +205,8 @@ } .badge { - font-size: inherit; + font-size: 12px; + line-height: 12px; } .panel-heading .badge-count { diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb index 6f4fdcdaa4f..b26a76d2b62 100644 --- a/app/controllers/concerns/creates_commit.rb +++ b/app/controllers/concerns/creates_commit.rb @@ -4,7 +4,7 @@ module CreatesCommit # rubocop:disable Gitlab/ModuleWithInstanceVariables def create_commit(service, success_path:, failure_path:, failure_view: nil, success_notice: nil) - if can?(current_user, :push_code, @project) + if user_access(@project).can_push_to_branch?(branch_name_or_ref) @project_to_commit_into = @project @branch_name ||= @ref else @@ -50,7 +50,7 @@ module CreatesCommit # rubocop:enable Gitlab/ModuleWithInstanceVariables def authorize_edit_tree! - return if can_collaborate_with_project? + return if can_collaborate_with_project?(project, ref: branch_name_or_ref) access_denied! end @@ -123,4 +123,8 @@ module CreatesCommit params[:create_merge_request].present? && (different_project? || @start_branch != @branch_name) # rubocop:disable Gitlab/ModuleWithInstanceVariables end + + def branch_name_or_ref + @branch_name || @ref # rubocop:disable Gitlab/ModuleWithInstanceVariables + end end diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index 6025a40348b..6d9b42a2c04 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -6,7 +6,7 @@ class Projects::ApplicationController < ApplicationController before_action :repository layout 'project' - helper_method :repository, :can_collaborate_with_project? + helper_method :repository, :can_collaborate_with_project?, :user_access private @@ -31,11 +31,12 @@ class Projects::ApplicationController < ApplicationController @repository ||= project.repository end - def can_collaborate_with_project?(project = nil) + def can_collaborate_with_project?(project = nil, ref: nil) project ||= @project can?(current_user, :push_code, project) || - (current_user && current_user.already_forked?(project)) + (current_user && current_user.already_forked?(project)) || + user_access(project).can_push_to_branch?(ref) end def authorize_action!(action) @@ -90,4 +91,9 @@ class Projects::ApplicationController < ApplicationController def check_issues_available! return render_404 unless @project.feature_available?(:issues, current_user) end + + def user_access(project) + @user_access ||= {} + @user_access[project] ||= Gitlab::UserAccess.new(current_user, project: project) + end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 405726c017c..0c1c286a0a4 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -9,8 +9,12 @@ class Projects::BlobController < Projects::ApplicationController before_action :require_non_empty_project, except: [:new, :create] before_action :authorize_download_code! - before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy] + + # We need to assign the blob vars before `authorize_edit_tree!` so we can + # validate access to a specific ref. before_action :assign_blob_vars + before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy] + before_action :commit, except: [:new, :create] before_action :blob, except: [:new, :create] before_action :require_branch_head, only: [:edit, :update] @@ -46,7 +50,7 @@ class Projects::BlobController < Projects::ApplicationController end def edit - if can_collaborate_with_project? + if can_collaborate_with_project?(project, ref: @ref) blob.load_all_data! else redirect_to action: 'show' diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index e0f4710175f..99790b8e7e8 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -112,12 +112,14 @@ class Projects::LabelsController < Projects::ApplicationController begin return render_404 unless promote_service.execute(@label) + flash[:notice] = "#{@label.title} promoted to group label." respond_to do |format| format.html do - redirect_to(project_labels_path(@project), - notice: 'Label was promoted to a Group Label') + redirect_to(project_labels_path(@project), status: 303) + end + format.json do + render json: { url: project_labels_path(@project) } end - format.js end rescue ActiveRecord::RecordInvalid => e Gitlab::AppLogger.error "Failed to promote label \"#{@label.title}\" to group label" diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index 793ae03fb88..67d4ea2ca8f 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -15,6 +15,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont def merge_request_params_attributes [ + :allow_maintainer_to_push, :assignee_id, :description, :force_remove_source_branch, diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index a1af125547c..54e7d81de6a 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -187,7 +187,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo begin @merge_request.environments_for(current_user).map do |environment| project = environment.project - deployment = environment.first_deployment_for(@merge_request.diff_head_commit) + deployment = environment.first_deployment_for(@merge_request.diff_head_sha) stop_url = if environment.stop_action? && can?(current_user, :create_deployment, environment) diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index 75b17d05e22..ff93147d00f 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -70,9 +70,17 @@ class Projects::MilestonesController < Projects::ApplicationController end def promote - promoted_milestone = Milestones::PromoteService.new(project, current_user).execute(milestone) - flash[:notice] = "Milestone has been promoted to group milestone." - redirect_to group_milestone_path(project.group, promoted_milestone.iid) + Milestones::PromoteService.new(project, current_user).execute(milestone) + + flash[:notice] = "#{milestone.title} promoted to group milestone" + respond_to do |format| + format.html do + redirect_to project_milestones_path(project) + end + format.json do + render json: { url: project_milestones_path(project) } + end + end rescue Milestones::PromoteService::PromoteMilestoneError => error redirect_to milestone, alert: error.message end diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index daa5c88aae0..f14cb5f6a9f 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -3,7 +3,8 @@ class Projects::ServicesController < Projects::ApplicationController # Authorize before_action :authorize_admin_project! - before_action :service, only: [:edit, :update, :test] + before_action :ensure_service_enabled + before_action :service respond_to :html @@ -23,26 +24,30 @@ class Projects::ServicesController < Projects::ApplicationController end def test - message = {} + if @service.can_test? + render json: service_test_response, status: :ok + else + render json: {}, status: :not_found + end + end - if @service.can_test? && @service.update_attributes(service_params[:service]) + private + + def service_test_response + if @service.update_attributes(service_params[:service]) data = @service.test_data(project, current_user) outcome = @service.test(data) - unless outcome[:success] - message = { error: true, message: 'Test failed.', service_response: outcome[:result].to_s } + if outcome[:success] + {} + else + { error: true, message: 'Test failed.', service_response: outcome[:result].to_s } end - - status = :ok else - status = :not_found + { error: true, message: 'Validations failed.', service_response: @service.errors.full_messages.join(',') } end - - render json: message, status: status end - private - def success_message if @service.active? "#{@service.title} activated." @@ -54,4 +59,8 @@ class Projects::ServicesController < Projects::ApplicationController def service @service ||= @project.find_or_initialize_service(params[:id]) end + + def ensure_service_enabled + render_404 unless service + end end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index ce57422f45d..fb4fe1c40b7 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -125,6 +125,19 @@ module MergeRequestsHelper link_to(url[merge_request.project, merge_request], data: data_attrs, &block) end + def allow_maintainer_push_unavailable_reason(merge_request) + return if merge_request.can_allow_maintainer_to_push?(current_user) + + minimum_visibility = [merge_request.target_project.visibility_level, + merge_request.source_project.visibility_level].min + + if minimum_visibility < Gitlab::VisibilityLevel::INTERNAL + _('Not available for private projects') + elsif ProtectedBranch.protected?(merge_request.source_project, merge_request.source_branch) + _('Not available for protected branches') + end + end + def merge_params_ee(merge_request) {} end diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index f6a6d9bebde..b64be89c181 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -49,15 +49,13 @@ module TreeHelper return false unless on_top_of_branch?(project, ref) - can_collaborate_with_project?(project) + can_collaborate_with_project?(project, ref: ref) end def tree_edit_branch(project = @project, ref = @ref) return unless can_edit_tree?(project, ref) - project = project.present(current_user: current_user) - - if project.can_current_user_push_to_branch?(ref) + if user_access(project).can_push_to_branch?(ref) ref else project = tree_edit_project(project) @@ -88,7 +86,16 @@ module TreeHelper end def commit_in_fork_help - "A new branch will be created in your fork and a new merge request will be started." + _("A new branch will be created in your fork and a new merge request will be started.") + end + + def commit_in_single_accessible_branch + branch_name = html_escape(selected_branch) + + message = _("Your changes can be committed to %{branch_name} because a merge "\ + "request is open.") % { branch_name: "<strong>#{branch_name}</strong>" } + + message.html_safe end def path_breadcrumbs(max_links = 6) @@ -125,4 +132,8 @@ module TreeHelper return tree.name end end + + def selected_branch + @branch_name || tree_edit_branch + end end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 45d4fb451d8..e4212775956 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -117,7 +117,7 @@ class Notify < BaseMailer if Gitlab::IncomingEmail.enabled? && @sent_notification address = Mail::Address.new(Gitlab::IncomingEmail.reply_address(reply_key)) - address.display_name = @project.name_with_namespace + address.display_name = @project.full_name headers['Reply-To'] = address diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 4560bc23193..5a566f3ac02 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -223,6 +223,10 @@ module Issuable def to_ability_name model_name.singular end + + def parent_class + ::Project + end end def today? diff --git a/app/models/environment.rb b/app/models/environment.rb index 582a7818502..2b0a88ac5b4 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -99,8 +99,8 @@ class Environment < ActiveRecord::Base folder_name == "production" end - def first_deployment_for(commit) - ref = project.repository.ref_name_for_sha(ref_path, commit.sha) + def first_deployment_for(commit_sha) + ref = project.repository.ref_name_for_sha(ref_path, commit_sha) return nil unless ref diff --git a/app/models/event.rb b/app/models/event.rb index be0fc7efa9a..17a198d52c7 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -65,6 +65,7 @@ class Event < ActiveRecord::Base # Callbacks after_create :reset_project_activity after_create :set_last_repository_updated_at, if: :push? + after_create :track_user_interacted_projects # Scopes scope :recent, -> { reorder(id: :desc) } @@ -389,4 +390,11 @@ class Event < ActiveRecord::Base Project.unscoped.where(id: project_id) .update_all(last_repository_updated_at: created_at) end + + def track_user_interacted_projects + # Note the call to .available? is due to earlier migrations + # that would otherwise conflict with the call to .track + # (because the table does not exist yet). + UserInteractedProject.track(self) if UserInteractedProject.available? + end end diff --git a/app/models/member.rb b/app/models/member.rb index 408e8b2d704..36090676051 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -85,6 +85,7 @@ class Member < ActiveRecord::Base after_create :create_notification_setting, unless: [:pending?, :importing?] after_create :post_create_hook, unless: [:pending?, :importing?] after_update :post_update_hook, unless: [:pending?, :importing?] + after_destroy :destroy_notification_setting after_destroy :post_destroy_hook, unless: :pending? after_commit :refresh_member_authorized_projects @@ -315,6 +316,10 @@ class Member < ActiveRecord::Base user.notification_settings.find_or_create_for(source) end + def destroy_notification_setting + notification_setting&.destroy + end + def notification_setting @notification_setting ||= user&.notification_settings_for(source) end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index b6f1dd272cd..1c7ed4a96df 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -13,8 +13,6 @@ class ProjectMember < Member scope :in_project, ->(project) { where(source_id: project.id) } - before_destroy :delete_member_todos - class << self # Add users to projects with passed access option # @@ -93,10 +91,6 @@ class ProjectMember < Member private - def delete_member_todos - user.todos.where(project_id: source_id).destroy_all if user - end - def send_invite notification_service.invite_project_member(self, @raw_invite_token) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 5bec68ce4f6..c2bae379a94 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -375,15 +375,27 @@ class MergeRequest < ActiveRecord::Base end def diff_start_sha - diff_start_commit.try(:sha) + if persisted? + merge_request_diff.start_commit_sha + else + target_branch_head.try(:sha) + end end def diff_base_sha - diff_base_commit.try(:sha) + if persisted? + merge_request_diff.base_commit_sha + else + branch_merge_base_commit.try(:sha) + end end def diff_head_sha - diff_head_commit.try(:sha) + if persisted? + merge_request_diff.head_commit_sha + else + source_branch_head.try(:sha) + end end # When importing a pull request from GitHub, the old and new branches may no @@ -646,7 +658,7 @@ class MergeRequest < ActiveRecord::Base !ProtectedBranch.protected?(source_project, source_branch) && !source_project.root_ref?(source_branch) && Ability.allowed?(current_user, :push_code, source_project) && - diff_head_commit == source_branch_head + diff_head_sha == source_branch_head.try(:sha) end def should_remove_source_branch? @@ -853,7 +865,7 @@ class MergeRequest < ActiveRecord::Base def can_be_merged_by?(user) access = ::Gitlab::UserAccess.new(user, project: project) - access.can_push_to_branch?(target_branch) || access.can_merge_to_branch?(target_branch) + access.can_update_branch?(target_branch) end def can_be_merged_via_command_line_by?(user) @@ -1075,4 +1087,22 @@ class MergeRequest < ActiveRecord::Base project.merge_requests.merged.where(author_id: author_id).empty? end + + def allow_maintainer_to_push + maintainer_push_possible? && super + end + + alias_method :allow_maintainer_to_push?, :allow_maintainer_to_push + + def maintainer_push_possible? + source_project.present? && for_fork? && + target_project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && + source_project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && + !ProtectedBranch.protected?(source_project, source_branch) + end + + def can_allow_maintainer_to_push?(user) + maintainer_push_possible? && + Ability.allowed?(user, :push_code, source_project) + end end diff --git a/app/models/note.rb b/app/models/note.rb index d7a67ec277c..787a80f0196 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -81,7 +81,7 @@ class Note < ActiveRecord::Base validates :author, presence: true validates :discussion_id, presence: true, format: { with: /\A\h{40}\z/ } - validate unless: [:for_commit?, :importing?, :for_personal_snippet?] do |note| + validate unless: [:for_commit?, :importing?, :skip_project_check?] do |note| unless note.noteable.try(:project) == note.project errors.add(:project, 'does not match noteable project') end @@ -228,7 +228,7 @@ class Note < ActiveRecord::Base end def skip_project_check? - for_personal_snippet? + !for_project_noteable? end def commit @@ -308,6 +308,11 @@ class Note < ActiveRecord::Base self.noteable.supports_discussions? && !part_of_discussion? end + def can_create_todo? + # Skip system notes, and notes on project snippet + !system? && !for_snippet? + end + def discussion_class(noteable = nil) # When commit notes are rendered on an MR's Discussion page, they are # displayed in one discussion instead of individually. diff --git a/app/models/project.rb b/app/models/project.rb index 5cd1da43645..5f9d9785d64 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -150,6 +150,7 @@ class Project < ActiveRecord::Base # Merge Requests for target project should be removed with it has_many :merge_requests, foreign_key: 'target_project_id' + has_many :source_of_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest' has_many :issues has_many :labels, class_name: 'ProjectLabel' has_many :services @@ -1799,6 +1800,33 @@ class Project < ActiveRecord::Base Badge.where("id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection end + def merge_requests_allowing_push_to_user(user) + return MergeRequest.none unless user + + developer_access_exists = user.project_authorizations + .where('access_level >= ? ', Gitlab::Access::DEVELOPER) + .where('project_authorizations.project_id = merge_requests.target_project_id') + .limit(1) + .select(1) + source_of_merge_requests.opened + .where(allow_maintainer_to_push: true) + .where('EXISTS (?)', developer_access_exists) + end + + def branch_allows_maintainer_push?(user, branch_name) + return false unless user + + cache_key = "user:#{user.id}:#{branch_name}:branch_allows_push" + + memoized_results = strong_memoize(:branch_allows_maintainer_push) do + Hash.new do |result, cache_key| + result[cache_key] = fetch_branch_allows_maintainer_push?(user, branch_name) + end + end + + memoized_results[cache_key] + end + private def storage @@ -1921,4 +1949,22 @@ class Project < ActiveRecord::Base raise ex end + + def fetch_branch_allows_maintainer_push?(user, branch_name) + check_access = -> do + merge_request = source_of_merge_requests.opened + .where(allow_maintainer_to_push: true) + .find_by(source_branch: branch_name) + + merge_request&.can_be_merged_by?(user) + end + + if RequestStore.active? + RequestStore.fetch("project-#{id}:branch-#{branch_name}:user-#{user.id}:branch_allows_maintainer_push") do + check_access.call + end + else + check_access.call + end + end end diff --git a/app/models/project_team.rb b/app/models/project_team.rb index a9e5cfb8240..33280eda0b9 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -85,6 +85,15 @@ class ProjectTeam @masters ||= fetch_members(Gitlab::Access::MASTER) end + def owners + @owners ||= + if group + group.owners + else + [project.owner] + end + end + def import(source_project, current_user = nil) target_project = project diff --git a/app/models/repository.rb b/app/models/repository.rb index 1a14afb951a..461dfbec2bc 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -651,14 +651,15 @@ class Repository end def last_commit_for_path(sha, path) - commit_by(oid: last_commit_id_for_path(sha, path)) + commit = raw_repository.last_commit_for_path(sha, path) + ::Commit.new(commit, @project) if commit end def last_commit_id_for_path(sha, path) key = path.blank? ? "last_commit_id_for_path:#{sha}" : "last_commit_id_for_path:#{sha}:#{Digest::SHA1.hexdigest(path)}" cache.fetch(key) do - raw_repository.last_commit_id_for_path(sha, path) + last_commit_for_path(sha, path)&.id end end @@ -866,20 +867,20 @@ class Repository raw_repository.ancestor?(ancestor_id, descendant_id) end - def fetch_as_mirror(url, forced: false, refmap: :all_refs, remote_name: nil) + def fetch_as_mirror(url, forced: false, refmap: :all_refs, remote_name: nil, prune: true) unless remote_name remote_name = "tmp-#{SecureRandom.hex}" tmp_remote_name = true end add_remote(remote_name, url, mirror_refmap: refmap) - fetch_remote(remote_name, forced: forced) + fetch_remote(remote_name, forced: forced, prune: prune) ensure remove_remote(remote_name) if tmp_remote_name end - def fetch_remote(remote, forced: false, ssh_auth: nil, no_tags: false) - gitlab_shell.fetch_remote(raw_repository, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags) + def fetch_remote(remote, forced: false, ssh_auth: nil, no_tags: false, prune: true) + gitlab_shell.fetch_remote(raw_repository, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags, prune: prune) end def fetch_source_branch!(source_repository, source_branch, local_ref) diff --git a/app/models/service.rb b/app/models/service.rb index 369cae2e85f..99bf757ae44 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -129,6 +129,17 @@ class Service < ActiveRecord::Base fields end + def configurable_events + events = self.class.supported_events + + # No need to disable individual triggers when there is only one + if events.count == 1 + [] + else + events + end + end + def supported_events self.class.supported_events end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index a58c208279e..644120453cf 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -168,5 +168,9 @@ class Snippet < ActiveRecord::Base def search_code(query) fuzzy_search(query, [:content]) end + + def parent_class + ::Project + end end end diff --git a/app/models/user.rb b/app/models/user.rb index 9c60adf0c90..951b161203c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1035,14 +1035,33 @@ class User < ActiveRecord::Base end end + def todos_done_count(force: false) + Rails.cache.fetch(['users', id, 'todos_done_count'], force: force, expires_in: 20.minutes) do + TodosFinder.new(self, state: :done).execute.count + end + end + + def todos_pending_count(force: false) + Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force, expires_in: 20.minutes) do + TodosFinder.new(self, state: :pending).execute.count + end + end + def update_cache_counts assigned_open_merge_requests_count(force: true) assigned_open_issues_count(force: true) end + def update_todos_count_cache + todos_done_count(force: true) + todos_pending_count(force: true) + end + def invalidate_cache_counts invalidate_issue_cache_counts invalidate_merge_request_cache_counts + invalidate_todos_done_count + invalidate_todos_pending_count end def invalidate_issue_cache_counts @@ -1053,21 +1072,12 @@ class User < ActiveRecord::Base Rails.cache.delete(['users', id, 'assigned_open_merge_requests_count']) end - def todos_done_count(force: false) - Rails.cache.fetch(['users', id, 'todos_done_count'], force: force, expires_in: 20.minutes) do - TodosFinder.new(self, state: :done).execute.count - end + def invalidate_todos_done_count + Rails.cache.delete(['users', id, 'todos_done_count']) end - def todos_pending_count(force: false) - Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force, expires_in: 20.minutes) do - TodosFinder.new(self, state: :pending).execute.count - end - end - - def update_todos_count_cache - todos_done_count(force: true) - todos_pending_count(force: true) + def invalidate_todos_pending_count + Rails.cache.delete(['users', id, 'todos_pending_count']) end # This is copied from Devise::Models::Lockable#valid_for_authentication?, as our auth diff --git a/app/models/user_interacted_project.rb b/app/models/user_interacted_project.rb new file mode 100644 index 00000000000..dd55a6acb79 --- /dev/null +++ b/app/models/user_interacted_project.rb @@ -0,0 +1,59 @@ +class UserInteractedProject < ActiveRecord::Base + belongs_to :user + belongs_to :project + + validates :project_id, presence: true + validates :user_id, presence: true + + CACHE_EXPIRY_TIME = 1.day + + # Schema version required for this model + REQUIRED_SCHEMA_VERSION = 20180223120443 + + class << self + def track(event) + # For events without a project, we simply don't care. + # An example of this is the creation of a snippet (which + # is not related to any project). + return unless event.project_id + + attributes = { + project_id: event.project_id, + user_id: event.author_id + } + + cached_exists?(attributes) do + transaction(requires_new: true) do + begin + where(attributes).select(1).first || create!(attributes) + true # not caching the whole record here for now + rescue ActiveRecord::RecordNotUnique + # Note, above queries are not atomic and prone + # to race conditions (similar like #find_or_create!). + # In the case where we hit this, the record we want + # already exists - shortcut and return. + true + end + end + end + end + + # Check if we can safely call .track (table exists) + def available? + @available_flag ||= ActiveRecord::Migrator.current_version >= REQUIRED_SCHEMA_VERSION # rubocop:disable Gitlab/PredicateMemoization + end + + # Flushes cached information about schema + def reset_column_information + @available_flag = nil + super + end + + private + + def cached_exists?(project_id:, user_id:, &block) + cache_key = "user_interacted_projects:#{project_id}:#{user_id}" + Rails.cache.fetch(cache_key, expires_in: CACHE_EXPIRY_TIME, &block) + end + end +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 3b0550b4dd6..57ab0c23dcd 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -61,6 +61,11 @@ class ProjectPolicy < BasePolicy desc "Project has request access enabled" condition(:request_access_enabled, scope: :subject) { project.request_access_enabled } + desc "Has merge requests allowing pushes to user" + condition(:has_merge_requests_allowing_pushes, scope: :subject) do + project.merge_requests_allowing_push_to_user(user).any? + end + features = %w[ merge_requests issues @@ -291,6 +296,15 @@ class ProjectPolicy < BasePolicy prevent :read_issue end + # These rules are included to allow maintainers of projects to push to certain + # to run pipelines for the branches they have access to. + rule { can?(:public_access) & has_merge_requests_allowing_pushes }.policy do + enable :create_build + enable :update_build + enable :create_pipeline + enable :update_pipeline + end + private def team_member? diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index 08ae49562c7..9f3f2637183 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -78,7 +78,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end def rebase_path - if !rebase_in_progress? && should_be_rebased? && user_can_push_to_source_branch? + if !rebase_in_progress? && should_be_rebased? && can_push_to_source_branch? rebase_project_merge_request_path(project, merge_request) end end @@ -160,7 +160,11 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end def can_push_to_source_branch? - source_branch_exists? && user_can_push_to_source_branch? + return false unless source_branch_exists? + + !!::Gitlab::UserAccess + .new(current_user, project: source_project) + .can_push_to_branch?(source_branch) end private @@ -191,17 +195,10 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end.sort.to_sentence end - def user_can_push_to_source_branch? - return false unless source_branch_exists? - - ::Gitlab::UserAccess - .new(current_user, project: source_project) - .can_push_to_branch?(source_branch) - end - def user_can_collaborate_with_project? can?(current_user, :push_code, project) || - (current_user && current_user.already_forked?(project)) + (current_user && current_user.already_forked?(project)) || + can_push_to_source_branch? end def user_can_fork_project? diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index 4e8ef320af2..4a812e39ee1 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -11,6 +11,7 @@ class MergeRequestWidgetEntity < IssuableEntity expose :source_project_id expose :target_branch expose :target_project_id + expose :allow_maintainer_to_push expose :should_be_rebased?, as: :should_be_rebased expose :ff_only_enabled do |merge_request| @@ -29,6 +30,7 @@ class MergeRequestWidgetEntity < IssuableEntity expose :can_push_to_source_branch do |merge_request| presenter(merge_request).can_push_to_source_branch? end + expose :rebase_path do |merge_request| presenter(merge_request).rebase_path end @@ -38,7 +40,7 @@ class MergeRequestWidgetEntity < IssuableEntity # Diff sha's expose :diff_head_sha do |merge_request| - merge_request.diff_head_sha if merge_request.diff_head_commit + merge_request.diff_head_sha.presence end expose :merge_commit_message @@ -136,8 +138,8 @@ class MergeRequestWidgetEntity < IssuableEntity end expose :new_blob_path do |merge_request| - if can?(current_user, :push_code, merge_request.project) - project_new_blob_path(merge_request.project, merge_request.source_branch) + if presenter(merge_request).can_push_to_source_branch? + project_new_blob_path(merge_request.source_project, merge_request.source_branch) end end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index c8b112132b3..3b3d9239086 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -81,7 +81,7 @@ module Ci end def related_merge_requests - MergeRequest.opened.where(source_project: pipeline.project, source_branch: pipeline.ref) + pipeline.project.source_of_merge_requests.opened.where(source_branch: pipeline.ref) end end end diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb index b141bfd5fbc..5b51e1982f1 100644 --- a/app/services/members/destroy_service.rb +++ b/app/services/members/destroy_service.rb @@ -5,12 +5,9 @@ module Members return member if member.is_a?(GroupMember) && member.source.last_owner?(member.user) - Member.transaction do - unassign_issues_and_merge_requests(member) unless member.invite? - member.notification_setting&.destroy + member.destroy - member.destroy - end + member.user&.invalidate_cache_counts if member.request? && member.user != current_user notification_service.decline_access_request(member) @@ -37,38 +34,5 @@ module Members raise "Unknown member type: #{member}!" end end - - def unassign_issues_and_merge_requests(member) - if member.is_a?(GroupMember) - issues = Issue.unscoped.select(1) - .joins(:project) - .where('issues.id = issue_assignees.issue_id AND projects.namespace_id = ?', member.source_id) - - # DELETE FROM issue_assignees WHERE user_id = X AND EXISTS (...) - IssueAssignee.unscoped - .where('user_id = :user_id AND EXISTS (:sub)', user_id: member.user_id, sub: issues) - .delete_all - - MergeRequestsFinder.new(current_user, group_id: member.source_id, assignee_id: member.user_id) - .execute - .update_all(assignee_id: nil) - else - project = member.source - - # SELECT 1 FROM issues WHERE issues.id = issue_assignees.issue_id AND issues.project_id = X - issues = Issue.unscoped.select(1) - .where('issues.id = issue_assignees.issue_id') - .where(project_id: project.id) - - # DELETE FROM issue_assignees WHERE user_id = X AND EXISTS (...) - IssueAssignee.unscoped - .where('user_id = :user_id AND EXISTS (:sub)', user_id: member.user_id, sub: issues) - .delete_all - - project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil) - end - - member.user.invalidate_cache_counts - end end end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 23262b62615..231ab76fde4 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -35,6 +35,14 @@ module MergeRequests end end + def filter_params(merge_request) + super + + unless merge_request.can_allow_maintainer_to_push?(current_user) + params.delete(:allow_maintainer_to_push) + end + end + def merge_request_metrics_service(merge_request) MergeRequestMetricsService.new(merge_request.metrics) end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index 4b186d93772..a98bbdf74dd 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -6,6 +6,7 @@ module MergeRequests @params_issue_iid = params.delete(:issue_iid) self.merge_request = MergeRequest.new(params) + merge_request.author = current_user merge_request.compare_commits = [] merge_request.source_project = find_source_project merge_request.target_project = find_target_project diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb index abf25bb778b..77e7b8a5ea7 100644 --- a/app/services/notes/build_service.rb +++ b/app/services/notes/build_service.rb @@ -26,14 +26,19 @@ module Notes if project project.notes.find_discussion(discussion_id) else - # only PersonalSnippets can have discussions without project association discussion = Note.find_discussion(discussion_id) noteable = discussion.noteable - return nil unless noteable.is_a?(PersonalSnippet) && can?(current_user, :comment_personal_snippet, noteable) + return nil unless noteable_without_project?(noteable) discussion end end + + def noteable_without_project?(noteable) + return true if noteable.is_a?(PersonalSnippet) && can?(current_user, :comment_personal_snippet, noteable) + + false + end end end diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb index 6a10e172483..ad3dcc5010b 100644 --- a/app/services/notes/post_process_service.rb +++ b/app/services/notes/post_process_service.rb @@ -11,7 +11,7 @@ module Notes unless @note.system? EventCreateService.new.leave_note(@note, @note.author) - return if @note.for_personal_snippet? + return unless @note.for_project_noteable? @note.create_cross_references! execute_note_hooks diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb index 6835b14648b..e4be953e810 100644 --- a/app/services/notification_recipient_service.rb +++ b/app/services/notification_recipient_service.rb @@ -280,7 +280,7 @@ module NotificationRecipientService add_participants(note.author) add_mentions(note.author, target: note) - unless note.for_personal_snippet? + if note.for_project_noteable? # Merge project watchers add_project_watchers diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 01838ec6b5d..7fa1387084c 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -85,7 +85,7 @@ module Projects end def after_create_actions - log_info("#{@project.owner.name} created a new project \"#{@project.name_with_namespace}\"") + log_info("#{@project.owner.name} created a new project \"#{@project.full_name}\"") unless @project.gitlab_project_import? @project.write_repository_config diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index c2ca404b179..ffd48e842c2 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -241,8 +241,7 @@ class TodoService end def handle_note(note, author, skip_users = []) - # Skip system notes, and notes on project snippet - return if note.system? || note.for_snippet? + return unless note.can_create_todo? project = note.project target = note.noteable diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb index b71002433d6..06b604dad4d 100644 --- a/app/services/users/destroy_service.rb +++ b/app/services/users/destroy_service.rb @@ -49,6 +49,8 @@ module Users ::Projects::DestroyService.new(project, current_user, skip_repo: project.legacy_storage?).execute end + yield(user) if block_given? + MigrateToGhostUserService.new(user).execute unless options[:hard_delete] # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 68788134b8e..81d7db04a3c 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -657,9 +657,11 @@ .checkbox = f.label :version_check_enabled do = f.check_box :version_check_enabled - Version check enabled + Enable version check .help-block - Let GitLab inform you when an update is available. + GitLab will inform you if a new version is available. + = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "version-check") + about what information is shared with GitLab Inc. .form-group .col-sm-offset-2.col-sm-10 - can_be_configured = @application_setting.usage_ping_can_be_configured? diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml index d8f96ed5b0d..a6324a97fd5 100644 --- a/app/views/admin/hooks/_form.html.haml +++ b/app/views/admin/hooks/_form.html.haml @@ -1,21 +1,20 @@ = form_errors(hook) .form-group - = form.label :url, 'URL', class: 'control-label' - .col-sm-10 - = form.text_field :url, class: 'form-control' + = form.label :url, 'URL', class: 'label-light' + = form.text_field :url, class: 'form-control' .form-group - = form.label :token, 'Secret Token', class: 'control-label' - .col-sm-10 - = form.text_field :token, class: 'form-control' - %p.help-block - Use this token to validate received payloads + = form.label :token, 'Secret Token', class: 'label-light' + = form.text_field :token, class: 'form-control' + %p.help-block + Use this token to validate received payloads .form-group - = form.label :url, 'Trigger', class: 'control-label' - .col-sm-10.prepend-top-10 - %div - System hook will be triggered on set of events like creating project - or adding ssh key. But you can also enable extra triggers like Push events. + = form.label :url, 'Trigger', class: 'label-light' + %ul.list-unstyled + %li + .help-block + System hook will be triggered on set of events like creating project + or adding ssh key. But you can also enable extra triggers like Push events. .prepend-top-default = form.check_box :repository_update_events, class: 'pull-left' @@ -24,21 +23,21 @@ %strong Repository update events %p.light This URL will be triggered when repository is updated - %div + %li = form.check_box :push_events, class: 'pull-left' .prepend-left-20 = form.label :push_events, class: 'list-label' do %strong Push events %p.light This URL will be triggered for each branch updated to the repository - %div + %li = form.check_box :tag_push_events, class: 'pull-left' .prepend-left-20 = form.label :tag_push_events, class: 'list-label' do %strong Tag push events %p.light This URL will be triggered when a new tag is pushed to the repository - %div + %li = form.check_box :merge_requests_events, class: 'pull-left' .prepend-left-20 = form.label :merge_requests_events, class: 'list-label' do @@ -46,9 +45,8 @@ %p.light This URL will be triggered when a merge request is created/updated/merged .form-group - = form.label :enable_ssl_verification, 'SSL verification', class: 'control-label checkbox' - .col-sm-10 - .checkbox - = form.label :enable_ssl_verification do - = form.check_box :enable_ssl_verification - %strong Enable SSL verification + = form.label :enable_ssl_verification, 'SSL verification', class: 'label-light checkbox' + .checkbox + = form.label :enable_ssl_verification do + = form.check_box :enable_ssl_verification + %strong Enable SSL verification diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml index bc02d9969d6..d9e2ce5e74c 100644 --- a/app/views/admin/hooks/index.html.haml +++ b/app/views/admin/hooks/index.html.haml @@ -1,33 +1,35 @@ - page_title 'System Hooks' -%h3.page-title - System hooks +.row.prepend-top-default + .col-lg-4 + %h4.prepend-top-0 + = page_title + %p + #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks'), class: 'vlink'} can be + used for binding events when GitLab creates a User or Project. -%p.light - #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks'), class: 'vlink'} can be - used for binding events when GitLab creates a User or Project. + .col-lg-8.append-bottom-default + = form_for @hook, as: :hook, url: admin_hooks_path do |f| + = render partial: 'form', locals: { form: f, hook: @hook } + = f.submit 'Add system hook', class: 'btn btn-create' -%hr + %hr -= form_for @hook, as: :hook, url: admin_hooks_path, html: { class: 'form-horizontal' } do |f| - = render partial: 'form', locals: { form: f, hook: @hook } - .form-actions - = f.submit 'Add system hook', class: 'btn btn-create' -%hr + - if @hooks.any? + .panel.panel-default + .panel-heading + System hooks (#{@hooks.count}) + %ul.content-list + - @hooks.each do |hook| + %li + .controls + = render 'shared/web_hooks/test_button', triggers: SystemHook.triggers, hook: hook, button_class: 'btn-sm' + = link_to 'Edit', edit_admin_hook_path(hook), class: 'btn btn-sm' + = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm' + .monospace= hook.url + %div + - SystemHook.triggers.each_value do |event| + - if hook.public_send(event) + %span.label.label-gray= event.to_s.titleize + %span.label.label-gray SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'} -- if @hooks.any? - .panel.panel-default - .panel-heading - System hooks (#{@hooks.count}) - %ul.content-list - - @hooks.each do |hook| - %li - .controls - = render 'shared/web_hooks/test_button', triggers: SystemHook.triggers, hook: hook, button_class: 'btn-sm' - = link_to 'Edit', edit_admin_hook_path(hook), class: 'btn btn-sm' - = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm' - .monospace= hook.url - %div - - SystemHook.triggers.each_value do |event| - - if hook.public_send(event) - %span.label.label-gray= event.to_s.titleize - %span.label.label-gray SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'} += render 'shared/plugins/index' diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index ca3f018c5e6..36df03302e8 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -2,9 +2,6 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@group.name} issues") -- content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_vue' - - if group_issues_count(state: 'all').zero? = render 'shared/empty_states/issues', project_select_button: true - else diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml index b55dc3dce5c..b387e38c1a6 100644 --- a/app/views/projects/_commit_button.html.haml +++ b/app/views/projects/_commit_button.html.haml @@ -3,6 +3,4 @@ = link_to 'Cancel', cancel_path, class: 'btn btn-cancel', data: {confirm: leave_edit_message} - - unless can?(current_user, :push_code, @project) - .inline.prepend-left-10 - = commit_in_fork_help + = render 'shared/projects/edit_information' diff --git a/app/views/projects/blob/_new_dir.html.haml b/app/views/projects/blob/_new_dir.html.haml index 5d48a35dc4c..48ff66900be 100644 --- a/app/views/projects/blob/_new_dir.html.haml +++ b/app/views/projects/blob/_new_dir.html.haml @@ -17,6 +17,4 @@ = submit_tag _("Create directory"), class: 'btn btn-create' = link_to "Cancel", '#', class: "btn btn-cancel", "data-dismiss" => "modal" - - unless can?(current_user, :push_code, @project) - .inline.prepend-left-10 - = commit_in_fork_help + = render 'shared/projects/edit_information' diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml index f1324c61500..182d02376bf 100644 --- a/app/views/projects/blob/_upload.html.haml +++ b/app/views/projects/blob/_upload.html.haml @@ -24,6 +24,4 @@ = button_title = link_to _("Cancel"), '#', class: "btn btn-cancel", "data-dismiss" => "modal" - - unless can?(current_user, :push_code, @project) - .inline.prepend-left-10 - = commit_in_fork_help + = render 'shared/projects/edit_information' diff --git a/app/views/projects/clusters/_integration_form.html.haml b/app/views/projects/clusters/_integration_form.html.haml index d4c0cd82ce3..db97203a2aa 100644 --- a/app/views/projects/clusters/_integration_form.html.haml +++ b/app/views/projects/clusters/_integration_form.html.haml @@ -21,6 +21,12 @@ = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') .form-group + %h5= s_('ClusterIntegration|Security') + %p + = s_("ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application.") + = link_to s_("ClusterIntegration|Learn more about security configuration"), help_page_path('user/project/clusters/index.md', anchor: 'security-implications') + + .form-group %h5= s_('ClusterIntegration|Environment scope') %p = s_("ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster.") diff --git a/app/views/projects/commit/_change.html.haml b/app/views/projects/commit/_change.html.haml index 93407956f56..21e4664d4e4 100644 --- a/app/views/projects/commit/_change.html.haml +++ b/app/views/projects/commit/_change.html.haml @@ -35,6 +35,4 @@ = submit_tag label, class: 'btn btn-create' = link_to _("Cancel"), '#', class: "btn btn-cancel", "data-dismiss" => "modal" - - unless can?(current_user, :push_code, @project) - .inline.prepend-left-10 - = commit_in_fork_help + = render 'shared/projects/edit_information' diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 02395b6eb9b..5041f322612 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -1,7 +1,5 @@ - @no_container = true - page_title "Cycle Analytics" -- content_for :page_specific_javascripts do - = webpack_bundle_tag('common_vue') #cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } } - if @cycle_analytics_no_data diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index 0d656b25bc8..7ebe617766f 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -2,9 +2,6 @@ - page_title "Environments" - add_to_breadcrumbs("Pipelines", project_pipelines_path(@project)) -- content_for :page_specific_javascripts do - = webpack_bundle_tag("common_vue") - #environments-list-view{ data: { environments_data: environments_list_data, "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s, "can-read-environment" => can?(current_user, :read_environment, @project).to_s, diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index fb06ba58c27..c427a9eedc2 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -4,9 +4,6 @@ - page_title "Issues" - new_issue_email = @project.new_issuable_address(current_user, 'issue') -- content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_vue' - = content_for :meta_tags do = auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@project.name} issues") diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 80e4dce1a80..9c78bade254 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -4,6 +4,7 @@ - can_admin_label = can?(current_user, :admin_label, @project) - if @labels.exists? || @prioritized_labels.exists? + #promote-label-modal %div{ class: container_class } .top-area.adjust .nav-text diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml index 720ba236434..b2c0d9e1cfa 100644 --- a/app/views/projects/merge_requests/index.html.haml +++ b/app/views/projects/merge_requests/index.html.haml @@ -6,9 +6,6 @@ - page_title "Merge Requests" - new_merge_request_email = @project.new_issuable_address(current_user, 'merge_request') -- content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_vue' - %div{ class: container_class } = render 'projects/last_push' diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml index 6a7bc4b1888..5b0197ed58c 100644 --- a/app/views/projects/milestones/index.html.haml +++ b/app/views/projects/milestones/index.html.haml @@ -13,6 +13,7 @@ .milestones #delete-milestone-modal + #promote-milestone-modal %ul.content-list = render @milestones diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index de381d489c6..b423888c875 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -27,8 +27,15 @@ Edit - if @project.group - = link_to promote_project_milestone_path(@milestone.project, @milestone), title: "Promote to Group Milestone", class: 'btn btn-grouped', data: { confirm: "Promoting #{@milestone.title} will make it available for all projects inside #{@project.group.name}. Existing project milestones with the same name will be merged. This action cannot be reversed.", toggle: "tooltip" }, method: :post do - Promote + %button.js-promote-project-milestone-button.btn.btn-grouped{ data: { toggle: 'modal', + target: '#promote-milestone-modal', + milestone_title: @milestone.title, + url: promote_project_milestone_path(@milestone.project, @milestone), + container: 'body' }, + disabled: true, + type: 'button' } + = _('Promote') + #promote-milestone-modal - if @milestone.active? = link_to 'Close milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped" diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index ffb0ae95f9b..a7d7c923957 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -10,6 +10,3 @@ = render "projects/pipelines/with_tabs", pipeline: @pipeline .js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json) } } - -- content_for :page_specific_javascripts do - = webpack_bundle_tag('common_vue') diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index 27e1f9fba3e..12d56e244ce 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -14,8 +14,6 @@ .col-lg-12 #js-vue-registry-images{ data: { endpoint: project_container_registry_index_path(@project, format: :json) } } - = webpack_bundle_tag('common_vue') - .row.prepend-top-10 .col-lg-12 .panel.panel-default diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index 17e804d682b..053ea24b848 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -5,6 +5,9 @@ = boolean_to_icon @service.activated? %p= @service.description + + - if @service.respond_to?(:detailed_description) + %p= @service.detailed_description .col-lg-9 = form_for(@service, as: :service, url: project_service_path(@project, @service.to_param), method: :put, html: { class: 'gl-show-field-errors form-horizontal integration-settings-form js-integration-settings-form', data: { 'can-test' => @service.can_test?, 'test-url' => test_project_service_path(@project, @service) } }) do |form| = render 'shared/service_settings', form: form, subject: @service diff --git a/app/views/projects/services/prometheus/_show.html.haml b/app/views/projects/services/prometheus/_show.html.haml index 6dc2b85fd32..43e6a173108 100644 --- a/app/views/projects/services/prometheus/_show.html.haml +++ b/app/views/projects/services/prometheus/_show.html.haml @@ -7,21 +7,19 @@ = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus') .col-lg-9 - .panel.panel-default.js-panel-monitored-metrics{ data: { active_metrics: active_common_project_prometheus_metrics_path(@project, :json) } } + .panel.panel-default.js-panel-monitored-metrics{ data: { active_metrics: active_common_project_prometheus_metrics_path(@project, :json), metrics_help_path: help_page_path('user/project/integrations/prometheus_library/metrics') } } .panel-heading %h3.panel-title - = s_('PrometheusService|Monitored') + = s_('PrometheusService|Common metrics') %span.badge.js-monitored-count 0 .panel-body - .loading-metrics.text-center.js-loading-metrics - = icon('spinner spin 3x', class: 'metrics-load-spinner') - %p + .loading-metrics.js-loading-metrics + %p.prepend-top-10.prepend-left-10 + = icon('spinner spin', class: 'metrics-load-spinner') = s_('PrometheusService|Finding and configuring metrics...') - .empty-metrics.text-center.hidden.js-empty-metrics - = custom_icon('icon_empty_metrics') - %p - = s_('PrometheusService|No metrics are being monitored. To start monitoring, deploy to an environment.') - = link_to s_('PrometheusService|View environments'), project_environments_path(@project), class: 'btn btn-success' + .empty-metrics.hidden.js-empty-metrics + %p.text-tertiary.prepend-top-10.prepend-left-10 + = s_('PrometheusService|Waiting for your first deployment to an environment to find common metrics') %ul.list-unstyled.metrics-list.hidden.js-metrics-list .panel.panel-default.hidden.js-panel-missing-env-vars diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index 235d532bf98..6bef4d19434 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -2,9 +2,6 @@ - page_title "Repository" - @content_class = "limit-container-width" unless fluid_layout -- content_for :page_specific_javascripts do - = webpack_bundle_tag('common_vue') - -# Protected branches & tags use a lot of nested partials. -# The shared parts of the views can be found in the `shared` directory. -# Those are used throughout the actual views. These `shared` views are then diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index 8847d11f623..5afbc78df53 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -48,8 +48,16 @@ .pull-right.hidden-xs.hidden-sm - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group) - = link_to promote_project_label_path(label.project, label), title: "Promote to Group Label", class: 'btn btn-transparent btn-action', data: {confirm: "Promoting #{label.title} will make it available for all projects inside #{label.project.group.name}. Existing project labels with the same name will be merged. This action cannot be reversed.", toggle: "tooltip"}, method: :post do - %span.sr-only Promote to Group + %button.js-promote-project-label-button.btn.btn-transparent.btn-action.has-tooltip{ title: _('Promote to Group Label'), + disabled: true, + type: 'button', + data: { url: promote_project_label_path(label.project, label), + label_title: label.title, + label_color: label.color, + label_text_color: label.text_color, + target: '#promote-label-modal', + container: 'body', + toggle: 'modal' } } = sprite_icon('level-up') - if can?(current_user, :admin_label, label) = link_to edit_label_path(label), title: "Edit", class: 'btn btn-transparent btn-action', data: {toggle: "tooltip"} do diff --git a/app/views/shared/_new_commit_form.html.haml b/app/views/shared/_new_commit_form.html.haml index 0a4a24ae807..9221fd1e025 100644 --- a/app/views/shared/_new_commit_form.html.haml +++ b/app/views/shared/_new_commit_form.html.haml @@ -1,3 +1,6 @@ +- project = @project.present(current_user: current_user) +- branch_name = selected_branch + = render 'shared/commit_message_container', placeholder: placeholder - if @project.empty_repo? @@ -7,12 +10,14 @@ .form-group.branch = label_tag 'branch_name', _('Target Branch'), class: 'control-label' .col-sm-10 - = text_field_tag 'branch_name', @branch_name || tree_edit_branch, required: true, class: "form-control js-branch-name ref-name" + = text_field_tag 'branch_name', branch_name, required: true, class: "form-control js-branch-name ref-name" .js-create-merge-request-container = render 'shared/new_merge_request_checkbox' + - elsif project.can_current_user_push_to_branch?(branch_name) + = hidden_field_tag 'branch_name', branch_name - else - = hidden_field_tag 'branch_name', @branch_name || tree_edit_branch + = hidden_field_tag 'branch_name', branch_name = hidden_field_tag 'create_merge_request', 1 = hidden_field_tag 'original_branch', @ref, class: 'js-original-branch' diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml index 61b39afb5d4..355b3ac75ae 100644 --- a/app/views/shared/_service_settings.html.haml +++ b/app/views/shared/_service_settings.html.haml @@ -13,12 +13,12 @@ .col-sm-10 = form.check_box :active, disabled: disable_fields_service?(@service) - - if @service.supported_events.present? + - if @service.configurable_events.present? .form-group = form.label :url, "Trigger", class: 'control-label' .col-sm-10 - - @service.supported_events.each do |event| + - @service.configurable_events.each do |event| %div = form.check_box service_event_field_name(event), class: 'pull-left' .prepend-left-20 diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index 3ac4245a61b..44b09545a61 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -7,7 +7,6 @@ - page_title "Boards" - content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_vue' -# haml-lint:disable InlineJavaScript %script#js-board-template{ type: "text/x-template" }= render "shared/boards/components/board" diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 6dfabd7ba4c..4c8f03f1498 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -33,6 +33,8 @@ = render 'shared/issuable/form/merge_params', issuable: issuable += render 'shared/issuable/form/contribution', issuable: issuable, form: form + - if @merge_request_to_resolve_discussions_of .form-group .col-sm-10.col-sm-offset-2 diff --git a/app/views/shared/issuable/form/_contribution.html.haml b/app/views/shared/issuable/form/_contribution.html.haml new file mode 100644 index 00000000000..0f2d313a5cc --- /dev/null +++ b/app/views/shared/issuable/form/_contribution.html.haml @@ -0,0 +1,20 @@ +- issuable = local_assigns.fetch(:issuable) +- form = local_assigns.fetch(:form) + +- return unless issuable.is_a?(MergeRequest) +- return unless issuable.for_fork? +- return unless can?(current_user, :push_code, issuable.source_project) + +%hr + +.form-group + .control-label + = _('Contribution') + .col-sm-10 + .checkbox + = form.label :allow_maintainer_to_push do + = form.check_box :allow_maintainer_to_push, disabled: !issuable.can_allow_maintainer_to_push?(current_user) + = _('Allow edits from maintainers') + = link_to 'About this feature', help_page_path('user/project/merge_requests/maintainer_access') + .help-block + = allow_maintainer_push_unavailable_reason(issuable) diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index da01fc02d07..9db2a321526 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -51,8 +51,15 @@ \ - if @project.group - = link_to promote_project_milestone_path(milestone.project, milestone), title: "Promote to Group Milestone", class: 'btn btn-xs btn-grouped', data: { confirm: "Promoting #{milestone.title} will make it available for all projects inside #{@project.group.name}. Existing project milestones with the same name will be merged. This action cannot be reversed.", toggle: "tooltip" }, method: :post do - Promote + %button.js-promote-project-milestone-button.btn.btn-xs.btn-grouped.has-tooltip{ title: _('Promote to Group Milestone'), + disabled: true, + type: 'button', + data: { url: promote_project_milestone_path(milestone.project, milestone), + milestone_title: milestone.title, + target: '#promote-milestone-modal', + container: 'body', + toggle: 'modal' } } + = _('Promote') = link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-xs btn-close btn-grouped" diff --git a/app/views/shared/plugins/_index.html.haml b/app/views/shared/plugins/_index.html.haml new file mode 100644 index 00000000000..fc643c3ecc2 --- /dev/null +++ b/app/views/shared/plugins/_index.html.haml @@ -0,0 +1,23 @@ +- plugins = Gitlab::Plugin.files + +.row.prepend-top-default + .col-lg-4 + %h4.prepend-top-0 + Plugins + %p + #{link_to 'Plugins', help_page_path('administration/plugins')} are similar to + system hooks but are executed as files instead of sending data to a URL. + + .col-lg-8.append-bottom-default + - if plugins.any? + .panel.panel-default + .panel-heading + Plugins (#{plugins.count}) + %ul.content-list + - plugins.each do |file| + %li + .monospace + = File.basename(file) + - else + %p.light-well.text-center + No plugins found. diff --git a/app/views/shared/projects/_edit_information.html.haml b/app/views/shared/projects/_edit_information.html.haml new file mode 100644 index 00000000000..ec9dc8f62c2 --- /dev/null +++ b/app/views/shared/projects/_edit_information.html.haml @@ -0,0 +1,6 @@ +- unless can?(current_user, :push_code, @project) + .inline.prepend-left-10 + - if @project.branch_allows_maintainer_push?(current_user, selected_branch) + = commit_in_single_accessible_branch + - else + = commit_in_fork_help diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb index 21da27973fe..2a4d65b5cb3 100644 --- a/app/workers/emails_on_push_worker.rb +++ b/app/workers/emails_on_push_worker.rb @@ -66,7 +66,7 @@ class EmailsOnPushWorker # These are input errors and won't be corrected even if Sidekiq retries rescue Net::SMTPFatalError, Net::SMTPSyntaxError => e - logger.info("Failed to send e-mail for project '#{project.name_with_namespace}' to #{recipient}: #{e}") + logger.info("Failed to send e-mail for project '#{project.full_name}' to #{recipient}: #{e}") end end ensure diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index f2b2c4428d3..3909dbf7d7f 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -55,7 +55,7 @@ class PostReceive end def process_wiki_changes(post_received) - # Nothing defined here yet. + post_received.project.touch(:last_activity_at, :last_repository_updated_at) end def log(message) diff --git a/changelogs/unreleased/39444-make-margin-around-dropdown-dividers-4px.yml b/changelogs/unreleased/39444-make-margin-around-dropdown-dividers-4px.yml new file mode 100644 index 00000000000..da65cfff799 --- /dev/null +++ b/changelogs/unreleased/39444-make-margin-around-dropdown-dividers-4px.yml @@ -0,0 +1,5 @@ +--- +title: Set margins around dropdown dividers to 4px +merge_request: 17517 +author: +type: fixed diff --git a/changelogs/unreleased/42921-ci-charts-include-current-day.yml b/changelogs/unreleased/42921-ci-charts-include-current-day.yml new file mode 100644 index 00000000000..d0de6665735 --- /dev/null +++ b/changelogs/unreleased/42921-ci-charts-include-current-day.yml @@ -0,0 +1,5 @@ +--- +title: CI charts now include the current day +merge_request: 17032 +author: Dakkaron +type: changed diff --git a/changelogs/unreleased/43460-track-projects-a-user-interacted-with.yml b/changelogs/unreleased/43460-track-projects-a-user-interacted-with.yml new file mode 100644 index 00000000000..99b6ac76a3e --- /dev/null +++ b/changelogs/unreleased/43460-track-projects-a-user-interacted-with.yml @@ -0,0 +1,5 @@ +--- +title: Keep track of projects a user interacted with. +merge_request: 17327 +author: +type: other diff --git a/changelogs/unreleased/43780-add-a-paragraph-about-clusters-security-implications.yml b/changelogs/unreleased/43780-add-a-paragraph-about-clusters-security-implications.yml new file mode 100644 index 00000000000..0fa21a2013c --- /dev/null +++ b/changelogs/unreleased/43780-add-a-paragraph-about-clusters-security-implications.yml @@ -0,0 +1,5 @@ +--- +title: Add a paragraph about security implications on Cluster's page +merge_request: 17486 +author: +type: added diff --git a/changelogs/unreleased/bvl-allow-maintainer-to-push.yml b/changelogs/unreleased/bvl-allow-maintainer-to-push.yml new file mode 100644 index 00000000000..a3fefc2889a --- /dev/null +++ b/changelogs/unreleased/bvl-allow-maintainer-to-push.yml @@ -0,0 +1,5 @@ +--- +title: Allow maintainers to push to forks of their projects when a merge request is open +merge_request: 17395 +author: +type: added diff --git a/changelogs/unreleased/ce-jej-github-project-service-for-ci.yml b/changelogs/unreleased/ce-jej-github-project-service-for-ci.yml new file mode 100644 index 00000000000..6102b7ecd93 --- /dev/null +++ b/changelogs/unreleased/ce-jej-github-project-service-for-ci.yml @@ -0,0 +1,5 @@ +--- +title: Hook data for pipelines includes detailed_status +merge_request: 17607 +author: +type: changed diff --git a/changelogs/unreleased/ce-jej-integrations-can-hide-trigger-checkboxes.yml b/changelogs/unreleased/ce-jej-integrations-can-hide-trigger-checkboxes.yml new file mode 100644 index 00000000000..771df06e7a6 --- /dev/null +++ b/changelogs/unreleased/ce-jej-integrations-can-hide-trigger-checkboxes.yml @@ -0,0 +1,6 @@ +--- +title: Avoid showing unnecessary Trigger checkboxes for project Integrations with + only one event +merge_request: 17607 +author: +type: changed diff --git a/changelogs/unreleased/discussions-api.yml b/changelogs/unreleased/discussions-api.yml new file mode 100644 index 00000000000..110df3aa414 --- /dev/null +++ b/changelogs/unreleased/discussions-api.yml @@ -0,0 +1,5 @@ +--- +title: Add discussions API for Issues and Snippets +merge_request: +author: +type: added diff --git a/changelogs/unreleased/dz-plugins-project-integrations.yml b/changelogs/unreleased/dz-plugins-project-integrations.yml new file mode 100644 index 00000000000..9dbe82f9af8 --- /dev/null +++ b/changelogs/unreleased/dz-plugins-project-integrations.yml @@ -0,0 +1,5 @@ +--- +title: Add plugins list to the system hooks page +merge_request: 17518 +author: +type: added diff --git a/changelogs/unreleased/feature--43691-count-diff-note-calendar-activity.yml b/changelogs/unreleased/feature--43691-count-diff-note-calendar-activity.yml index 768686aeda8..d8020592897 100644 --- a/changelogs/unreleased/feature--43691-count-diff-note-calendar-activity.yml +++ b/changelogs/unreleased/feature--43691-count-diff-note-calendar-activity.yml @@ -1,5 +1,5 @@ --- -title: Count comments on diffs as contributions for the contributions calendar +title: Count comments on diffs and discussions as contributions for the contributions calendar merge_request: 17418 author: Riccardo Padovani type: fixed diff --git a/changelogs/unreleased/jivl-new-modal-project-labels-milestones.yml b/changelogs/unreleased/jivl-new-modal-project-labels-milestones.yml new file mode 100644 index 00000000000..6b7e14c6cfc --- /dev/null +++ b/changelogs/unreleased/jivl-new-modal-project-labels-milestones.yml @@ -0,0 +1,5 @@ +--- +title: Added new design for promotion modals +merge_request: 17197 +author: +type: other diff --git a/changelogs/unreleased/mr-commit-optimization.yml b/changelogs/unreleased/mr-commit-optimization.yml new file mode 100644 index 00000000000..522d8951b18 --- /dev/null +++ b/changelogs/unreleased/mr-commit-optimization.yml @@ -0,0 +1,5 @@ +--- +title: Use persisted/memoized value for MRs shas instead of doing git lookups +merge_request: 17555 +author: +type: performance diff --git a/changelogs/unreleased/refactor-move-sidebar-assignee-vue-component.yml b/changelogs/unreleased/refactor-move-sidebar-assignee-vue-component.yml new file mode 100644 index 00000000000..e77b651363e --- /dev/null +++ b/changelogs/unreleased/refactor-move-sidebar-assignee-vue-component.yml @@ -0,0 +1,5 @@ +--- +title: Move SidebarAssignees vue component +merge_request: 17398 +author: George Tsiolis +type: performance diff --git a/changelogs/unreleased/replace_redcarpet_with_cmark.yml b/changelogs/unreleased/replace_redcarpet_with_cmark.yml new file mode 100644 index 00000000000..7ce848b0bbd --- /dev/null +++ b/changelogs/unreleased/replace_redcarpet_with_cmark.yml @@ -0,0 +1,5 @@ +--- +title: Add CommonMark markdown engine (experimental) +merge_request: 14835 +author: blackst0ne +type: added diff --git a/changelogs/unreleased/sh-make-prune-optional-in-git-fetch.yml b/changelogs/unreleased/sh-make-prune-optional-in-git-fetch.yml new file mode 100644 index 00000000000..e961a23a031 --- /dev/null +++ b/changelogs/unreleased/sh-make-prune-optional-in-git-fetch.yml @@ -0,0 +1,5 @@ +--- +title: Make --prune a configurable parameter in fetching a git remote +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/unassign-when-leaving.yml b/changelogs/unreleased/unassign-when-leaving.yml new file mode 100644 index 00000000000..c00a87b1749 --- /dev/null +++ b/changelogs/unreleased/unassign-when-leaving.yml @@ -0,0 +1,5 @@ +--- +title: Don't delete todos or unassign issues and MRs when a user leaves a project +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/upgrade-workhorse-4-0-0.yml b/changelogs/unreleased/upgrade-workhorse-4-0-0.yml new file mode 100644 index 00000000000..f9dbdc7fc56 --- /dev/null +++ b/changelogs/unreleased/upgrade-workhorse-4-0-0.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade GitLab Workhorse to 4.0.0 +merge_request: +author: +type: added diff --git a/changelogs/unreleased/zj-move-opt-out-ruby-endpoints.yml b/changelogs/unreleased/zj-move-opt-out-ruby-endpoints.yml new file mode 100644 index 00000000000..0ddb42bc80a --- /dev/null +++ b/changelogs/unreleased/zj-move-opt-out-ruby-endpoints.yml @@ -0,0 +1,5 @@ +--- +title: Move Ruby endpoints to OPT_OUT +merge_request: +author: +type: other diff --git a/config/initializers/8_metrics.rb b/config/initializers/8_metrics.rb index 45b39b2a38d..7cdf49159b4 100644 --- a/config/initializers/8_metrics.rb +++ b/config/initializers/8_metrics.rb @@ -94,6 +94,7 @@ def instrument_classes(instrumentation) instrumentation.instrument_instance_methods(RepositoryCheck::SingleRepositoryWorker) + instrumentation.instrument_instance_methods(Rouge::Plugins::CommonMark) instrumentation.instrument_instance_methods(Rouge::Plugins::Redcarpet) instrumentation.instrument_instance_methods(Rouge::Formatters::HTMLGitlab) diff --git a/config/routes/project.rb b/config/routes/project.rb index cb46c439415..710fe0ec325 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -69,7 +69,7 @@ constraints(ProjectUrlConstrainer.new) do end end - resources :services, constraints: { id: %r{[^/]+} }, only: [:index, :edit, :update] do + resources :services, constraints: { id: %r{[^/]+} }, only: [:edit, :update] do member do put :test end diff --git a/config/webpack.config.js b/config/webpack.config.js index 19eeb497a14..3403c0c207d 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -44,8 +44,6 @@ function generateEntries() { const manualEntries = { common: './commons/index.js', - common_vue: './vue_shared/vue_resource_interceptor.js', - locale: './locale/index.js', main: './main.js', raven: './raven/index.js', webpack_runtime: './webpack.js', diff --git a/db/migrate/20180221151752_add_allow_maintainer_to_push_to_merge_requests.rb b/db/migrate/20180221151752_add_allow_maintainer_to_push_to_merge_requests.rb new file mode 100644 index 00000000000..81acfbc3655 --- /dev/null +++ b/db/migrate/20180221151752_add_allow_maintainer_to_push_to_merge_requests.rb @@ -0,0 +1,18 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddAllowMaintainerToPushToMergeRequests < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column :merge_requests, :allow_maintainer_to_push, :boolean + end + + def down + remove_column :merge_requests, :allow_maintainer_to_push + end +end diff --git a/db/migrate/20180223120443_create_user_interacted_projects_table.rb b/db/migrate/20180223120443_create_user_interacted_projects_table.rb new file mode 100644 index 00000000000..20749940b1e --- /dev/null +++ b/db/migrate/20180223120443_create_user_interacted_projects_table.rb @@ -0,0 +1,18 @@ +class CreateUserInteractedProjectsTable < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + create_table :user_interacted_projects, id: false do |t| + t.references :user, null: false + t.references :project, null: false + end + end + + def down + drop_table :user_interacted_projects + end +end diff --git a/db/post_migrate/20180223124427_build_user_interacted_projects_table.rb b/db/post_migrate/20180223124427_build_user_interacted_projects_table.rb new file mode 100644 index 00000000000..5e729b1aa53 --- /dev/null +++ b/db/post_migrate/20180223124427_build_user_interacted_projects_table.rb @@ -0,0 +1,124 @@ +class BuildUserInteractedProjectsTable < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + disable_ddl_transaction! + + def up + if Gitlab::Database.postgresql? + PostgresStrategy.new + else + MysqlStrategy.new + end.up + + unless index_exists?(:user_interacted_projects, [:project_id, :user_id]) + add_concurrent_index :user_interacted_projects, [:project_id, :user_id], unique: true + end + + unless foreign_key_exists?(:user_interacted_projects, :user_id) + add_concurrent_foreign_key :user_interacted_projects, :users, column: :user_id, on_delete: :cascade + end + + unless foreign_key_exists?(:user_interacted_projects, :project_id) + add_concurrent_foreign_key :user_interacted_projects, :projects, column: :project_id, on_delete: :cascade + end + end + + def down + execute "TRUNCATE user_interacted_projects" + + if foreign_key_exists?(:user_interacted_projects, :user_id) + remove_foreign_key :user_interacted_projects, :users + end + + if foreign_key_exists?(:user_interacted_projects, :project_id) + remove_foreign_key :user_interacted_projects, :projects + end + + if index_exists_by_name?(:user_interacted_projects, 'index_user_interacted_projects_on_project_id_and_user_id') + remove_concurrent_index_by_name :user_interacted_projects, 'index_user_interacted_projects_on_project_id_and_user_id' + end + end + + private + + # Rails' index_exists? doesn't work when you only give it a table and index + # name. As such we have to use some extra code to check if an index exists for + # a given name. + def index_exists_by_name?(table, index) + indexes_for_table[table].include?(index) + end + + def indexes_for_table + @indexes_for_table ||= Hash.new do |hash, table_name| + hash[table_name] = indexes(table_name).map(&:name) + end + end + + def foreign_key_exists?(table, column) + foreign_keys(table).any? do |key| + key.options[:column] == column.to_s + end + end + + class PostgresStrategy < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + BATCH_SIZE = 100_000 + SLEEP_TIME = 5 + + def up + with_index(:events, [:author_id, :project_id], name: 'events_user_interactions_temp', where: 'project_id IS NOT NULL') do + iteration = 0 + records = 0 + begin + Rails.logger.info "Building user_interacted_projects table, batch ##{iteration}" + result = execute <<~SQL + INSERT INTO user_interacted_projects (user_id, project_id) + SELECT e.user_id, e.project_id + FROM (SELECT DISTINCT author_id AS user_id, project_id FROM events WHERE project_id IS NOT NULL) AS e + LEFT JOIN user_interacted_projects ucp USING (user_id, project_id) + WHERE ucp.user_id IS NULL + LIMIT #{BATCH_SIZE} + SQL + iteration += 1 + records += result.cmd_tuples + Rails.logger.info "Building user_interacted_projects table, batch ##{iteration} complete, created #{records} overall" + Kernel.sleep(SLEEP_TIME) if result.cmd_tuples > 0 + rescue ActiveRecord::InvalidForeignKey => e + Rails.logger.info "Retry on InvalidForeignKey: #{e}" + retry + end while result.cmd_tuples > 0 + end + + execute "ANALYZE user_interacted_projects" + + end + + private + + def with_index(*args) + add_concurrent_index(*args) unless index_exists?(*args) + yield + ensure + remove_concurrent_index(*args) if index_exists?(*args) + end + end + + class MysqlStrategy < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + def up + execute <<~SQL + INSERT INTO user_interacted_projects (user_id, project_id) + SELECT e.user_id, e.project_id + FROM (SELECT DISTINCT author_id AS user_id, project_id FROM events WHERE project_id IS NOT NULL) AS e + LEFT JOIN user_interacted_projects ucp USING (user_id, project_id) + WHERE ucp.user_id IS NULL + SQL + end + end + +end diff --git a/db/schema.rb b/db/schema.rb index b27516f47f8..bfd4d2ef38f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1146,6 +1146,7 @@ ActiveRecord::Schema.define(version: 20180308052825) do t.boolean "discussion_locked" t.integer "latest_merge_request_diff_id" t.string "rebase_commit_sha" + t.boolean "allow_maintainer_to_push" end add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree @@ -1846,6 +1847,13 @@ ActiveRecord::Schema.define(version: 20180308052825) do add_index "user_custom_attributes", ["key", "value"], name: "index_user_custom_attributes_on_key_and_value", using: :btree add_index "user_custom_attributes", ["user_id", "key"], name: "index_user_custom_attributes_on_user_id_and_key", unique: true, using: :btree + create_table "user_interacted_projects", id: false, force: :cascade do |t| + t.integer "user_id", null: false + t.integer "project_id", null: false + end + + add_index "user_interacted_projects", ["project_id", "user_id"], name: "index_user_interacted_projects_on_project_id_and_user_id", unique: true, using: :btree + create_table "user_synced_attributes_metadata", force: :cascade do |t| t.boolean "name_synced", default: false t.boolean "email_synced", default: false @@ -2118,6 +2126,8 @@ ActiveRecord::Schema.define(version: 20180308052825) do add_foreign_key "u2f_registrations", "users" add_foreign_key "user_callouts", "users", on_delete: :cascade add_foreign_key "user_custom_attributes", "users", on_delete: :cascade + add_foreign_key "user_interacted_projects", "projects", name: "fk_722ceba4f7", on_delete: :cascade + add_foreign_key "user_interacted_projects", "users", name: "fk_0894651f08", on_delete: :cascade add_foreign_key "user_synced_attributes_metadata", "users", on_delete: :cascade add_foreign_key "users_star_projects", "projects", name: "fk_22cd27ddfc", on_delete: :cascade add_foreign_key "web_hook_logs", "web_hooks", on_delete: :cascade diff --git a/doc/administration/monitoring/index.md b/doc/administration/monitoring/index.md index b6320aba83e..d18dddf09c0 100644 --- a/doc/administration/monitoring/index.md +++ b/doc/administration/monitoring/index.md @@ -7,3 +7,4 @@ Explore our features to monitor your GitLab instance: - [GitHub imports](github_imports.md): Monitor the health and progress of GitLab's GitHub importer with various Prometheus metrics. - [Monitoring uptime](../../user/admin_area/monitoring/health_check.md): Check the server status using the health check endpoint. - [IP whitelists](ip_whitelist.md): Configure GitLab for monitoring endpoints that provide health check information when probed. +- [nginx_status](https://docs.gitlab.com/omnibus/settings/nginx.html#enabling-disabling-nginx_status): Monitor your Nginx server status diff --git a/doc/api/README.md b/doc/api/README.md index b67500a9b9e..ae4481b400e 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -37,6 +37,7 @@ following locations: - [Group milestones](group_milestones.md) - [Namespaces](namespaces.md) - [Notes](notes.md) (comments) +- [Discussions](discussions.md) (threaded comments) - [Notification settings](notification_settings.md) - [Open source license templates](templates/licenses.md) - [Pages Domains](pages_domains.md) diff --git a/doc/api/discussions.md b/doc/api/discussions.md new file mode 100644 index 00000000000..c341b7f2009 --- /dev/null +++ b/doc/api/discussions.md @@ -0,0 +1,411 @@ +# Discussions API + +Discussions are set of related notes on snippets or issues. + +## Issues + +### List project issue discussions + +Gets a list of all discussions for a single issue. + +``` +GET /projects/:id/issues/:issue_iid/discussions +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ------------ | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `issue_iid` | integer | yes | The IID of an issue | + +```json +[ + { + "id": "6a9c1750b37d513a43987b574953fceb50b03ce7", + "individual_note": false, + "notes": [ + { + "id": 1126, + "type": "DiscussionNote", + "body": "discussion text", + "attachment": null, + "author": { + "id": 1, + "name": "root", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "created_at": "2018-03-03T21:54:39.668Z", + "updated_at": "2018-03-03T21:54:39.668Z", + "system": false, + "noteable_id": 3, + "noteable_type": "Issue", + "noteable_iid": null + }, + { + "id": 1129, + "type": "DiscussionNote", + "body": "reply to the discussion", + "attachment": null, + "author": { + "id": 1, + "name": "root", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "created_at": "2018-03-04T13:38:02.127Z", + "updated_at": "2018-03-04T13:38:02.127Z", + "system": false, + "noteable_id": 3, + "noteable_type": "Issue", + "noteable_iid": null + } + ] + }, + { + "id": "87805b7c09016a7058e91bdbe7b29d1f284a39e6", + "individual_note": true, + "notes": [ + { + "id": 1128, + "type": null, + "body": "a single comment", + "attachment": null, + "author": { + "id": 1, + "name": "root", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "created_at": "2018-03-04T09:17:22.520Z", + "updated_at": "2018-03-04T09:17:22.520Z", + "system": false, + "noteable_id": 3, + "noteable_type": "Issue", + "noteable_iid": null + } + ] + } +] +``` + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/discussions +``` + +### Get single issue discussion + +Returns a single discussion for a specific project issue + +``` +GET /projects/:id/issues/:issue_iid/discussions/:discussion_id +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------------- | -------------- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `issue_iid` | integer | yes | The IID of an issue | +| `discussion_id` | integer | yes | The ID of a discussion | + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7 +``` + +### Create new issue discussion + +Creates a new discussion to a single project issue. This is similar to creating +a note but but another comments (replies) can be added to it later. + +``` +POST /projects/:id/issues/:issue_iid/discussions +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------------- | -------------- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `issue_iid` | integer | yes | The IID of an issue | +| `body` | string | yes | The content of a discussion | +| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. 2016-03-11T03:45:40Z | + +```bash +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/discussions?body=comment +``` + +### Add note to existing issue discussion + +Adds a new note to the discussion. + +``` +POST /projects/:id/issues/:issue_iid/discussions/:discussion_id/notes +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------------- | -------------- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `issue_iid` | integer | yes | The IID of an issue | +| `discussion_id` | integer | yes | The ID of a discussion | +| `note_id` | integer | yes | The ID of a discussion note | +| `body` | string | yes | The content of a discussion | +| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. 2016-03-11T03:45:40Z | + +```bash +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes?body=comment +``` + +### Modify existing issue discussion note + +Modify existing discussion note of an issue. + +``` +PUT /projects/:id/issues/:issue_iid/discussions/:discussion_id/notes/:note_id +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------------- | -------------- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `issue_iid` | integer | yes | The IID of an issue | +| `discussion_id` | integer | yes | The ID of a discussion | +| `note_id` | integer | yes | The ID of a discussion note | +| `body` | string | yes | The content of a discussion | + +```bash +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes/1108?body=comment +``` + +### Delete an issue discussion note + +Deletes an existing discussion note of an issue. + +``` +DELETE /projects/:id/issues/:issue_iid/discussions/:discussion_id/notes/:note_id +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------------- | -------------- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `issue_iid` | integer | yes | The IID of an issue | +| `discussion_id` | integer | yes | The ID of a discussion | +| `note_id` | integer | yes | The ID of a discussion note | + +```bash +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/discussions/636 +``` + +## Snippets + +### List project snippet discussions + +Gets a list of all discussions for a single snippet. + +``` +GET /projects/:id/snippets/:snippet_id/discussions +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `snippet_id` | integer | yes | The ID of an snippet | + +```json +[ + { + "id": "6a9c1750b37d513a43987b574953fceb50b03ce7", + "individual_note": false, + "notes": [ + { + "id": 1126, + "type": "DiscussionNote", + "body": "discussion text", + "attachment": null, + "author": { + "id": 1, + "name": "root", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "created_at": "2018-03-03T21:54:39.668Z", + "updated_at": "2018-03-03T21:54:39.668Z", + "system": false, + "noteable_id": 3, + "noteable_type": "Snippet", + "noteable_id": null + }, + { + "id": 1129, + "type": "DiscussionNote", + "body": "reply to the discussion", + "attachment": null, + "author": { + "id": 1, + "name": "root", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "created_at": "2018-03-04T13:38:02.127Z", + "updated_at": "2018-03-04T13:38:02.127Z", + "system": false, + "noteable_id": 3, + "noteable_type": "Snippet", + "noteable_id": null + } + ] + }, + { + "id": "87805b7c09016a7058e91bdbe7b29d1f284a39e6", + "individual_note": true, + "notes": [ + { + "id": 1128, + "type": null, + "body": "a single comment", + "attachment": null, + "author": { + "id": 1, + "name": "root", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "created_at": "2018-03-04T09:17:22.520Z", + "updated_at": "2018-03-04T09:17:22.520Z", + "system": false, + "noteable_id": 3, + "noteable_type": "Snippet", + "noteable_id": null + } + ] + } +] +``` + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions +``` + +### Get single snippet discussion + +Returns a single discussion for a specific project snippet + +``` +GET /projects/:id/snippets/:snippet_id/discussions/:discussion_id +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------------- | -------------- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `snippet_id` | integer | yes | The ID of an snippet | +| `discussion_id` | integer | yes | The ID of a discussion | + +```bash +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7 +``` + +### Create new snippet discussion + +Creates a new discussion to a single project snippet. This is similar to creating +a note but but another comments (replies) can be added to it later. + +``` +POST /projects/:id/snippets/:snippet_id/discussions +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------------- | -------------- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `snippet_id` | integer | yes | The ID of an snippet | +| `body` | string | yes | The content of a discussion | +| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. 2016-03-11T03:45:40Z | + +```bash +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions?body=comment +``` + +### Add note to existing snippet discussion + +Adds a new note to the discussion. + +``` +POST /projects/:id/snippets/:snippet_id/discussions/:discussion_id/notes +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------------- | -------------- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `snippet_id` | integer | yes | The ID of an snippet | +| `discussion_id` | integer | yes | The ID of a discussion | +| `note_id` | integer | yes | The ID of a discussion note | +| `body` | string | yes | The content of a discussion | +| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. 2016-03-11T03:45:40Z | + +```bash +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes?body=comment +``` + +### Modify existing snippet discussion note + +Modify existing discussion note of an snippet. + +``` +PUT /projects/:id/snippets/:snippet_id/discussions/:discussion_id/notes/:note_id +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------------- | -------------- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `snippet_id` | integer | yes | The ID of an snippet | +| `discussion_id` | integer | yes | The ID of a discussion | +| `note_id` | integer | yes | The ID of a discussion note | +| `body` | string | yes | The content of a discussion | + +```bash +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes/1108?body=comment +``` + +### Delete an snippet discussion note + +Deletes an existing discussion note of an snippet. + +``` +DELETE /projects/:id/snippets/:snippet_id/discussions/:discussion_id/notes/:note_id +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------------- | -------------- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `snippet_id` | integer | yes | The ID of an snippet | +| `discussion_id` | integer | yes | The ID of a discussion | +| `note_id` | integer | yes | The ID of a discussion note | + +```bash +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions/636 +``` diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index 25b0807eb18..b9a4f661777 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -529,18 +529,19 @@ Creates a new merge request. POST /projects/:id/merge_requests ``` -| 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 | -| `source_branch` | string | yes | The source branch | -| `target_branch` | string | yes | The target branch | -| `title` | string | yes | Title of MR | -| `assignee_id` | integer | no | Assignee user ID | -| `description` | string | no | Description of MR | -| `target_project_id` | integer | no | The target project (numeric id) | -| `labels` | string | no | Labels for MR as a comma-separated list | -| `milestone_id` | integer | no | The ID of a milestone | -| `remove_source_branch` | boolean | no | Flag indicating if a merge request should remove the source branch when merging | +| 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 | +| `source_branch` | string | yes | The source branch | +| `target_branch` | string | yes | The target branch | +| `title` | string | yes | Title of MR | +| `assignee_id` | integer | no | Assignee user ID | +| `description` | string | no | Description of MR | +| `target_project_id` | integer | no | The target project (numeric id) | +| `labels` | string | no | Labels for MR as a comma-separated list | +| `milestone_id` | integer | no | The ID of a milestone | +| `remove_source_branch` | boolean | no | Flag indicating if a merge request should remove the source branch when merging | +| `allow_maintainer_to_push` | boolean | no | Whether or not a maintainer of the target project can push to the source branch | ```json { @@ -548,7 +549,7 @@ POST /projects/:id/merge_requests "iid": 1, "target_branch": "master", "source_branch": "test1", - "project_id": 3, + "project_id": 4, "title": "test1", "state": "opened", "upvotes": 0, @@ -569,7 +570,7 @@ POST /projects/:id/merge_requests "state": "active", "created_at": "2012-04-29T08:46:00Z" }, - "source_project_id": 4, + "source_project_id": 3, "target_project_id": 4, "labels": [ ], "description": "fixed login page css paddings", @@ -596,6 +597,7 @@ POST /projects/:id/merge_requests "force_remove_source_branch": false, "web_url": "http://example.com/example/example/merge_requests/1", "discussion_locked": false, + "allow_maintainer_to_push": false, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -613,19 +615,20 @@ Updates an existing merge request. You can change the target branch, title, or e PUT /projects/:id/merge_requests/:merge_request_iid ``` -| 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 | -| `merge_request_iid` | integer | yes | The ID of a merge request | -| `target_branch` | string | no | The target branch | -| `title` | string | no | Title of MR | -| `assignee_id` | integer | no | The ID of the user to assign the merge request to. Set to `0` or provide an empty value to unassign all assignees. | -| `milestone_id` | integer | no | The ID of a milestone to assign the merge request to. Set to `0` or provide an empty value to unassign a milestone.| -| `labels` | string | no | Comma-separated label names for a merge request. Set to an empty string to unassign all labels. | -| `description` | string | no | Description of MR | -| `state_event` | string | no | New state (close/reopen) | -| `remove_source_branch` | boolean | no | Flag indicating if a merge request should remove the source branch when merging | -| `discussion_locked` | boolean | no | Flag indicating if the merge request's discussion is locked. If the discussion is locked only project members can add, edit or resolve comments. | +| 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 | +| `merge_request_iid` | integer | yes | The ID of a merge request | +| `target_branch` | string | no | The target branch | +| `title` | string | no | Title of MR | +| `assignee_id` | integer | no | The ID of the user to assign the merge request to. Set to `0` or provide an empty value to unassign all assignees. | +| `milestone_id` | integer | no | The ID of a milestone to assign the merge request to. Set to `0` or provide an empty value to unassign a milestone.| +| `labels` | string | no | Comma-separated label names for a merge request. Set to an empty string to unassign all labels. | +| `description` | string | no | Description of MR | +| `state_event` | string | no | New state (close/reopen) | +| `remove_source_branch` | boolean | no | Flag indicating if a merge request should remove the source branch when merging | +| `discussion_locked` | boolean | no | Flag indicating if the merge request's discussion is locked. If the discussion is locked only project members can add, edit or resolve comments. | +| `allow_maintainer_to_push` | boolean | no | Whether or not a maintainer of the target project can push to the source branch | Must include at least one non-required attribute from above. @@ -634,7 +637,7 @@ Must include at least one non-required attribute from above. "id": 1, "iid": 1, "target_branch": "master", - "project_id": 3, + "project_id": 4, "title": "test1", "state": "opened", "upvotes": 0, @@ -655,7 +658,7 @@ Must include at least one non-required attribute from above. "state": "active", "created_at": "2012-04-29T08:46:00Z" }, - "source_project_id": 4, + "source_project_id": 3, "target_project_id": 4, "labels": [ ], "description": "description1", @@ -682,6 +685,7 @@ Must include at least one non-required attribute from above. "force_remove_source_branch": false, "web_url": "http://example.com/example/example/merge_requests/1", "discussion_locked": false, + "allow_maintainer_to_push": false, "time_stats": { "time_estimate": 0, "total_time_spent": 0, diff --git a/doc/api/notes.md b/doc/api/notes.md index 1b68bd99ce2..aa38d22845c 100644 --- a/doc/api/notes.md +++ b/doc/api/notes.md @@ -15,7 +15,7 @@ GET /projects/:id/issues/:issue_iid/notes?sort=asc&order_by=updated_at | 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 +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | `issue_iid` | integer | yes | The IID of an issue | `sort` | string | no | Return issue notes sorted in `asc` or `desc` order. Default is `desc` | `order_by` | string | no | Return issue notes ordered by `created_at` or `updated_at` fields. Default is `created_at` @@ -63,6 +63,10 @@ GET /projects/:id/issues/:issue_iid/notes?sort=asc&order_by=updated_at ] ``` +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/notes +``` + ### Get single issue note Returns a single note for a specific project issue @@ -73,14 +77,17 @@ GET /projects/:id/issues/:issue_iid/notes/:note_id Parameters: -- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user +- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) - `issue_iid` (required) - The IID of a project issue - `note_id` (required) - The ID of an issue note +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/notes/1 +``` + ### Create new issue note -Creates a new note to a single project issue. If you create a note where the body -only contains an Award Emoji, you'll receive this object back. +Creates a new note to a single project issue. ``` POST /projects/:id/issues/:issue_iid/notes @@ -88,11 +95,15 @@ POST /projects/:id/issues/:issue_iid/notes Parameters: -- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user +- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) - `issue_id` (required) - The IID of an issue - `body` (required) - The content of a note - `created_at` (optional) - Date time string, ISO 8601 formatted, e.g. 2016-03-11T03:45:40Z +```bash +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/notes?body=note +``` + ### Modify existing issue note Modify existing note of an issue. @@ -103,11 +114,15 @@ PUT /projects/:id/issues/:issue_iid/notes/:note_id Parameters: -- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user +- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) - `issue_iid` (required) - The IID of an issue - `note_id` (required) - The ID of a note - `body` (required) - The content of a note +```bash +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/notes?body=note +``` + ### Delete an issue note Deletes an existing note of an issue. @@ -120,7 +135,7 @@ Parameters: | 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 | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | | `issue_iid` | integer | yes | The IID of an issue | | `note_id` | integer | yes | The ID of a note | @@ -141,11 +156,15 @@ GET /projects/:id/snippets/:snippet_id/notes?sort=asc&order_by=updated_at | 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 +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | `snippet_id` | integer | yes | The ID of a project snippet | `sort` | string | no | Return snippet notes sorted in `asc` or `desc` order. Default is `desc` | `order_by` | string | no | Return snippet notes ordered by `created_at` or `updated_at` fields. Default is `created_at` +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/notes +``` + ### Get single snippet note Returns a single note for a given snippet. @@ -156,7 +175,7 @@ GET /projects/:id/snippets/:snippet_id/notes/:note_id Parameters: -- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user +- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) - `snippet_id` (required) - The ID of a project snippet - `note_id` (required) - The ID of a snippet note @@ -179,6 +198,10 @@ Parameters: } ``` +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/notes/11 +``` + ### Create new snippet note Creates a new note for a single snippet. Snippet notes are comments users can post to a snippet. @@ -190,10 +213,14 @@ POST /projects/:id/snippets/:snippet_id/notes Parameters: -- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user +- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) - `snippet_id` (required) - The ID of a snippet - `body` (required) - The content of a note +```bash +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippet/11/notes?body=note +``` + ### Modify existing snippet note Modify existing note of a snippet. @@ -204,11 +231,15 @@ PUT /projects/:id/snippets/:snippet_id/notes/:note_id Parameters: -- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user +- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) - `snippet_id` (required) - The ID of a snippet - `note_id` (required) - The ID of a note - `body` (required) - The content of a note +```bash +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/notes?body=note +``` + ### Delete a snippet note Deletes an existing note of a snippet. @@ -221,7 +252,7 @@ Parameters: | 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 | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | | `snippet_id` | integer | yes | The ID of a snippet | | `note_id` | integer | yes | The ID of a note | @@ -242,11 +273,15 @@ GET /projects/:id/merge_requests/:merge_request_iid/notes?sort=asc&order_by=upda | 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 +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | `merge_request_iid` | integer | yes | The IID of a project merge request | `sort` | string | no | Return merge request notes sorted in `asc` or `desc` order. Default is `desc` | `order_by` | string | no | Return merge request notes ordered by `created_at` or `updated_at` fields. Default is `created_at` +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/11/notes +``` + ### Get single merge request note Returns a single note for a given merge request. @@ -257,7 +292,7 @@ GET /projects/:id/merge_requests/:merge_request_iid/notes/:note_id Parameters: -- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user +- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) - `merge_request_iid` (required) - The IID of a project merge request - `note_id` (required) - The ID of a merge request note @@ -283,6 +318,10 @@ Parameters: } ``` +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/11/notes/1 +``` + ### Create new merge request note Creates a new note for a single merge request. @@ -295,7 +334,7 @@ POST /projects/:id/merge_requests/:merge_request_iid/notes Parameters: -- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user +- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) - `merge_request_iid` (required) - The IID of a merge request - `body` (required) - The content of a note @@ -309,11 +348,15 @@ PUT /projects/:id/merge_requests/:merge_request_iid/notes/:note_id Parameters: -- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user +- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) - `merge_request_iid` (required) - The IID of a merge request - `note_id` (required) - The ID of a note - `body` (required) - The content of a note +```bash +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/11/notes?body=note +``` + ### Delete a merge request note Deletes an existing note of a merge request. @@ -326,7 +369,7 @@ Parameters: | 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 | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | | `merge_request_iid` | integer | yes | The IID of a merge request | | `note_id` | integer | yes | The ID of a note | diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md index 03aa6ff8e7c..f879ed62010 100644 --- a/doc/ci/runners/README.md +++ b/doc/ci/runners/README.md @@ -163,8 +163,7 @@ in your `.gitlab-ci.yml`. Behind the scenes, this works by increasing a counter in the database, and the value of that counter is used to create the key for the cache. After a push, a -new key is generated and the old cache is not valid anymore. Eventually, the -Runner's garbage collector will remove it form the filesystem. +new key is generated and the old cache is not valid anymore. ## How shared Runners pick jobs diff --git a/doc/development/new_fe_guide/dependencies.md b/doc/development/new_fe_guide/dependencies.md new file mode 100644 index 00000000000..3417d77a06d --- /dev/null +++ b/doc/development/new_fe_guide/dependencies.md @@ -0,0 +1,3 @@ +# Dependencies + +> TODO: Add Dependencies
\ No newline at end of file diff --git a/doc/development/new_fe_guide/development/accessibility.md b/doc/development/new_fe_guide/development/accessibility.md new file mode 100644 index 00000000000..ed35f08432f --- /dev/null +++ b/doc/development/new_fe_guide/development/accessibility.md @@ -0,0 +1,3 @@ +# Accessibility + +> TODO: Add content diff --git a/doc/development/new_fe_guide/development/components.md b/doc/development/new_fe_guide/development/components.md new file mode 100644 index 00000000000..637099d1e83 --- /dev/null +++ b/doc/development/new_fe_guide/development/components.md @@ -0,0 +1,3 @@ +# Components + +> TODO: Add content diff --git a/doc/development/new_fe_guide/development/design_patterns.md b/doc/development/new_fe_guide/development/design_patterns.md new file mode 100644 index 00000000000..ee06566ed30 --- /dev/null +++ b/doc/development/new_fe_guide/development/design_patterns.md @@ -0,0 +1,3 @@ +# Design patterns + +> TODO: Add content diff --git a/doc/development/new_fe_guide/development/index.md b/doc/development/new_fe_guide/development/index.md new file mode 100644 index 00000000000..cee8e43ebad --- /dev/null +++ b/doc/development/new_fe_guide/development/index.md @@ -0,0 +1,29 @@ +# Development + +## [Design patterns](design_patterns.md) + +Examples of proven design patterns used in our codebase. + +## [Components](components.md) + +Documentation on existing components and how to best create a new component. + +## [Accessibility](accessibility.md) + +Learn how to implement an accessible frontend. + +## [Network requests](network_requests.md) + +Learn how to handle network requests in our codebase. + +## [Security](security.md) + +Learn how to ensure that our frontend is secure. + +## [Performance](performance.md) + +Learn how to keep our frontend performant. + +## [Testing](testing.md) + +Learn how to keep our frontend tested. diff --git a/doc/development/new_fe_guide/development/network_requests.md b/doc/development/new_fe_guide/development/network_requests.md new file mode 100644 index 00000000000..047c00313bc --- /dev/null +++ b/doc/development/new_fe_guide/development/network_requests.md @@ -0,0 +1,3 @@ +# Network requests + +> TODO: Add content diff --git a/doc/development/new_fe_guide/development/performance.md b/doc/development/new_fe_guide/development/performance.md new file mode 100644 index 00000000000..26b07874f0f --- /dev/null +++ b/doc/development/new_fe_guide/development/performance.md @@ -0,0 +1,3 @@ +# Performance + +> TODO: Add content diff --git a/doc/development/new_fe_guide/development/security.md b/doc/development/new_fe_guide/development/security.md new file mode 100644 index 00000000000..debda7de0c6 --- /dev/null +++ b/doc/development/new_fe_guide/development/security.md @@ -0,0 +1,3 @@ +# Security + +> TODO: Add content diff --git a/doc/development/new_fe_guide/development/testing.md b/doc/development/new_fe_guide/development/testing.md new file mode 100644 index 00000000000..c359bd83ed1 --- /dev/null +++ b/doc/development/new_fe_guide/development/testing.md @@ -0,0 +1,3 @@ +# Testing + +> TODO: Add content diff --git a/doc/development/new_fe_guide/index.md b/doc/development/new_fe_guide/index.md new file mode 100644 index 00000000000..08c6a266e7f --- /dev/null +++ b/doc/development/new_fe_guide/index.md @@ -0,0 +1,28 @@ +# Frontend Development Guidelines + +This guide contains all the information to successfully contribute to GitLab's frontend. +This is a living document, and we welcome contributions, feedback and suggestions. + +## [Principles](principles.md) + +Ensure that your frontend contribution starts off in the right direction. + +## [Initiatives](initiatives.md) + +High level overview of where we are going from a frontend perspective. + +## [Development](development/index.md) + +Guidance on topics related to development. + +## [Dependencies](dependencies.md) + +Learn about all the dependencies that make up our frontend, including some of our own custom built libraries. + +## [Style](style/index.md) + +Style guides to keep our code consistent. + +## [Tips](tips.md) + +Tips from our frontend team to develop more efficiently and effectively. diff --git a/doc/development/new_fe_guide/initiatives.md b/doc/development/new_fe_guide/initiatives.md new file mode 100644 index 00000000000..c81ed3579f0 --- /dev/null +++ b/doc/development/new_fe_guide/initiatives.md @@ -0,0 +1,3 @@ +# Initiatives + +> TODO: Add Initiatives diff --git a/doc/development/new_fe_guide/principles.md b/doc/development/new_fe_guide/principles.md new file mode 100644 index 00000000000..2126d202a7e --- /dev/null +++ b/doc/development/new_fe_guide/principles.md @@ -0,0 +1,3 @@ +# Principles + +> TODO: Add principles diff --git a/doc/development/new_fe_guide/style/html.md b/doc/development/new_fe_guide/style/html.md new file mode 100644 index 00000000000..5489def5d6e --- /dev/null +++ b/doc/development/new_fe_guide/style/html.md @@ -0,0 +1,3 @@ +# HTML style guide + +> TODO: Add content diff --git a/doc/development/new_fe_guide/style/index.md b/doc/development/new_fe_guide/style/index.md new file mode 100644 index 00000000000..d2d576b3b46 --- /dev/null +++ b/doc/development/new_fe_guide/style/index.md @@ -0,0 +1,9 @@ +# Style + +## [HTML style guide](html.md) + +## [SCSS style guide](scss.md) + +## [JavaScript style guide](javascript.md) + +## [Vue style guide](vue.md) diff --git a/doc/development/new_fe_guide/style/javascript.md b/doc/development/new_fe_guide/style/javascript.md new file mode 100644 index 00000000000..480d50a211f --- /dev/null +++ b/doc/development/new_fe_guide/style/javascript.md @@ -0,0 +1,3 @@ +# JavaScript style guide + +> TODO: Add content diff --git a/doc/development/new_fe_guide/style/scss.md b/doc/development/new_fe_guide/style/scss.md new file mode 100644 index 00000000000..6f5e818d7db --- /dev/null +++ b/doc/development/new_fe_guide/style/scss.md @@ -0,0 +1,3 @@ +# SCSS style guide + +> TODO: Add content diff --git a/doc/development/new_fe_guide/style/vue.md b/doc/development/new_fe_guide/style/vue.md new file mode 100644 index 00000000000..fd9353e0d3f --- /dev/null +++ b/doc/development/new_fe_guide/style/vue.md @@ -0,0 +1,3 @@ +# Vue style guide + +> TODO: Add content diff --git a/doc/development/new_fe_guide/tips.md b/doc/development/new_fe_guide/tips.md new file mode 100644 index 00000000000..f0cdf52d618 --- /dev/null +++ b/doc/development/new_fe_guide/tips.md @@ -0,0 +1,3 @@ +# Tips + +> TODO: Add tips diff --git a/doc/user/admin_area/settings/img/update-available.png b/doc/user/admin_area/settings/img/update-available.png Binary files differnew file mode 100644 index 00000000000..0dafdad618e --- /dev/null +++ b/doc/user/admin_area/settings/img/update-available.png diff --git a/doc/user/admin_area/settings/usage_statistics.md b/doc/user/admin_area/settings/usage_statistics.md index d874688cc29..381efdf5d67 100644 --- a/doc/user/admin_area/settings/usage_statistics.md +++ b/doc/user/admin_area/settings/usage_statistics.md @@ -8,20 +8,26 @@ under **Admin area > Settings > Usage statistics**. ## Version check -GitLab can inform you when an update is available and the importance of it. +If enabled, version check will inform you if a new version is available and the +importance of it through a status. This is shown on the help page (i.e. `/help`) +for all signed in users, and on the admin pages. The statuses are: -No information other than the GitLab version and the instance's hostname (through the HTTP referer) -are collected. +* Green: You are running the latest version of GitLab. +* Orange: An updated version of GitLab is available. +* Red: The version of GitLab you are running is vulnerable. You should install + the latest version with security fixes as soon as possible. -In the **Overview** tab you can see if your GitLab version is up to date. There -are three cases: 1) you are up to date (green), 2) there is an update available -(yellow) and 3) your version is vulnerable and a security fix is released (red). +![Orange version check example](img/update-available.png) -In any case, you will see a message informing you of the state and the -importance of the update. +GitLab Inc. collects your instance's version and hostname (through the HTTP +referer) as part of the version check. No other information is collected. -If enabled, the version status will also be shown in the help page (`/help`) -for all signed in users. +This information is used, among other things, to identify to which versions +patches will need to be back ported, making sure active GitLab instances remain +secure. + +If you disable version check, this information will not be collected. Enable or +disable the version check at **Admin area > Settings > Usage statistics**. ## Usage ping diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md index 4ac54f96aa2..661697aaeb7 100644 --- a/doc/user/project/clusters/index.md +++ b/doc/user/project/clusters/index.md @@ -109,6 +109,41 @@ you will be notified. You can now proceed to install some pre-defined applications and then enable the Kubernetes cluster integration. +## Security implications + +CAUTION: **Important:** +The whole cluster security is based on a model where [developers](../../permissions.md) +are trusted, so **only trusted users should be allowed to control your clusters**. + +The default cluster configuration grants access to a wide set of +functionalities needed to successfully build and deploy a containerized +application. Bare in mind that the same credentials are used for all the +applications running on the cluster. + +When GitLab creates the cluster, it enables and uses the legacy +[Attribute-based access control (ABAC)](https://kubernetes.io/docs/admin/authorization/abac/). +The newer [RBAC](https://kubernetes.io/docs/admin/authorization/rbac/) +authorization will be supported in a +[future release](https://gitlab.com/gitlab-org/gitlab-ce/issues/29398). + +### Security of GitLab Runners + +GitLab Runners have the [privileged mode](https://docs.gitlab.com/runner/executors/docker.html#the-privileged-mode) +enabled by default, which allows them to execute special commands and running +Docker in Docker. This functionality is needed to run some of the [Auto DevOps] +jobs. This implies the containers are running in privileged mode and you should, +therefore, be aware of some important details. + +The privileged flag gives all capabilities to the running container, which in +turn can do almost everything that the host can do. Be aware of the +inherent security risk associated with performing `docker run` operations on +arbitrary images as they effectively have root access. + +If you don't want to use GitLab Runner in privileged mode, first make sure that +you don't have it installed via the applications, and then use the +[Runner's Helm chart](../../../install/kubernetes/gitlab_runner_chart.md) to +install it manually. + ## Installing applications GitLab provides a one-click install for various applications which will be @@ -118,16 +153,16 @@ added directly to your configured cluster. Those applications are needed for | Application | GitLab version | Description | | ----------- | :------------: | ----------- | | [Helm Tiller](https://docs.helm.sh/) | 10.2+ | Helm is a package manager for Kubernetes and is required to install all the other applications. It will be automatically installed as a dependency when you try to install a different app. It is installed in its own pod inside the cluster which can run the `helm` CLI in a safe environment. | -| [Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) | 10.2+ | Ingress can provide load balancing, SSL termination, and name-based virtual hosting. It acts as a web proxy for your applications and is useful if you want to use [Auto DevOps](../../../topics/autodevops/index.md) or deploy your own web apps. | +| [Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) | 10.2+ | Ingress can provide load balancing, SSL termination, and name-based virtual hosting. It acts as a web proxy for your applications and is useful if you want to use [Auto DevOps] or deploy your own web apps. | | [Prometheus](https://prometheus.io/docs/introduction/overview/) | 10.4+ | Prometheus is an open-source monitoring and alerting system useful to supervise your deployed applications | -| [GitLab Runner](https://docs.gitlab.com/runner/) | 10.6+ | GitLab Runner is the open source project that is used to run your jobs and send the results back to GitLab. It is used in conjunction with [GitLab CI](https://about.gitlab.com/features/gitlab-ci-cd/), the open-source continuous integration service included with GitLab that coordinates the jobs. | +| [GitLab Runner](https://docs.gitlab.com/runner/) | 10.6+ | GitLab Runner is the open source project that is used to run your jobs and send the results back to GitLab. It is used in conjunction with [GitLab CI/CD](https://about.gitlab.com/features/gitlab-ci-cd/), the open-source continuous integration service included with GitLab that coordinates the jobs. When installing the GitLab Runner via the applications, it will run in **privileged mode** by default. Make sure you read the [security implications](#security-implications) before doing so. | ## Getting the external IP address NOTE: **Note:** You need a load balancer installed in your cluster in order to obtain the external IP address with the following procedure. It can be deployed using the -[**Ingress** application](#installing-appplications). +[**Ingress** application](#installing-applications). In order to publish your web application, you first need to find the external IP address associated to your load balancer. @@ -329,3 +364,4 @@ the deployment variables above, ensuring any pods you create are labelled with [permissions]: ../../permissions.md [ee]: https://about.gitlab.com/products/ +[Auto DevOps]: ../../../topics/autodevops/index.md diff --git a/doc/user/project/merge_requests/img/allow_maintainer_push.png b/doc/user/project/merge_requests/img/allow_maintainer_push.png Binary files differnew file mode 100644 index 00000000000..1631527071b --- /dev/null +++ b/doc/user/project/merge_requests/img/allow_maintainer_push.png diff --git a/doc/user/project/merge_requests/index.md b/doc/user/project/merge_requests/index.md index d3220598933..10d67729734 100644 --- a/doc/user/project/merge_requests/index.md +++ b/doc/user/project/merge_requests/index.md @@ -28,6 +28,7 @@ With GitLab merge requests, you can: - Enable [fast-forward merge requests](#fast-forward-merge-requests) - Enable [semi-linear history merge requests](#semi-linear-history-merge-requests) as another security layer to guarantee the pipeline is passing in the target branch - [Create new merge requests by email](#create-new-merge-requests-by-email) +- Allow maintainers of the target project to push directly to the fork by [allowing edits from maintainers](maintainer_access.md) With **[GitLab Enterprise Edition][ee]**, you can also: diff --git a/doc/user/project/merge_requests/maintainer_access.md b/doc/user/project/merge_requests/maintainer_access.md new file mode 100644 index 00000000000..7feccc28f6b --- /dev/null +++ b/doc/user/project/merge_requests/maintainer_access.md @@ -0,0 +1,13 @@ +# Allow maintainer pushes for merge requests across forks + +This feature is available for merge requests across forked projects that are +publicly accessible. It makes it easier for maintainers of projects to collaborate +on merge requests across forks. + +When enabling this feature for a merge request, you give can give members with push access to the target project rights to edit files on the source branch of the merge request. + +The feature can only be enabled by users who already have push access to the source project. And only lasts while the merge request is open. + +Enable this functionality while creating a merge request: + +![Enable maintainer edits](./img/allow_maintainer_push.png) diff --git a/features/steps/shared/project.rb b/features/steps/shared/project.rb index affbccccdf9..07a0e2e072c 100644 --- a/features/steps/shared/project.rb +++ b/features/steps/shared/project.rb @@ -82,7 +82,7 @@ module SharedProject step 'I should see project "Shop" activity feed' do project = Project.find_by(name: "Shop") - expect(page).to have_content "#{@user.name} pushed new branch fix at #{project.name_with_namespace}" + expect(page).to have_content "#{@user.name} pushed new branch fix at #{project.full_name}" end step 'I should see project settings' do @@ -113,12 +113,12 @@ module SharedProject step 'I should not see project "Archive"' do project = Project.find_by(name: "Archive") - expect(page).not_to have_content project.name_with_namespace + expect(page).not_to have_content project.full_name end step 'I should see project "Archive"' do project = Project.find_by(name: "Archive") - expect(page).to have_content project.name_with_namespace + expect(page).to have_content project.full_name end # ---------------------------------------- diff --git a/lib/api/api.rb b/lib/api/api.rb index 5e93c129bc8..62ffebeacb0 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -136,6 +136,7 @@ module API mount ::API::MergeRequests mount ::API::Namespaces mount ::API::Notes + mount ::API::Discussions mount ::API::NotificationSettings mount ::API::PagesDomains mount ::API::Pipelines diff --git a/lib/api/discussions.rb b/lib/api/discussions.rb new file mode 100644 index 00000000000..6abd575b6ad --- /dev/null +++ b/lib/api/discussions.rb @@ -0,0 +1,195 @@ +module API + class Discussions < Grape::API + include PaginationParams + helpers ::API::Helpers::NotesHelpers + + before { authenticate! } + + NOTEABLE_TYPES = [Issue, Snippet].freeze + + NOTEABLE_TYPES.each do |noteable_type| + parent_type = noteable_type.parent_class.to_s.underscore + noteables_str = noteable_type.to_s.underscore.pluralize + + params do + requires :id, type: String, desc: "The ID of a #{parent_type}" + end + resource parent_type.pluralize.to_sym, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + desc "Get a list of #{noteable_type.to_s.downcase} discussions" do + success Entities::Discussion + end + params do + requires :noteable_id, type: Integer, desc: 'The ID of the noteable' + use :pagination + end + get ":id/#{noteables_str}/:noteable_id/discussions" do + noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) + + return not_found!("Discussions") unless can?(current_user, noteable_read_ability_name(noteable), noteable) + + notes = noteable.notes + .inc_relations_for_view + .includes(:noteable) + .fresh + + notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } + discussions = Kaminari.paginate_array(Discussion.build_collection(notes, noteable)) + + present paginate(discussions), with: Entities::Discussion + end + + desc "Get a single #{noteable_type.to_s.downcase} discussion" do + success Entities::Discussion + end + params do + requires :discussion_id, type: String, desc: 'The ID of a discussion' + requires :noteable_id, type: Integer, desc: 'The ID of the noteable' + end + get ":id/#{noteables_str}/:noteable_id/discussions/:discussion_id" do + noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) + notes = readable_discussion_notes(noteable, params[:discussion_id]) + + if notes.empty? || !can?(current_user, noteable_read_ability_name(noteable), noteable) + return not_found!("Discussion") + end + + discussion = Discussion.build(notes, noteable) + + present discussion, with: Entities::Discussion + end + + desc "Create a new #{noteable_type.to_s.downcase} discussion" do + success Entities::Discussion + end + params do + requires :noteable_id, type: Integer, desc: 'The ID of the noteable' + requires :body, type: String, desc: 'The content of a note' + optional :created_at, type: String, desc: 'The creation date of the note' + end + post ":id/#{noteables_str}/:noteable_id/discussions" do + noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) + + opts = { + note: params[:body], + created_at: params[:created_at], + type: 'DiscussionNote', + noteable_type: noteables_str.classify, + noteable_id: noteable.id + } + + note = create_note(noteable, opts) + + if note.valid? + present note.discussion, with: Entities::Discussion + else + bad_request!("Note #{note.errors.messages}") + end + end + + desc "Get comments in a single #{noteable_type.to_s.downcase} discussion" do + success Entities::Discussion + end + params do + requires :discussion_id, type: String, desc: 'The ID of a discussion' + requires :noteable_id, type: Integer, desc: 'The ID of the noteable' + end + get ":id/#{noteables_str}/:noteable_id/discussions/:discussion_id/notes" do + noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) + notes = readable_discussion_notes(noteable, params[:discussion_id]) + + if notes.empty? || !can?(current_user, noteable_read_ability_name(noteable), noteable) + return not_found!("Notes") + end + + present notes, with: Entities::Note + end + + desc "Add a comment to a #{noteable_type.to_s.downcase} discussion" do + success Entities::Note + end + params do + requires :noteable_id, type: Integer, desc: 'The ID of the noteable' + requires :discussion_id, type: String, desc: 'The ID of a discussion' + requires :body, type: String, desc: 'The content of a note' + optional :created_at, type: String, desc: 'The creation date of the note' + end + post ":id/#{noteables_str}/:noteable_id/discussions/:discussion_id/notes" do + noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) + notes = readable_discussion_notes(noteable, params[:discussion_id]) + + return not_found!("Discussion") if notes.empty? + return bad_request!("Discussion is an individual note.") unless notes.first.part_of_discussion? + + opts = { + note: params[:body], + type: 'DiscussionNote', + in_reply_to_discussion_id: params[:discussion_id], + created_at: params[:created_at] + } + note = create_note(noteable, opts) + + if note.valid? + present note, with: Entities::Note + else + bad_request!("Note #{note.errors.messages}") + end + end + + desc "Get a comment in a #{noteable_type.to_s.downcase} discussion" do + success Entities::Note + end + params do + requires :noteable_id, type: Integer, desc: 'The ID of the noteable' + requires :discussion_id, type: String, desc: 'The ID of a discussion' + requires :note_id, type: Integer, desc: 'The ID of a note' + end + get ":id/#{noteables_str}/:noteable_id/discussions/:discussion_id/notes/:note_id" do + noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) + + get_note(noteable, params[:note_id]) + end + + desc "Edit a comment in a #{noteable_type.to_s.downcase} discussion" do + success Entities::Note + end + params do + requires :noteable_id, type: Integer, desc: 'The ID of the noteable' + requires :discussion_id, type: String, desc: 'The ID of a discussion' + requires :note_id, type: Integer, desc: 'The ID of a note' + requires :body, type: String, desc: 'The content of a note' + end + put ":id/#{noteables_str}/:noteable_id/discussions/:discussion_id/notes/:note_id" do + noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) + + update_note(noteable, params[:note_id]) + end + + desc "Delete a comment in a #{noteable_type.to_s.downcase} discussion" do + success Entities::Note + end + params do + requires :noteable_id, type: Integer, desc: 'The ID of the noteable' + requires :discussion_id, type: String, desc: 'The ID of a discussion' + requires :note_id, type: Integer, desc: 'The ID of a note' + end + delete ":id/#{noteables_str}/:noteable_id/discussions/:discussion_id/notes/:note_id" do + noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) + + delete_note(noteable, params[:note_id]) + end + end + end + + helpers do + def readable_discussion_notes(noteable, discussion_id) + notes = noteable.notes + .where(discussion_id: discussion_id) + .inc_relations_for_view + .includes(:noteable) + .fresh + + notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } + end + end + end +end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index f39906270d8..16147ee90c9 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -547,6 +547,7 @@ module API expose :discussion_locked expose :should_remove_source_branch?, as: :should_remove_source_branch expose :force_remove_source_branch?, as: :force_remove_source_branch + expose :allow_maintainer_to_push, if: -> (merge_request, _) { merge_request.for_fork? } expose :web_url do |merge_request, options| Gitlab::UrlBuilder.build(merge_request) @@ -644,6 +645,7 @@ module API NOTEABLE_TYPES_WITH_IID = %w(Issue MergeRequest).freeze expose :id + expose :type expose :note, as: :body expose :attachment_identifier, as: :attachment expose :author, using: Entities::UserBasic @@ -655,6 +657,12 @@ module API expose(:noteable_iid) { |note| note.noteable.iid if NOTEABLE_TYPES_WITH_IID.include?(note.noteable_type) } end + class Discussion < Grape::Entity + expose :id + expose :individual_note?, as: :individual_note + expose :notes, using: Entities::Note + end + class AwardEmoji < Grape::Entity expose :id expose :name diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb index cd59da6fc70..4b564cfdef2 100644 --- a/lib/api/helpers/internal_helpers.rb +++ b/lib/api/helpers/internal_helpers.rb @@ -111,13 +111,6 @@ module API def gitaly_payload(action) return unless %w[git-receive-pack git-upload-pack].include?(action) - if action == 'git-receive-pack' - return unless Gitlab::GitalyClient.feature_enabled?( - :ssh_receive_pack, - status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT - ) - end - { repository: repository.gitaly_repository, address: Gitlab::GitalyClient.address(project.repository_storage), diff --git a/lib/api/helpers/notes_helpers.rb b/lib/api/helpers/notes_helpers.rb new file mode 100644 index 00000000000..cd91df1ecd8 --- /dev/null +++ b/lib/api/helpers/notes_helpers.rb @@ -0,0 +1,76 @@ +module API + module Helpers + module NotesHelpers + def update_note(noteable, note_id) + note = noteable.notes.find(params[:note_id]) + + authorize! :admin_note, note + + opts = { + note: params[:body] + } + parent = noteable_parent(noteable) + project = parent if parent.is_a?(Project) + + note = ::Notes::UpdateService.new(project, current_user, opts).execute(note) + + if note.valid? + present note, with: Entities::Note + else + bad_request!("Failed to save note #{note.errors.messages}") + end + end + + def delete_note(noteable, note_id) + note = noteable.notes.find(note_id) + + authorize! :admin_note, note + + parent = noteable_parent(noteable) + project = parent if parent.is_a?(Project) + destroy_conditionally!(note) do |note| + ::Notes::DestroyService.new(project, current_user).execute(note) + end + end + + def get_note(noteable, note_id) + note = noteable.notes.with_metadata.find(params[:note_id]) + can_read_note = can?(current_user, noteable_read_ability_name(noteable), noteable) && !note.cross_reference_not_visible_for?(current_user) + + if can_read_note + present note, with: Entities::Note + else + not_found!("Note") + end + end + + def noteable_read_ability_name(noteable) + "read_#{noteable.class.to_s.underscore}".to_sym + end + + def find_noteable(parent, noteables_str, noteable_id) + public_send("find_#{parent}_#{noteables_str.singularize}", noteable_id) # rubocop:disable GitlabSecurity/PublicSend + end + + def noteable_parent(noteable) + public_send("user_#{noteable.class.parent_class.to_s.underscore}") # rubocop:disable GitlabSecurity/PublicSend + end + + def create_note(noteable, opts) + noteables_str = noteable.model_name.to_s.underscore.pluralize + + return not_found!(noteables_str) unless can?(current_user, noteable_read_ability_name(noteable), noteable) + + authorize! :create_note, noteable + + parent = noteable_parent(noteable) + if opts[:created_at] + opts.delete(:created_at) unless current_user.admin? || parent.owner == current_user + end + + project = parent if parent.is_a?(Project) + ::Notes::CreateService.new(project, current_user, opts).execute + end + end + end +end diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index ead1bb7957b..3264a26f7d2 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -144,6 +144,7 @@ module API optional :milestone_id, type: Integer, desc: 'The ID of a milestone to assign the merge request' optional :labels, type: String, desc: 'Comma-separated list of label names' optional :remove_source_branch, type: Boolean, desc: 'Remove source branch when merging' + optional :allow_maintainer_to_push, type: Boolean, desc: 'Whether a maintainer of the target project can push to the source project' use :optional_params_ee end diff --git a/lib/api/notes.rb b/lib/api/notes.rb index 3588dc85c9e..69f1df6b341 100644 --- a/lib/api/notes.rb +++ b/lib/api/notes.rb @@ -1,19 +1,23 @@ module API class Notes < Grape::API include PaginationParams + helpers ::API::Helpers::NotesHelpers before { authenticate! } NOTEABLE_TYPES = [Issue, MergeRequest, Snippet].freeze - params do - requires :id, type: String, desc: 'The ID of a project' - end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do - NOTEABLE_TYPES.each do |noteable_type| + NOTEABLE_TYPES.each do |noteable_type| + parent_type = noteable_type.parent_class.to_s.underscore + noteables_str = noteable_type.to_s.underscore.pluralize + + params do + requires :id, type: String, desc: "The ID of a #{parent_type}" + end + resource parent_type.pluralize.to_sym, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do noteables_str = noteable_type.to_s.underscore.pluralize - desc 'Get a list of project +noteable+ notes' do + desc "Get a list of #{noteable_type.to_s.downcase} notes" do success Entities::Note end params do @@ -25,7 +29,7 @@ module API use :pagination end get ":id/#{noteables_str}/:noteable_id/notes" do - noteable = find_project_noteable(noteables_str, params[:noteable_id]) + noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) if can?(current_user, noteable_read_ability_name(noteable), noteable) # We exclude notes that are cross-references and that cannot be viewed @@ -46,7 +50,7 @@ module API end end - desc 'Get a single +noteable+ note' do + desc "Get a single #{noteable_type.to_s.downcase} note" do success Entities::Note end params do @@ -54,18 +58,11 @@ module API requires :noteable_id, type: Integer, desc: 'The ID of the noteable' end get ":id/#{noteables_str}/:noteable_id/notes/:note_id" do - noteable = find_project_noteable(noteables_str, params[:noteable_id]) - note = noteable.notes.with_metadata.find(params[:note_id]) - can_read_note = can?(current_user, noteable_read_ability_name(noteable), noteable) && !note.cross_reference_not_visible_for?(current_user) - - if can_read_note - present note, with: Entities::Note - else - not_found!("Note") - end + noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) + get_note(noteable, params[:note_id]) end - desc 'Create a new +noteable+ note' do + desc "Create a new #{noteable_type.to_s.downcase} note" do success Entities::Note end params do @@ -74,34 +71,25 @@ module API optional :created_at, type: String, desc: 'The creation date of the note' end post ":id/#{noteables_str}/:noteable_id/notes" do - noteable = find_project_noteable(noteables_str, params[:noteable_id]) + noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) opts = { note: params[:body], noteable_type: noteables_str.classify, - noteable_id: noteable.id + noteable_id: noteable.id, + created_at: params[:created_at] } - if can?(current_user, noteable_read_ability_name(noteable), noteable) - authorize! :create_note, noteable + note = create_note(noteable, opts) - if params[:created_at] && (current_user.admin? || user_project.owner == current_user) - opts[:created_at] = params[:created_at] - end - - note = ::Notes::CreateService.new(user_project, current_user, opts).execute - - if note.valid? - present note, with: Entities.const_get(note.class.name) - else - not_found!("Note #{note.errors.messages}") - end + if note.valid? + present note, with: Entities.const_get(note.class.name) else - not_found!("Note") + bad_request!("Note #{note.errors.messages}") end end - desc 'Update an existing +noteable+ note' do + desc "Update an existing #{noteable_type.to_s.downcase} note" do success Entities::Note end params do @@ -110,24 +98,12 @@ module API requires :body, type: String, desc: 'The content of a note' end put ":id/#{noteables_str}/:noteable_id/notes/:note_id" do - note = user_project.notes.find(params[:note_id]) + noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) - authorize! :admin_note, note - - opts = { - note: params[:body] - } - - note = ::Notes::UpdateService.new(user_project, current_user, opts).execute(note) - - if note.valid? - present note, with: Entities::Note - else - render_api_error!("Failed to save note #{note.errors.messages}", 400) - end + update_note(noteable, params[:note_id]) end - desc 'Delete a +noteable+ note' do + desc "Delete a #{noteable_type.to_s.downcase} note" do success Entities::Note end params do @@ -135,25 +111,11 @@ module API requires :note_id, type: Integer, desc: 'The ID of a note' end delete ":id/#{noteables_str}/:noteable_id/notes/:note_id" do - note = user_project.notes.find(params[:note_id]) - - authorize! :admin_note, note + noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) - destroy_conditionally!(note) do |note| - ::Notes::DestroyService.new(user_project, current_user).execute(note) - end + delete_note(noteable, params[:note_id]) end end end - - helpers do - def find_project_noteable(noteables_str, noteable_id) - public_send("find_project_#{noteables_str.singularize}", noteable_id) # rubocop:disable GitlabSecurity/PublicSend - end - - def noteable_read_ability_name(noteable) - "read_#{noteable.class.to_s.underscore}".to_sym - end - end end end diff --git a/lib/banzai/filter/commit_reference_filter.rb b/lib/banzai/filter/commit_reference_filter.rb index eedb95197aa..43bf4fc6565 100644 --- a/lib/banzai/filter/commit_reference_filter.rb +++ b/lib/banzai/filter/commit_reference_filter.rb @@ -18,7 +18,8 @@ module Banzai def find_object(project, id) if project && project.valid_repo? - project.commit(id) + # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/43894 + Gitlab::GitalyClient.allow_n_plus_1_calls { project.commit(id) } end end diff --git a/lib/banzai/filter/markdown_engines/common_mark.rb b/lib/banzai/filter/markdown_engines/common_mark.rb new file mode 100644 index 00000000000..bc9597df894 --- /dev/null +++ b/lib/banzai/filter/markdown_engines/common_mark.rb @@ -0,0 +1,45 @@ +# `CommonMark` markdown engine for GitLab's Banzai markdown filter. +# This module is used in Banzai::Filter::MarkdownFilter. +# Used gem is `commonmarker` which is a ruby wrapper for libcmark (CommonMark parser) +# including GitHub's GFM extensions. +# Homepage: https://github.com/gjtorikian/commonmarker + +module Banzai + module Filter + module MarkdownEngines + class CommonMark + EXTENSIONS = [ + :autolink, # provides support for automatically converting URLs to anchor tags. + :strikethrough, # provides support for strikethroughs. + :table, # provides support for tables. + :tagfilter # strips out several "unsafe" HTML tags from being used: https://github.github.com/gfm/#disallowed-raw-html-extension- + ].freeze + + PARSE_OPTIONS = [ + :FOOTNOTES, # parse footnotes. + :STRIKETHROUGH_DOUBLE_TILDE, # parse strikethroughs by double tildes (as redcarpet does). + :VALIDATE_UTF8 # replace illegal sequences with the replacement character U+FFFD. + ].freeze + + # The `:GITHUB_PRE_LANG` option is not used intentionally because + # it renders a fence block with language as `<pre lang="LANG"><code>some code\n</code></pre>` + # while GitLab's syntax is `<pre><code lang="LANG">some code\n</code></pre>`. + # If in the future the syntax is about to be made GitHub-compatible, please, add `:GITHUB_PRE_LANG` render option below + # and remove `code_block` method from `lib/banzai/renderer/common_mark/html.rb`. + RENDER_OPTIONS = [ + :DEFAULT # default rendering system. Nothing special. + ].freeze + + def initialize + @renderer = Banzai::Renderer::CommonMark::HTML.new(options: RENDER_OPTIONS) + end + + def render(text) + doc = CommonMarker.render_doc(text, PARSE_OPTIONS, EXTENSIONS) + + @renderer.render(doc) + end + end + end + end +end diff --git a/lib/banzai/filter/markdown_engines/redcarpet.rb b/lib/banzai/filter/markdown_engines/redcarpet.rb new file mode 100644 index 00000000000..ac99941fefa --- /dev/null +++ b/lib/banzai/filter/markdown_engines/redcarpet.rb @@ -0,0 +1,32 @@ +# `Redcarpet` markdown engine for GitLab's Banzai markdown filter. +# This module is used in Banzai::Filter::MarkdownFilter. +# Used gem is `redcarpet` which is a ruby library for markdown processing. +# Homepage: https://github.com/vmg/redcarpet + +module Banzai + module Filter + module MarkdownEngines + class Redcarpet + OPTIONS = { + fenced_code_blocks: true, + footnotes: true, + lax_spacing: true, + no_intra_emphasis: true, + space_after_headers: true, + strikethrough: true, + superscript: true, + tables: true + }.freeze + + def initialize + html_renderer = Banzai::Renderer::Redcarpet::HTML.new + @renderer = ::Redcarpet::Markdown.new(html_renderer, OPTIONS) + end + + def render(text) + @renderer.render(text) + end + end + end + end +end diff --git a/lib/banzai/filter/markdown_filter.rb b/lib/banzai/filter/markdown_filter.rb index 9cac303e645..c1e2b680240 100644 --- a/lib/banzai/filter/markdown_filter.rb +++ b/lib/banzai/filter/markdown_filter.rb @@ -1,34 +1,31 @@ module Banzai module Filter class MarkdownFilter < HTML::Pipeline::TextFilter - # https://github.com/vmg/redcarpet#and-its-like-really-simple-to-use - REDCARPET_OPTIONS = { - fenced_code_blocks: true, - footnotes: true, - lax_spacing: true, - no_intra_emphasis: true, - space_after_headers: true, - strikethrough: true, - superscript: true, - tables: true - }.freeze - def initialize(text, context = nil, result = nil) - super text, context, result - @text = @text.delete "\r" + super(text, context, result) + + @renderer = renderer(context[:markdown_engine]).new + @text = @text.delete("\r") end def call - html = self.class.renderer.render(@text) - html.rstrip! - html + @renderer.render(@text).rstrip + end + + private + + DEFAULT_ENGINE = :redcarpet + + def engine(engine_from_context) + engine_from_context ||= DEFAULT_ENGINE + + engine_from_context.to_s.classify end - def self.renderer - Thread.current[:banzai_markdown_renderer] ||= begin - renderer = Banzai::Renderer::HTML.new - Redcarpet::Markdown.new(renderer, REDCARPET_OPTIONS) - end + def renderer(engine_from_context) + "Banzai::Filter::MarkdownEngines::#{engine(engine_from_context)}".constantize + rescue NameError + raise NameError, "`#{engine_from_context}` is unknown markdown engine" end end end diff --git a/lib/banzai/filter/syntax_highlight_filter.rb b/lib/banzai/filter/syntax_highlight_filter.rb index 0ac7e231b5b..6dbf0d68fe8 100644 --- a/lib/banzai/filter/syntax_highlight_filter.rb +++ b/lib/banzai/filter/syntax_highlight_filter.rb @@ -1,3 +1,4 @@ +require 'rouge/plugins/common_mark' require 'rouge/plugins/redcarpet' module Banzai diff --git a/lib/banzai/renderer/common_mark/html.rb b/lib/banzai/renderer/common_mark/html.rb new file mode 100644 index 00000000000..c7a54629f31 --- /dev/null +++ b/lib/banzai/renderer/common_mark/html.rb @@ -0,0 +1,21 @@ +module Banzai + module Renderer + module CommonMark + class HTML < CommonMarker::HtmlRenderer + def code_block(node) + block do + code = node.string_content + lang = node.fence_info + lang_attr = lang.present? ? %Q{ lang="#{lang}"} : '' + result = + "<pre>" \ + "<code#{lang_attr}>#{html_escape(code)}</code>" \ + "</pre>" + + out(result) + end + end + end + end + end +end diff --git a/lib/banzai/renderer/html.rb b/lib/banzai/renderer/html.rb deleted file mode 100644 index 252caa35947..00000000000 --- a/lib/banzai/renderer/html.rb +++ /dev/null @@ -1,13 +0,0 @@ -module Banzai - module Renderer - class HTML < Redcarpet::Render::HTML - def block_code(code, lang) - lang_attr = lang ? %Q{ lang="#{lang}"} : '' - - "\n<pre>" \ - "<code#{lang_attr}>#{html_escape(code)}</code>" \ - "</pre>" - end - end - end -end diff --git a/lib/banzai/renderer/redcarpet/html.rb b/lib/banzai/renderer/redcarpet/html.rb new file mode 100644 index 00000000000..94df5d8b1e1 --- /dev/null +++ b/lib/banzai/renderer/redcarpet/html.rb @@ -0,0 +1,15 @@ +module Banzai + module Renderer + module Redcarpet + class HTML < ::Redcarpet::Render::HTML + def block_code(code, lang) + lang_attr = lang ? %Q{ lang="#{lang}"} : '' + + "\n<pre>" \ + "<code#{lang_attr}>#{html_escape(code)}</code>" \ + "</pre>" + end + end + end + end +end diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index d48ae17aeaf..bffbcb86137 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -135,7 +135,7 @@ module Gitlab if label.valid? @labels[label_params[:title]] = label else - raise "Failed to create label \"#{label_params[:title]}\" for project \"#{project.name_with_namespace}\"" + raise "Failed to create label \"#{label_params[:title]}\" for project \"#{project.full_name}\"" end end end diff --git a/lib/gitlab/checks/change_access.rb b/lib/gitlab/checks/change_access.rb index 3ce5f807989..51ba09aa129 100644 --- a/lib/gitlab/checks/change_access.rb +++ b/lib/gitlab/checks/change_access.rb @@ -47,7 +47,7 @@ module Gitlab protected def push_checks - if user_access.cannot_do_action?(:push_code) + unless can_push? raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:push_code] end end @@ -183,6 +183,11 @@ module Gitlab def commits @commits ||= project.repository.new_commits(newrev) end + + def can_push? + user_access.can_do_action?(:push_code) || + user_access.can_push_to_branch?(branch_name) + end end end end diff --git a/lib/gitlab/ci/charts.rb b/lib/gitlab/ci/charts.rb index 525563a97f5..46ed330dbbf 100644 --- a/lib/gitlab/ci/charts.rb +++ b/lib/gitlab/ci/charts.rb @@ -68,10 +68,11 @@ module Gitlab class YearChart < Chart include MonthlyInterval + attr_reader :to, :from def initialize(*) - @to = Date.today.end_of_month - @from = @to.years_ago(1).beginning_of_month + @to = Date.today.end_of_month.end_of_day + @from = @to.years_ago(1).beginning_of_month.beginning_of_day @format = '%d %B %Y' super @@ -80,10 +81,11 @@ module Gitlab class MonthChart < Chart include DailyInterval + attr_reader :to, :from def initialize(*) - @to = Date.today - @from = @to - 30.days + @to = Date.today.end_of_day + @from = 1.month.ago.beginning_of_day @format = '%d %B' super @@ -92,10 +94,11 @@ module Gitlab class WeekChart < Chart include DailyInterval + attr_reader :to, :from def initialize(*) - @to = Date.today - @from = @to - 7.days + @to = Date.today.end_of_day + @from = 1.week.ago.beginning_of_day @format = '%d %B' super diff --git a/lib/gitlab/contributions_calendar.rb b/lib/gitlab/contributions_calendar.rb index 02d3763514e..d7369060cc5 100644 --- a/lib/gitlab/contributions_calendar.rb +++ b/lib/gitlab/contributions_calendar.rb @@ -23,7 +23,7 @@ module Gitlab mr_events = event_counts(date_from, :merge_requests) .having(action: [Event::MERGED, Event::CREATED, Event::CLOSED], target_type: "MergeRequest") note_events = event_counts(date_from, :merge_requests) - .having(action: [Event::COMMENTED], target_type: %w(Note DiffNote)) + .having(action: [Event::COMMENTED]) union = Gitlab::SQL::Union.new([repo_events, issue_events, mr_events, note_events]) events = Event.find_by_sql(union.to_sql).map(&:attributes) diff --git a/lib/gitlab/data_builder/build.rb b/lib/gitlab/data_builder/build.rb index 8e74e18a311..2f1445a050a 100644 --- a/lib/gitlab/data_builder/build.rb +++ b/lib/gitlab/data_builder/build.rb @@ -31,7 +31,7 @@ module Gitlab # TODO: do we still need it? project_id: project.id, - project_name: project.name_with_namespace, + project_name: project.full_name, user: { id: user.try(:id), diff --git a/lib/gitlab/data_builder/pipeline.rb b/lib/gitlab/data_builder/pipeline.rb index e47fb85b5ee..1e283cc092b 100644 --- a/lib/gitlab/data_builder/pipeline.rb +++ b/lib/gitlab/data_builder/pipeline.rb @@ -22,6 +22,7 @@ module Gitlab sha: pipeline.sha, before_sha: pipeline.before_sha, status: pipeline.status, + detailed_status: pipeline.detailed_status(nil).label, stages: pipeline.stages_names, created_at: pipeline.created_at, finished_at: pipeline.finished_at, diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index 594b6a9cbc5..93037ed8d90 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -347,7 +347,7 @@ module Gitlab # # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/324 def to_diff - Gitlab::GitalyClient.migrate(:commit_patch) do |is_enabled| + Gitlab::GitalyClient.migrate(:commit_patch, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled| if is_enabled @repository.gitaly_commit_client.patch(id) else diff --git a/lib/gitlab/git/gitlab_projects.rb b/lib/gitlab/git/gitlab_projects.rb index e5a747cb987..5e1e22ae65c 100644 --- a/lib/gitlab/git/gitlab_projects.rb +++ b/lib/gitlab/git/gitlab_projects.rb @@ -63,11 +63,12 @@ module Gitlab end end - def fetch_remote(name, timeout, force:, tags:, ssh_key: nil, known_hosts: nil) + def fetch_remote(name, timeout, force:, tags:, ssh_key: nil, known_hosts: nil, prune: true) tags_option = tags ? '--tags' : '--no-tags' logger.info "Fetching remote #{name} for repository #{repository_absolute_path}." - cmd = %W(git fetch #{name} --prune --quiet) + cmd = %W(git fetch #{name} --quiet) + cmd << '--prune' if prune cmd << '--force' if force cmd << tags_option diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 21c79a7a550..d4f6b543daf 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -228,7 +228,7 @@ module Gitlab end def has_local_branches? - gitaly_migrate(:has_local_branches) do |is_enabled| + gitaly_migrate(:has_local_branches, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled| if is_enabled gitaly_repository_client.has_local_branches? else @@ -715,7 +715,7 @@ module Gitlab end def add_branch(branch_name, user:, target:) - gitaly_migrate(:operation_user_create_branch) do |is_enabled| + gitaly_migrate(:operation_user_create_branch, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled| if is_enabled gitaly_add_branch(branch_name, user, target) else @@ -725,7 +725,7 @@ module Gitlab end def add_tag(tag_name, user:, target:, message: nil) - gitaly_migrate(:operation_user_add_tag) do |is_enabled| + gitaly_migrate(:operation_user_add_tag, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled| if is_enabled gitaly_add_tag(tag_name, user: user, target: target, message: message) else @@ -735,7 +735,7 @@ module Gitlab end def rm_branch(branch_name, user:) - gitaly_migrate(:operation_user_delete_branch) do |is_enabled| + gitaly_migrate(:operation_user_delete_branch, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled| if is_enabled gitaly_operations_client.user_delete_branch(branch_name, user) else @@ -810,7 +810,7 @@ module Gitlab end def revert(user:, commit:, branch_name:, message:, start_branch_name:, start_repository:) - gitaly_migrate(:revert) do |is_enabled| + gitaly_migrate(:revert, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled| args = { user: user, commit: commit, @@ -876,7 +876,7 @@ module Gitlab # Delete the specified branch from the repository def delete_branch(branch_name) - gitaly_migrate(:delete_branch) do |is_enabled| + gitaly_migrate(:delete_branch, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled| if is_enabled gitaly_ref_client.delete_branch(branch_name) else @@ -903,7 +903,7 @@ module Gitlab # create_branch("feature") # create_branch("other-feature", "master") def create_branch(ref, start_point = "HEAD") - gitaly_migrate(:create_branch) do |is_enabled| + gitaly_migrate(:create_branch, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled| if is_enabled gitaly_ref_client.create_branch(ref, start_point) else @@ -1010,7 +1010,7 @@ module Gitlab end def languages(ref = nil) - Gitlab::GitalyClient.migrate(:commit_languages) do |is_enabled| + gitaly_migrate(:commit_languages, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled| if is_enabled gitaly_commit_client.languages(ref) else @@ -1443,12 +1443,12 @@ module Gitlab end end - def last_commit_id_for_path(sha, path) + def last_commit_for_path(sha, path) gitaly_migrate(:last_commit_for_path) do |is_enabled| if is_enabled - last_commit_for_path_by_gitaly(sha, path).id + last_commit_for_path_by_gitaly(sha, path) else - last_commit_id_for_path_by_shelling_out(sha, path) + last_commit_for_path_by_rugged(sha, path) end end end @@ -1896,7 +1896,7 @@ module Gitlab end def last_commit_for_path_by_rugged(sha, path) - sha = last_commit_id_for_path(sha, path) + sha = last_commit_id_for_path_by_shelling_out(sha, path) commit(sha) end diff --git a/lib/gitlab/gitaly_client/repository_service.rb b/lib/gitlab/gitaly_client/repository_service.rb index fdb3247cf4d..e1bc2f9ab61 100644 --- a/lib/gitlab/gitaly_client/repository_service.rb +++ b/lib/gitlab/gitaly_client/repository_service.rb @@ -45,10 +45,10 @@ module Gitlab GitalyClient.call(@storage, :repository_service, :apply_gitattributes, request) end - def fetch_remote(remote, ssh_auth:, forced:, no_tags:, timeout:) + def fetch_remote(remote, ssh_auth:, forced:, no_tags:, timeout:, prune: true) request = Gitaly::FetchRemoteRequest.new( repository: @gitaly_repo, remote: remote, force: forced, - no_tags: no_tags, timeout: timeout + no_tags: no_tags, timeout: timeout, no_prune: !prune ) if ssh_auth&.ssh_import? diff --git a/lib/gitlab/shell.rb b/lib/gitlab/shell.rb index 4ba44e0feef..dda7afc0999 100644 --- a/lib/gitlab/shell.rb +++ b/lib/gitlab/shell.rb @@ -125,13 +125,13 @@ module Gitlab # Ex. # fetch_remote(my_repo, "upstream") # - def fetch_remote(repository, remote, ssh_auth: nil, forced: false, no_tags: false) + def fetch_remote(repository, remote, ssh_auth: nil, forced: false, no_tags: false, prune: true) gitaly_migrate(:fetch_remote) do |is_enabled| if is_enabled - repository.gitaly_repository_client.fetch_remote(remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags, timeout: git_timeout) + repository.gitaly_repository_client.fetch_remote(remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags, timeout: git_timeout, prune: prune) else storage_path = Gitlab.config.repositories.storages[repository.storage]["path"] - local_fetch_remote(storage_path, repository.relative_path, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags) + local_fetch_remote(storage_path, repository.relative_path, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags, prune: prune) end end end @@ -428,8 +428,8 @@ module Gitlab ) end - def local_fetch_remote(storage_path, repository_relative_path, remote, ssh_auth: nil, forced: false, no_tags: false) - vars = { force: forced, tags: !no_tags } + def local_fetch_remote(storage_path, repository_relative_path, remote, ssh_auth: nil, forced: false, no_tags: false, prune: true) + vars = { force: forced, tags: !no_tags, prune: prune } if ssh_auth&.ssh_import? if ssh_auth.ssh_key_auth? && ssh_auth.ssh_private_key.present? diff --git a/lib/gitlab/slash_commands/presenters/issue_new.rb b/lib/gitlab/slash_commands/presenters/issue_new.rb index 86490a39cc1..5964bfe9960 100644 --- a/lib/gitlab/slash_commands/presenters/issue_new.rb +++ b/lib/gitlab/slash_commands/presenters/issue_new.rb @@ -38,7 +38,7 @@ module Gitlab end def project_link - "[#{project.name_with_namespace}](#{project.web_url})" + "[#{project.full_name}](#{project.web_url})" end def author_profile_link diff --git a/lib/gitlab/slash_commands/presenters/issue_show.rb b/lib/gitlab/slash_commands/presenters/issue_show.rb index c99316df667..562f15f403c 100644 --- a/lib/gitlab/slash_commands/presenters/issue_show.rb +++ b/lib/gitlab/slash_commands/presenters/issue_show.rb @@ -53,7 +53,7 @@ module Gitlab end def pretext - "Issue *#{@resource.to_reference}* from #{project.name_with_namespace}" + "Issue *#{@resource.to_reference}* from #{project.full_name}" end end end diff --git a/lib/gitlab/user_access.rb b/lib/gitlab/user_access.rb index 91b8bb2a83f..24393f96d96 100644 --- a/lib/gitlab/user_access.rb +++ b/lib/gitlab/user_access.rb @@ -63,13 +63,12 @@ module Gitlab request_cache def can_push_to_branch?(ref) return false unless can_access_git? + return false unless user.can?(:push_code, project) || project.branch_allows_maintainer_push?(user, ref) if protected?(ProtectedBranch, project, ref) - return true if project.user_can_push_to_empty_repo?(user) - - protected_branch_accessible_to?(ref, action: :push) + project.user_can_push_to_empty_repo?(user) || protected_branch_accessible_to?(ref, action: :push) else - user.can?(:push_code, project) + true end end diff --git a/lib/gitlab/utils.rb b/lib/gitlab/utils.rb index fa22f0e37b2..dc9391f32cf 100644 --- a/lib/gitlab/utils.rb +++ b/lib/gitlab/utils.rb @@ -67,5 +67,13 @@ module Gitlab nil end + + # Used in EE + # Accepts either an Array or a String and returns an array + def ensure_array_from_string(string_or_array) + return string_or_array if string_or_array.is_a?(Array) + + string_or_array.split(',').map(&:strip) + end end end diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index 823df67ea39..0b0d667d4fd 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -10,6 +10,7 @@ module Gitlab INTERNAL_API_CONTENT_TYPE = 'application/vnd.gitlab-workhorse+json'.freeze INTERNAL_API_REQUEST_HEADER = 'Gitlab-Workhorse-Api-Request'.freeze NOTIFICATION_CHANNEL = 'workhorse:notifications'.freeze + ALLOWED_GIT_HTTP_ACTIONS = %w[git_receive_pack git_upload_pack info_refs].freeze # Supposedly the effective key size for HMAC-SHA256 is 256 bits, i.e. 32 # bytes https://tools.ietf.org/html/rfc4868#section-2.6 @@ -17,6 +18,8 @@ module Gitlab class << self def git_http_ok(repository, is_wiki, user, action, show_all_refs: false) + raise "Unsupported action: #{action}" unless ALLOWED_GIT_HTTP_ACTIONS.include?(action.to_s) + project = repository.project repo_path = repository.path_to_repo params = { @@ -31,24 +34,7 @@ module Gitlab token: Gitlab::GitalyClient.token(project.repository_storage) } params[:Repository] = repository.gitaly_repository.to_h - - feature_enabled = case action.to_s - when 'git_receive_pack' - Gitlab::GitalyClient.feature_enabled?( - :post_receive_pack, - status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT - ) - when 'git_upload_pack' - true - when 'info_refs' - true - else - raise "Unsupported action: #{action}" - end - - if feature_enabled - params[:GitalyServer] = server - end + params[:GitalyServer] = server params end diff --git a/lib/rouge/plugins/common_mark.rb b/lib/rouge/plugins/common_mark.rb new file mode 100644 index 00000000000..8f9de061124 --- /dev/null +++ b/lib/rouge/plugins/common_mark.rb @@ -0,0 +1,20 @@ +# A rouge plugin for CommonMark markdown engine. +# Used to highlight code generated by CommonMark. + +module Rouge + module Plugins + module CommonMark + def code_block(code, language) + lexer = Lexer.find_fancy(language, code) || Lexers::PlainText + + formatter = rouge_formatter(lexer) + formatter.format(lexer.lex(code)) + end + + # override this method for custom formatting behavior + def rouge_formatter(lexer) + Formatters::HTMLLegacy.new(css_class: "highlight #{lexer.tag}") + end + end + end +end diff --git a/lib/system_check/helpers.rb b/lib/system_check/helpers.rb index 914ed794601..6227e461d24 100644 --- a/lib/system_check/helpers.rb +++ b/lib/system_check/helpers.rb @@ -50,7 +50,7 @@ module SystemCheck if should_sanitize? "#{project.namespace_id.to_s.color(:yellow)}/#{project.id.to_s.color(:yellow)} ... " else - "#{project.name_with_namespace.color(:yellow)} ... " + "#{project.full_name.color(:yellow)} ... " end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9ad7ad98c25..4b187312c06 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2018-03-05 13:02-0600\n" -"PO-Revision-Date: 2018-03-05 13:02-0600\n" +"POT-Creation-Date: 2018-03-06 17:36+0100\n" +"PO-Revision-Date: 2018-03-06 17:36+0100\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -105,6 +105,9 @@ msgstr "" msgid "A collection of graphs regarding Continuous Integration" msgstr "" +msgid "A new branch will be created in your fork and a new merge request will be started." +msgstr "" + msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}." msgstr "" @@ -210,12 +213,18 @@ msgstr "" msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings." msgstr "" +msgid "Allow edits from maintainers" +msgstr "" + msgid "Allows you to add and manage Kubernetes clusters." msgstr "" msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import." msgstr "" +msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import." +msgstr "" + msgid "An error occurred previewing the blob" msgstr "" @@ -294,9 +303,6 @@ msgstr "" msgid "Are you sure you want to delete this pipeline schedule?" msgstr "" -msgid "Are you sure you want to discard your changes?" -msgstr "" - msgid "Are you sure you want to reset registration token?" msgstr "" @@ -398,9 +404,6 @@ msgstr[1] "" msgid "Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}" msgstr "" -msgid "Branch has changed" -msgstr "" - msgid "Branch is already taken" msgstr "" @@ -416,6 +419,15 @@ msgstr "" msgid "Branches" msgstr "" +msgid "Branches|Active" +msgstr "" + +msgid "Branches|Active branches" +msgstr "" + +msgid "Branches|All" +msgstr "" + msgid "Branches|Cant find HEAD commit for this branch" msgstr "" @@ -461,12 +473,39 @@ msgstr "" msgid "Branches|Only a project master or owner can delete a protected branch" msgstr "" -msgid "Branches|Protected branches can be managed in %{project_settings_link}" +msgid "Branches|Overview" +msgstr "" + +msgid "Branches|Protected branches can be managed in %{project_settings_link}." +msgstr "" + +msgid "Branches|Show active branches" +msgstr "" + +msgid "Branches|Show all branches" +msgstr "" + +msgid "Branches|Show more active branches" +msgstr "" + +msgid "Branches|Show more stale branches" +msgstr "" + +msgid "Branches|Show overview of the branches" +msgstr "" + +msgid "Branches|Show stale branches" msgstr "" msgid "Branches|Sort by" msgstr "" +msgid "Branches|Stale" +msgstr "" + +msgid "Branches|Stale branches" +msgstr "" + msgid "Branches|The default branch cannot be deleted" msgstr "" @@ -518,9 +557,6 @@ msgstr "" msgid "Cancel" msgstr "" -msgid "Cancel edit" -msgstr "" - msgid "Cannot modify managed Kubernetes cluster" msgstr "" @@ -578,6 +614,9 @@ msgstr "" msgid "Choose which repositories you want to import." msgstr "" +msgid "Choose which repositories you want to import." +msgstr "" + msgid "CiStatusLabel|canceled" msgstr "" @@ -671,6 +710,9 @@ msgstr "" msgid "Clone repository" msgstr "" +msgid "Close" +msgstr "" + msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster" msgstr "" @@ -1092,6 +1134,9 @@ msgstr "" msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images." msgstr "" +msgid "Contribution" +msgstr "" + msgid "Contribution guide" msgstr "" @@ -1143,9 +1188,6 @@ msgstr "" msgid "Create empty bare repository" msgstr "" -msgid "Create file" -msgstr "" - msgid "Create lists from labels. Issues with that label appear in that list." msgstr "" @@ -1155,15 +1197,6 @@ msgstr "" msgid "Create merge request and branch" msgstr "" -msgid "Create new branch" -msgstr "" - -msgid "Create new directory" -msgstr "" - -msgid "Create new file" -msgstr "" - msgid "Create new label" msgstr "" @@ -1253,9 +1286,6 @@ msgstr "" msgid "Directory name" msgstr "" -msgid "Discard changes" -msgstr "" - msgid "Dismiss Cycle Analytics introduction box" msgstr "" @@ -1265,6 +1295,9 @@ msgstr "" msgid "Done" msgstr "" +msgid "Done" +msgstr "" + msgid "Download" msgstr "" @@ -1418,6 +1451,9 @@ msgstr "" msgid "Failed" msgstr "" +msgid "Failed" +msgstr "" + msgid "Failed Jobs" msgstr "" @@ -1442,9 +1478,6 @@ msgstr "" msgid "Fields on this page are now uneditable, you can configure" msgstr "" -msgid "File name" -msgstr "" - msgid "Files" msgstr "" @@ -1460,6 +1493,9 @@ msgstr "" msgid "Find file" msgstr "" +msgid "Finished" +msgstr "" + msgid "FirstPushedBy|First" msgstr "" @@ -1486,6 +1522,9 @@ msgstr "" msgid "From %{provider_title}" msgstr "" +msgid "From %{provider_title}" +msgstr "" + msgid "From issue creation until deploy to production" msgstr "" @@ -1522,6 +1561,9 @@ msgstr "" msgid "Gitaly Servers" msgstr "" +msgid "Go back" +msgstr "" + msgid "Go to your fork" msgstr "" @@ -1641,6 +1683,12 @@ msgstr "" msgid "Import all repositories" msgstr "" +msgid "Import" +msgstr "" + +msgid "Import all repositories" +msgstr "" + msgid "Import in progress" msgstr "" @@ -1737,6 +1785,15 @@ msgstr "" msgid "LFSStatus|Enabled" msgstr "" +msgid "Label" +msgstr "" + +msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more" +msgstr "" + +msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more" +msgstr "" + msgid "Labels" msgstr "" @@ -1984,6 +2041,12 @@ msgstr "" msgid "Not available" msgstr "" +msgid "Not available for private projects" +msgstr "" + +msgid "Not available for protected branches" +msgstr "" + msgid "Not confidential" msgstr "" @@ -2200,9 +2263,30 @@ msgstr "" msgid "Pipelines|Build with confidence" msgstr "" +msgid "Pipelines|CI Lint" +msgstr "" + +msgid "Pipelines|Clear Runner Caches" +msgstr "" + msgid "Pipelines|Get started with Pipelines" msgstr "" +msgid "Pipelines|Loading Pipelines" +msgstr "" + +msgid "Pipelines|Run Pipeline" +msgstr "" + +msgid "Pipelines|There are currently no %{scope} pipelines." +msgstr "" + +msgid "Pipelines|There are currently no pipelines." +msgstr "" + +msgid "Pipelines|This project is not currently set up to run pipelines." +msgstr "" + msgid "Pipeline|Retry pipeline" msgstr "" @@ -2455,6 +2539,9 @@ msgstr "" msgid "Public - The project can be accessed without any authentication." msgstr "" +msgid "Push access to this project is necessary in order to enable this option" +msgstr "" + msgid "Push events" msgstr "" @@ -2541,6 +2628,9 @@ msgstr "" msgid "Revert this merge request" msgstr "" +msgid "Running" +msgstr "" + msgid "SSH Keys" msgstr "" @@ -2565,6 +2655,9 @@ msgstr "" msgid "Scheduling Pipelines" msgstr "" +msgid "Search" +msgstr "" + msgid "Search branches and tags" msgstr "" @@ -2813,6 +2906,12 @@ msgstr "" msgid "Status" msgstr "" +msgid "Started" +msgstr "" + +msgid "Status" +msgstr "" + msgid "Stopped" msgstr "" @@ -3239,6 +3338,15 @@ msgstr "" msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:" msgstr "" +msgid "To GitLab" +msgstr "" + +msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import." +msgstr "" + +msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:" +msgstr "" + msgid "To import an SVN repository, check out %{svn_link}." msgstr "" @@ -3347,9 +3455,6 @@ msgstr "" msgid "We want to be sure it is you, please confirm you are not a robot." msgstr "" -msgid "Web IDE" -msgstr "" - msgid "Wiki" msgstr "" @@ -3464,13 +3569,13 @@ msgstr "" msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?" msgstr "" -msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?" +msgid "You are going to remove %{project_full_name}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?" msgstr "" msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?" msgstr "" -msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?" +msgid "You are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?" msgstr "" msgid "You are on a read-only GitLab instance." @@ -3536,6 +3641,9 @@ msgstr "" msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure" msgstr "" +msgid "Your changes can be committed to %{branch_name} because a merge request is open." +msgstr "" + msgid "Your comment will not be visible to the public." msgstr "" @@ -3569,6 +3677,9 @@ msgstr "" msgid "connecting" msgstr "" +msgid "connecting" +msgstr "" + msgid "day" msgid_plural "days" msgstr[0] "" @@ -3588,6 +3699,9 @@ msgstr[1] "" msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch" msgstr "" +msgid "mrWidget|Allows edits from maintainers" +msgstr "" + msgid "mrWidget|Cancel automatic merge" msgstr "" diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb index 00cf464ec5b..306094f7ffb 100644 --- a/spec/controllers/projects/milestones_controller_spec.rb +++ b/spec/controllers/projects/milestones_controller_spec.rb @@ -98,10 +98,8 @@ describe Projects::MilestonesController do it 'shows group milestone' do post :promote, namespace_id: project.namespace.id, project_id: project.id, id: milestone.iid - group_milestone = assigns(:milestone) - - expect(response).to redirect_to(group_milestone_path(project.group, group_milestone.iid)) - expect(flash[:notice]).to eq('Milestone has been promoted to group milestone.') + expect(flash[:notice]).to eq("#{milestone.title} promoted to group milestone") + expect(response).to redirect_to(project_milestones_path(project)) end end diff --git a/spec/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb index 847ac6f2be0..e4dc61b3a68 100644 --- a/spec/controllers/projects/services_controller_spec.rb +++ b/spec/controllers/projects/services_controller_spec.rb @@ -23,6 +23,18 @@ describe Projects::ServicesController do end end + context 'when validations fail' do + let(:service_params) { { active: 'true', token: '' } } + + it 'returns error messages in JSON response' do + put :test, namespace_id: project.namespace, project_id: project, id: :hipchat, service: service_params + + expect(json_response['message']).to eq "Validations failed." + expect(json_response['service_response']).to eq "Token can't be blank" + expect(response).to have_gitlab_http_status(200) + end + end + context 'success' do context 'with empty project' do let(:project) { create(:project) } diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb index 3f4e408b3a6..857333f222d 100644 --- a/spec/factories/notes.rb +++ b/spec/factories/notes.rb @@ -16,6 +16,8 @@ FactoryBot.define do factory :note_on_personal_snippet, traits: [:on_personal_snippet] factory :system_note, traits: [:system] + factory :discussion_note, class: DiscussionNote + factory :discussion_note_on_merge_request, traits: [:on_merge_request], class: DiscussionNote do association :project, :repository @@ -31,6 +33,8 @@ FactoryBot.define do factory :discussion_note_on_personal_snippet, traits: [:on_personal_snippet], class: DiscussionNote + factory :discussion_note_on_snippet, traits: [:on_snippet], class: DiscussionNote + factory :legacy_diff_note_on_commit, traits: [:on_commit, :legacy_diff_note], class: LegacyDiffNote factory :legacy_diff_note_on_merge_request, traits: [:on_merge_request, :legacy_diff_note], class: LegacyDiffNote do @@ -96,6 +100,10 @@ FactoryBot.define do noteable { create(:issue, project: project) } end + trait :on_snippet do + noteable { create(:snippet, project: project) } + end + trait :on_merge_request do noteable { create(:merge_request, source_project: project) } end diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb index f266f2ecc54..25ed3bdc88e 100644 --- a/spec/features/admin/admin_hooks_spec.rb +++ b/spec/features/admin/admin_hooks_spec.rb @@ -24,6 +24,16 @@ describe 'Admin::Hooks' do visit admin_hooks_path expect(page).to have_content(system_hook.url) end + + it 'renders plugins list as well' do + allow(Gitlab::Plugin).to receive(:files).and_return(['foo.rb', 'bar.clj']) + + visit admin_hooks_path + + expect(page).to have_content('Plugins') + expect(page).to have_content('foo.rb') + expect(page).to have_content('bar.clj') + end end describe 'New Hook' do diff --git a/spec/features/merge_request/maintainer_edits_fork_spec.rb b/spec/features/merge_request/maintainer_edits_fork_spec.rb new file mode 100644 index 00000000000..a3323da1b1f --- /dev/null +++ b/spec/features/merge_request/maintainer_edits_fork_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe 'a maintainer edits files on a source-branch of an MR from a fork', :js do + include ProjectForksHelper + let(:user) { create(:user, username: 'the-maintainer') } + let(:target_project) { create(:project, :public, :repository) } + let(:author) { create(:user, username: 'mr-authoring-machine') } + let(:source_project) { fork_project(target_project, author, repository: true) } + + let(:merge_request) do + create(:merge_request, + source_project: source_project, + target_project: target_project, + source_branch: 'fix', + target_branch: 'master', + author: author, + allow_maintainer_to_push: true) + end + + before do + target_project.add_master(user) + sign_in(user) + + visit project_merge_request_path(target_project, merge_request) + click_link 'Changes' + wait_for_requests + first('.js-file-title').click_link 'Edit' + wait_for_requests + end + + it 'mentions commits will go to the source branch' do + expect(page).to have_content('Your changes can be committed to fix because a merge request is open.') + end + + it 'allows committing to the source branch' do + find('.ace_text-input', visible: false).send_keys('Updated the readme') + + click_button 'Commit changes' + wait_for_requests + + expect(page).to have_content('Your changes have been successfully committed') + expect(page).to have_content('Updated the readme') + end +end diff --git a/spec/features/merge_request/user_allows_a_maintainer_to_push_spec.rb b/spec/features/merge_request/user_allows_a_maintainer_to_push_spec.rb new file mode 100644 index 00000000000..eb41d7de8ed --- /dev/null +++ b/spec/features/merge_request/user_allows_a_maintainer_to_push_spec.rb @@ -0,0 +1,85 @@ +require 'spec_helper' + +describe 'create a merge request that allows maintainers to push', :js do + include ProjectForksHelper + let(:user) { create(:user) } + let(:target_project) { create(:project, :public, :repository) } + let(:source_project) { fork_project(target_project, user, repository: true, namespace: user.namespace) } + + def visit_new_merge_request + visit project_new_merge_request_path( + source_project, + merge_request: { + source_project_id: source_project.id, + target_project_id: target_project.id, + source_branch: 'fix', + target_branch: 'master' + }) + end + + before do + sign_in(user) + end + + it 'allows setting maintainer push possible' do + visit_new_merge_request + + check 'Allow edits from maintainers' + + click_button 'Submit merge request' + + wait_for_requests + + expect(page).to have_content('Allows edits from maintainers') + end + + it 'shows a message when one of the projects is private' do + source_project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + + visit_new_merge_request + + expect(page).to have_content('Not available for private projects') + end + + it 'shows a message when the source branch is protected' do + create(:protected_branch, project: source_project, name: 'fix') + + visit_new_merge_request + + expect(page).to have_content('Not available for protected branches') + end + + context 'when the merge request is being created within the same project' do + let(:source_project) { target_project } + + it 'hides the checkbox if the merge request is being created within the same project' do + target_project.add_developer(user) + + visit_new_merge_request + + expect(page).not_to have_content('Allows edits from maintainers') + end + end + + context 'when a maintainer tries to edit the option' do + let(:maintainer) { create(:user) } + let(:merge_request) do + create(:merge_request, + source_project: source_project, + target_project: target_project, + source_branch: 'fixes') + end + + before do + target_project.add_master(maintainer) + + sign_in(maintainer) + end + + it 'it hides the option from maintainers' do + visit edit_project_merge_request_path(target_project, merge_request) + + expect(page).not_to have_content('Allows edits from maintainers') + end + end +end diff --git a/spec/features/projects/services/disable_triggers_spec.rb b/spec/features/projects/services/disable_triggers_spec.rb new file mode 100644 index 00000000000..1a13fe03a67 --- /dev/null +++ b/spec/features/projects/services/disable_triggers_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe 'Disable individual triggers' do + let(:project) { create(:project) } + let(:user) { project.owner } + let(:checkbox_selector) { 'input[type=checkbox][id$=_events]' } + + before do + sign_in(user) + + visit(project_settings_integrations_path(project)) + + click_link(service_name) + end + + context 'service has multiple supported events' do + let(:service_name) { 'HipChat' } + + it 'shows trigger checkboxes' do + event_count = HipchatService.supported_events.count + + expect(page).to have_content "Trigger" + expect(page).to have_css(checkbox_selector, count: event_count) + end + end + + context 'services only has one supported event' do + let(:service_name) { 'Asana' } + + it "doesn't show unnecessary Trigger checkboxes" do + expect(page).not_to have_content "Trigger" + expect(page).not_to have_css(checkbox_selector) + end + end +end diff --git a/spec/features/projects/user_creates_files_spec.rb b/spec/features/projects/user_creates_files_spec.rb index 7a935dd2477..8993533676b 100644 --- a/spec/features/projects/user_creates_files_spec.rb +++ b/spec/features/projects/user_creates_files_spec.rb @@ -133,13 +133,20 @@ describe 'User creates files' do before do project2.add_reporter(user) visit(project2_tree_path_root_ref) - end - it 'creates and commit new file in forked project', :js do find('.add-to-tree').click click_link('New file') + end + + it 'shows a message saying the file will be committed in a fork' do + message = "A new branch will be created in your fork and a new merge request will be started." + expect(page).to have_content(message) + end + + it 'creates and commit new file in forked project', :js do expect(page).to have_selector('.file-editor') + expect(page).to have_content find('#editor') execute_script("ace.edit('editor').setValue('*.rbca')") diff --git a/spec/fixtures/api/schemas/entities/merge_request_basic.json b/spec/fixtures/api/schemas/entities/merge_request_basic.json index f1199468d53..46031961cca 100644 --- a/spec/fixtures/api/schemas/entities/merge_request_basic.json +++ b/spec/fixtures/api/schemas/entities/merge_request_basic.json @@ -12,7 +12,8 @@ "rebase_in_progress": { "type": "boolean" }, "assignee_id": { "type": ["integer", "null"] }, "subscribed": { "type": ["boolean", "null"] }, - "participants": { "type": "array" } + "participants": { "type": "array" }, + "allow_maintainer_to_push": { "type": "boolean"} }, "additionalProperties": false } diff --git a/spec/fixtures/api/schemas/entities/merge_request_widget.json b/spec/fixtures/api/schemas/entities/merge_request_widget.json index cfbeec58a45..a622bf88b13 100644 --- a/spec/fixtures/api/schemas/entities/merge_request_widget.json +++ b/spec/fixtures/api/schemas/entities/merge_request_widget.json @@ -30,6 +30,7 @@ "source_project_id": { "type": "integer" }, "target_branch": { "type": "string" }, "target_project_id": { "type": "integer" }, + "allow_maintainer_to_push": { "type": "boolean"}, "metrics": { "oneOf": [ { "type": "null" }, diff --git a/spec/fixtures/api/schemas/public_api/v4/merge_requests.json b/spec/fixtures/api/schemas/public_api/v4/merge_requests.json index e86176e5316..0dc2eabec5d 100644 --- a/spec/fixtures/api/schemas/public_api/v4/merge_requests.json +++ b/spec/fixtures/api/schemas/public_api/v4/merge_requests.json @@ -80,7 +80,8 @@ "total_time_spent": { "type": "integer" }, "human_time_estimate": { "type": ["string", "null"] }, "human_total_time_spent": { "type": ["string", "null"] } - } + }, + "allow_maintainer_to_push": { "type": ["boolean", "null"] } }, "required": [ "id", "iid", "project_id", "title", "description", diff --git a/spec/fixtures/api/schemas/public_api/v4/notes.json b/spec/fixtures/api/schemas/public_api/v4/notes.json index 6525f7c2c80..4c4ca3b582f 100644 --- a/spec/fixtures/api/schemas/public_api/v4/notes.json +++ b/spec/fixtures/api/schemas/public_api/v4/notes.json @@ -4,6 +4,7 @@ "type": "object", "properties" : { "id": { "type": "integer" }, + "type": { "type": ["string", "null"] }, "body": { "type": "string" }, "attachment": { "type": ["string", "null"] }, "author": { diff --git a/spec/helpers/tree_helper_spec.rb b/spec/helpers/tree_helper_spec.rb index d3b1be599dd..ccac6e29447 100644 --- a/spec/helpers/tree_helper_spec.rb +++ b/spec/helpers/tree_helper_spec.rb @@ -62,4 +62,13 @@ describe TreeHelper do end end end + + describe '#commit_in_single_accessible_branch' do + it 'escapes HTML from the branch name' do + helper.instance_variable_set(:@branch_name, "<script>alert('escape me!');</script>") + escaped_branch_name = '<script>alert('escape me!');</script>' + + expect(helper.commit_in_single_accessible_branch).to include(escaped_branch_name) + end + end end diff --git a/spec/javascripts/environments/environments_app_spec.js b/spec/javascripts/environments/environments_app_spec.js index 5bb37304372..e4c3bf2bef1 100644 --- a/spec/javascripts/environments/environments_app_spec.js +++ b/spec/javascripts/environments/environments_app_spec.js @@ -61,6 +61,7 @@ describe('Environment', () => { }); describe('with paginated environments', () => { + let backupInterceptors; const environmentsResponseInterceptor = (request, next) => { next((response) => { response.headers.set('X-nExt-pAge', '2'); @@ -84,16 +85,16 @@ describe('Environment', () => { }; beforeEach(() => { - Vue.http.interceptors.push(environmentsResponseInterceptor); - Vue.http.interceptors.push(headersInterceptor); + backupInterceptors = Vue.http.interceptors; + Vue.http.interceptors = [ + environmentsResponseInterceptor, + headersInterceptor, + ]; component = mountComponent(EnvironmentsComponent, mockData); }); afterEach(() => { - Vue.http.interceptors = _.without( - Vue.http.interceptors, environmentsResponseInterceptor, - ); - Vue.http.interceptors = _.without(Vue.http.interceptors, headersInterceptor); + Vue.http.interceptors = backupInterceptors; }); it('should render a table with environments', (done) => { diff --git a/spec/javascripts/pages/labels/components/promote_label_modal_spec.js b/spec/javascripts/pages/labels/components/promote_label_modal_spec.js new file mode 100644 index 00000000000..ba2e07f02f7 --- /dev/null +++ b/spec/javascripts/pages/labels/components/promote_label_modal_spec.js @@ -0,0 +1,88 @@ +import Vue from 'vue'; +import promoteLabelModal from '~/pages/projects/labels/components/promote_label_modal.vue'; +import eventHub from '~/pages/projects/labels/event_hub'; +import axios from '~/lib/utils/axios_utils'; +import mountComponent from '../../../helpers/vue_mount_component_helper'; + +describe('Promote label modal', () => { + let vm; + const Component = Vue.extend(promoteLabelModal); + const labelMockData = { + labelTitle: 'Documentation', + labelColor: '#5cb85c', + labelTextColor: '#ffffff', + url: `${gl.TEST_HOST}/dummy/promote/labels`, + }; + + describe('Modal title and description', () => { + beforeEach(() => { + vm = mountComponent(Component, labelMockData); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('contains the proper description', () => { + expect(vm.text).toContain('Promoting this label will make it available for all projects inside the group'); + }); + + it('contains a label span with the color', () => { + const labelFromTitle = vm.$el.querySelector('.modal-header .label.color-label'); + + expect(labelFromTitle.style.backgroundColor).not.toBe(null); + expect(labelFromTitle.textContent).toContain(vm.labelTitle); + }); + }); + + describe('When requesting a label promotion', () => { + beforeEach(() => { + vm = mountComponent(Component, { + ...labelMockData, + }); + spyOn(eventHub, '$emit'); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('redirects when a label is promoted', (done) => { + const responseURL = `${gl.TEST_HOST}/dummy/endpoint`; + spyOn(axios, 'post').and.callFake((url) => { + expect(url).toBe(labelMockData.url); + expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestStarted', labelMockData.url); + return Promise.resolve({ + request: { + responseURL, + }, + }); + }); + + vm.onSubmit() + .then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', { labelUrl: labelMockData.url, successful: true }); + }) + .then(done) + .catch(done.fail); + }); + + it('displays an error if promoting a label failed', (done) => { + const dummyError = new Error('promoting label failed'); + dummyError.response = { status: 500 }; + spyOn(axios, 'post').and.callFake((url) => { + expect(url).toBe(labelMockData.url); + expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestStarted', labelMockData.url); + return Promise.reject(dummyError); + }); + + vm.onSubmit() + .catch((error) => { + expect(error).toBe(dummyError); + expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', { labelUrl: labelMockData.url, successful: false }); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js b/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js new file mode 100644 index 00000000000..bf044fe8fb5 --- /dev/null +++ b/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js @@ -0,0 +1,83 @@ +import Vue from 'vue'; +import promoteMilestoneModal from '~/pages/milestones/shared/components/promote_milestone_modal.vue'; +import eventHub from '~/pages/milestones/shared/event_hub'; +import axios from '~/lib/utils/axios_utils'; +import mountComponent from '../../../../helpers/vue_mount_component_helper'; + +describe('Promote milestone modal', () => { + let vm; + const Component = Vue.extend(promoteMilestoneModal); + const milestoneMockData = { + milestoneTitle: 'v1.0', + url: `${gl.TEST_HOST}/dummy/promote/milestones`, + }; + + describe('Modal title and description', () => { + beforeEach(() => { + vm = mountComponent(Component, milestoneMockData); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('contains the proper description', () => { + expect(vm.text).toContain('Promoting this milestone will make it available for all projects inside the group.'); + }); + + it('contains the correct title', () => { + expect(vm.title).toEqual('Promote v1.0 to group milestone?'); + }); + }); + + describe('When requesting a milestone promotion', () => { + beforeEach(() => { + vm = mountComponent(Component, { + ...milestoneMockData, + }); + spyOn(eventHub, '$emit'); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('redirects when a milestone is promoted', (done) => { + const responseURL = `${gl.TEST_HOST}/dummy/endpoint`; + spyOn(axios, 'post').and.callFake((url) => { + expect(url).toBe(milestoneMockData.url); + expect(eventHub.$emit).toHaveBeenCalledWith('promoteMilestoneModal.requestStarted', milestoneMockData.url); + return Promise.resolve({ + request: { + responseURL, + }, + }); + }); + + vm.onSubmit() + .then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('promoteMilestoneModal.requestFinished', { milestoneUrl: milestoneMockData.url, successful: true }); + }) + .then(done) + .catch(done.fail); + }); + + it('displays an error if promoting a milestone failed', (done) => { + const dummyError = new Error('promoting milestone failed'); + dummyError.response = { status: 500 }; + spyOn(axios, 'post').and.callFake((url) => { + expect(url).toBe(milestoneMockData.url); + expect(eventHub.$emit).toHaveBeenCalledWith('promoteMilestoneModal.requestStarted', milestoneMockData.url); + return Promise.reject(dummyError); + }); + + vm.onSubmit() + .catch((error) => { + expect(error).toBe(dummyError); + expect(eventHub.$emit).toHaveBeenCalledWith('promoteMilestoneModal.requestFinished', { milestoneUrl: milestoneMockData.url, successful: false }); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js b/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js index f6c0f51cf62..955ec6a531c 100644 --- a/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js +++ b/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js @@ -85,7 +85,7 @@ describe('PrometheusMetrics', () => { expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy(); expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeFalsy(); - expect(prometheusMetrics.$monitoredMetricsCount.text()).toEqual('12'); + expect(prometheusMetrics.$monitoredMetricsCount.text()).toEqual('3 exporters with 12 metrics were found'); expect($metricsListLi.length).toEqual(metrics.length); expect($metricsListLi.first().find('.badge').text()).toEqual(`${metrics[0].active_metrics}`); }); diff --git a/spec/javascripts/sidebar/sidebar_assignees_spec.js b/spec/javascripts/sidebar/sidebar_assignees_spec.js index 2fbb7268e0b..ebaaa6e806b 100644 --- a/spec/javascripts/sidebar/sidebar_assignees_spec.js +++ b/spec/javascripts/sidebar/sidebar_assignees_spec.js @@ -1,6 +1,6 @@ import _ from 'underscore'; import Vue from 'vue'; -import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees'; +import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees.vue'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarStore from '~/sidebar/stores/sidebar_store'; diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_maintainer_edit_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_maintainer_edit_spec.js new file mode 100644 index 00000000000..cee22d5342a --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_maintainer_edit_spec.js @@ -0,0 +1,40 @@ +import Vue from 'vue'; +import maintainerEditComponent from '~/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('RWidgetMaintainerEdit', () => { + let Component; + let vm; + + beforeEach(() => { + Component = Vue.extend(maintainerEditComponent); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('when a maintainer is allowed to edit', () => { + beforeEach(() => { + vm = mountComponent(Component, { + maintainerEditAllowed: true, + }); + }); + + it('it renders the message', () => { + expect(vm.$el.textContent.trim()).toEqual('Allows edits from maintainers'); + }); + }); + + describe('when a maintainer is not allowed to edit', () => { + beforeEach(() => { + vm = mountComponent(Component, { + maintainerEditAllowed: false, + }); + }); + + it('hides the message', () => { + expect(vm.$el.textContent.trim()).toEqual(''); + }); + }); +}); 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 18ba34b55a5..ebe151ac3b1 100644 --- a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js @@ -349,6 +349,7 @@ describe('mrWidgetOptions', () => { expect(comps['mr-widget-pipeline-blocked']).toBeDefined(); expect(comps['mr-widget-pipeline-failed']).toBeDefined(); expect(comps['mr-widget-merge-when-pipeline-succeeds']).toBeDefined(); + expect(comps['mr-widget-maintainer-edit']).toBeDefined(); }); }); diff --git a/spec/javascripts/vue_shared/components/clipboard_button_spec.js b/spec/javascripts/vue_shared/components/clipboard_button_spec.js index d0fc10d69ea..f598b1afa74 100644 --- a/spec/javascripts/vue_shared/components/clipboard_button_spec.js +++ b/spec/javascripts/vue_shared/components/clipboard_button_spec.js @@ -10,6 +10,7 @@ describe('clipboard button', () => { vm = mountComponent(Component, { text: 'copy me', title: 'Copy this value into Clipboard!', + cssClass: 'btn-danger', }); }); @@ -28,4 +29,8 @@ describe('clipboard button', () => { expect(vm.$el.getAttribute('data-placement')).toEqual('top'); expect(vm.$el.getAttribute('data-container')).toEqual(null); }); + + it('should render provided classname', () => { + expect(vm.$el.classList).toContain('btn-danger'); + }); }); diff --git a/spec/lib/gitlab/checks/change_access_spec.rb b/spec/lib/gitlab/checks/change_access_spec.rb index b49ddbfc780..48e9902027c 100644 --- a/spec/lib/gitlab/checks/change_access_spec.rb +++ b/spec/lib/gitlab/checks/change_access_spec.rb @@ -30,9 +30,10 @@ describe Gitlab::Checks::ChangeAccess do end end - context 'when the user is not allowed to push code' do + context 'when the user is not allowed to push to the repo' do it 'raises an error' do expect(user_access).to receive(:can_do_action?).with(:push_code).and_return(false) + expect(user_access).to receive(:can_push_to_branch?).with('master').and_return(false) expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to this project.') end diff --git a/spec/lib/gitlab/ci/charts_spec.rb b/spec/lib/gitlab/ci/charts_spec.rb index f8188675013..1668d3bbaac 100644 --- a/spec/lib/gitlab/ci/charts_spec.rb +++ b/spec/lib/gitlab/ci/charts_spec.rb @@ -1,6 +1,51 @@ require 'spec_helper' describe Gitlab::Ci::Charts do + context "yearchart" do + let(:project) { create(:project) } + let(:chart) { Gitlab::Ci::Charts::YearChart.new(project) } + + subject { chart.to } + + it 'goes until the end of the current month (including the whole last day of the month)' do + is_expected.to eq(Date.today.end_of_month.end_of_day) + end + + it 'starts at the beginning of the current year' do + expect(chart.from).to eq(chart.to.years_ago(1).beginning_of_month.beginning_of_day) + end + end + + context "monthchart" do + let(:project) { create(:project) } + let(:chart) { Gitlab::Ci::Charts::MonthChart.new(project) } + + subject { chart.to } + + it 'includes the whole current day' do + is_expected.to eq(Date.today.end_of_day) + end + + it 'starts one month ago' do + expect(chart.from).to eq(1.month.ago.beginning_of_day) + end + end + + context "weekchart" do + let(:project) { create(:project) } + let(:chart) { Gitlab::Ci::Charts::WeekChart.new(project) } + + subject { chart.to } + + it 'includes the whole current day' do + is_expected.to eq(Date.today.end_of_day) + end + + it 'starts one week ago' do + expect(chart.from).to eq(1.week.ago.beginning_of_day) + end + end + context "pipeline_times" do let(:project) { create(:project) } let(:chart) { Gitlab::Ci::Charts::PipelineTime.new(project) } diff --git a/spec/lib/gitlab/contributions_calendar_spec.rb b/spec/lib/gitlab/contributions_calendar_spec.rb index 167876ca158..2c63f3b0455 100644 --- a/spec/lib/gitlab/contributions_calendar_spec.rb +++ b/spec/lib/gitlab/contributions_calendar_spec.rb @@ -77,6 +77,13 @@ describe Gitlab::ContributionsCalendar do expect(calendar(contributor).activity_dates[today]).to eq(1) end + it "counts the discussions on merge requests and issues" do + create_event(public_project, today, 0, Event::COMMENTED, :discussion_note_on_merge_request) + create_event(public_project, today, 2, Event::COMMENTED, :discussion_note_on_issue) + + expect(calendar(contributor).activity_dates[today]).to eq(2) + end + context "when events fall under different dates depending on the time zone" do before do create_event(public_project, today, 1) diff --git a/spec/lib/gitlab/data_builder/pipeline_spec.rb b/spec/lib/gitlab/data_builder/pipeline_spec.rb index f13041e498c..9ca960502c8 100644 --- a/spec/lib/gitlab/data_builder/pipeline_spec.rb +++ b/spec/lib/gitlab/data_builder/pipeline_spec.rb @@ -26,6 +26,7 @@ describe Gitlab::DataBuilder::Pipeline do it { expect(attributes[:tag]).to eq(pipeline.tag) } it { expect(attributes[:id]).to eq(pipeline.id) } it { expect(attributes[:status]).to eq(pipeline.status) } + it { expect(attributes[:detailed_status]).to eq('passed') } it { expect(build_data).to be_a(Hash) } it { expect(build_data[:id]).to eq(build.id) } diff --git a/spec/lib/gitlab/git/gitlab_projects_spec.rb b/spec/lib/gitlab/git/gitlab_projects_spec.rb index f4b964e1ee9..45bcd730332 100644 --- a/spec/lib/gitlab/git/gitlab_projects_spec.rb +++ b/spec/lib/gitlab/git/gitlab_projects_spec.rb @@ -61,10 +61,11 @@ describe Gitlab::Git::GitlabProjects do let(:remote_name) { 'remote-name' } let(:branch_name) { 'master' } let(:force) { false } + let(:prune) { true } let(:tags) { true } - let(:args) { { force: force, tags: tags }.merge(extra_args) } + let(:args) { { force: force, tags: tags, prune: prune }.merge(extra_args) } let(:extra_args) { {} } - let(:cmd) { %W(git fetch #{remote_name} --prune --quiet --tags) } + let(:cmd) { %W(git fetch #{remote_name} --quiet --prune --tags) } subject { gl_projects.fetch_remote(remote_name, 600, args) } @@ -97,7 +98,7 @@ describe Gitlab::Git::GitlabProjects do context 'with --force' do let(:force) { true } - let(:cmd) { %W(git fetch #{remote_name} --prune --quiet --force --tags) } + let(:cmd) { %W(git fetch #{remote_name} --quiet --prune --force --tags) } it 'executes the command with forced option' do stub_spawn(cmd, 600, tmp_repo_path, {}, success: true) @@ -108,7 +109,18 @@ describe Gitlab::Git::GitlabProjects do context 'with --no-tags' do let(:tags) { false } - let(:cmd) { %W(git fetch #{remote_name} --prune --quiet --no-tags) } + let(:cmd) { %W(git fetch #{remote_name} --quiet --prune --no-tags) } + + it 'executes the command' do + stub_spawn(cmd, 600, tmp_repo_path, {}, success: true) + + is_expected.to be_truthy + end + end + + context 'with no prune' do + let(:prune) { false } + let(:cmd) { %W(git fetch #{remote_name} --quiet --tags) } it 'executes the command' do stub_spawn(cmd, 600, tmp_repo_path, {}, success: true) diff --git a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb index c50e73cecfc..1c41dbcb9ef 100644 --- a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb @@ -85,6 +85,20 @@ describe Gitlab::GitalyClient::RepositoryService do end end + describe '#fetch_remote' do + let(:ssh_auth) { double(:ssh_auth, ssh_import?: true, ssh_key_auth?: false, ssh_known_hosts: nil) } + let(:import_url) { 'ssh://example.com' } + + it 'sends a fetch_remote_request message' do + expect_any_instance_of(Gitaly::RepositoryService::Stub) + .to receive(:fetch_remote) + .with(gitaly_request_with_params(no_prune: false), kind_of(Hash)) + .and_return(double(value: true)) + + client.fetch_remote(import_url, ssh_auth: ssh_auth, forced: false, no_tags: false, timeout: 60) + end + end + describe '#rebase_in_progress?' do let(:rebase_id) { 1 } diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index b20cc34dd5c..bece82e531a 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -278,6 +278,7 @@ project: - custom_attributes - lfs_file_locks - project_badges +- source_of_merge_requests award_emoji: - awardable - user diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index ddcbb7a0033..0b938892da5 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -168,6 +168,7 @@ MergeRequest: - last_edited_by_id - head_pipeline_id - discussion_locked +- allow_maintainer_to_push MergeRequestDiff: - id - state diff --git a/spec/lib/gitlab/shell_spec.rb b/spec/lib/gitlab/shell_spec.rb index 4506cbc3982..56b45d8da3c 100644 --- a/spec/lib/gitlab/shell_spec.rb +++ b/spec/lib/gitlab/shell_spec.rb @@ -508,8 +508,8 @@ describe Gitlab::Shell do end shared_examples 'fetch_remote' do |gitaly_on| - def fetch_remote(ssh_auth = nil) - gitlab_shell.fetch_remote(repository.raw_repository, 'remote-name', ssh_auth: ssh_auth) + def fetch_remote(ssh_auth = nil, prune = true) + gitlab_shell.fetch_remote(repository.raw_repository, 'remote-name', ssh_auth: ssh_auth, prune: prune) end def expect_gitlab_projects(fail = false, options = {}) @@ -555,27 +555,33 @@ describe Gitlab::Shell do end it 'returns true when the command succeeds' do - expect_call(false, force: false, tags: true) + expect_call(false, force: false, tags: true, prune: true) expect(fetch_remote).to be_truthy end + it 'returns true when the command succeeds' do + expect_call(false, force: false, tags: true, prune: false) + + expect(fetch_remote(nil, false)).to be_truthy + end + it 'raises an exception when the command fails' do - expect_call(true, force: false, tags: true) + expect_call(true, force: false, tags: true, prune: true) expect { fetch_remote }.to raise_error(Gitlab::Shell::Error) end it 'allows forced and no_tags to be changed' do - expect_call(false, force: true, tags: false) + expect_call(false, force: true, tags: false, prune: true) - result = gitlab_shell.fetch_remote(repository.raw_repository, 'remote-name', forced: true, no_tags: true) + result = gitlab_shell.fetch_remote(repository.raw_repository, 'remote-name', forced: true, no_tags: true, prune: true) expect(result).to be_truthy end context 'SSH auth' do it 'passes the SSH key if specified' do - expect_call(false, force: false, tags: true, ssh_key: 'foo') + expect_call(false, force: false, tags: true, prune: true, ssh_key: 'foo') ssh_auth = build_ssh_auth(ssh_key_auth?: true, ssh_private_key: 'foo') @@ -583,7 +589,7 @@ describe Gitlab::Shell do end it 'does not pass an empty SSH key' do - expect_call(false, force: false, tags: true) + expect_call(false, force: false, tags: true, prune: true) ssh_auth = build_ssh_auth(ssh_key_auth: true, ssh_private_key: '') @@ -591,7 +597,7 @@ describe Gitlab::Shell do end it 'does not pass the key unless SSH key auth is to be used' do - expect_call(false, force: false, tags: true) + expect_call(false, force: false, tags: true, prune: true) ssh_auth = build_ssh_auth(ssh_key_auth: false, ssh_private_key: 'foo') @@ -599,7 +605,7 @@ describe Gitlab::Shell do end it 'passes the known_hosts data if specified' do - expect_call(false, force: false, tags: true, known_hosts: 'foo') + expect_call(false, force: false, tags: true, prune: true, known_hosts: 'foo') ssh_auth = build_ssh_auth(ssh_known_hosts: 'foo') @@ -607,7 +613,7 @@ describe Gitlab::Shell do end it 'does not pass empty known_hosts data' do - expect_call(false, force: false, tags: true) + expect_call(false, force: false, tags: true, prune: true) ssh_auth = build_ssh_auth(ssh_known_hosts: '') @@ -615,7 +621,7 @@ describe Gitlab::Shell do end it 'does not pass known_hosts data unless SSH is to be used' do - expect_call(false, force: false, tags: true) + expect_call(false, force: false, tags: true, prune: true) ssh_auth = build_ssh_auth(ssh_import?: false, ssh_known_hosts: 'foo') @@ -642,7 +648,7 @@ describe Gitlab::Shell do it 'passes the correct params to the gitaly service' do expect(repository.gitaly_repository_client).to receive(:fetch_remote) - .with(remote_name, ssh_auth: ssh_auth, forced: true, no_tags: true, timeout: timeout) + .with(remote_name, ssh_auth: ssh_auth, forced: true, no_tags: true, prune: true, timeout: timeout) subject end diff --git a/spec/lib/gitlab/user_access_spec.rb b/spec/lib/gitlab/user_access_spec.rb index 7280acb6c82..40c8286b1b9 100644 --- a/spec/lib/gitlab/user_access_spec.rb +++ b/spec/lib/gitlab/user_access_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Gitlab::UserAccess do + include ProjectForksHelper + let(:access) { described_class.new(user, project: project) } let(:project) { create(:project, :repository) } let(:user) { create(:user) } @@ -118,6 +120,39 @@ describe Gitlab::UserAccess do end end + describe 'allowing pushes to maintainers of forked projects' do + let(:canonical_project) { create(:project, :public, :repository) } + let(:project) { fork_project(canonical_project, create(:user), repository: true) } + + before do + create( + :merge_request, + target_project: canonical_project, + source_project: project, + source_branch: 'awesome-feature', + allow_maintainer_to_push: true + ) + end + + it 'allows users that have push access to the canonical project to push to the MR branch' do + canonical_project.add_developer(user) + + expect(access.can_push_to_branch?('awesome-feature')).to be_truthy + end + + it 'does not allow the user to push to other branches' do + canonical_project.add_developer(user) + + expect(access.can_push_to_branch?('master')).to be_falsey + end + + it 'does not allow the user to push if he does not have push access to the canonical project' do + canonical_project.add_guest(user) + + expect(access.can_push_to_branch?('awesome-feature')).to be_falsey + end + end + describe 'merge to protected branch if allowed for developers' do before do @branch = create :protected_branch, :developers_can_merge, project: project diff --git a/spec/lib/gitlab/utils_spec.rb b/spec/lib/gitlab/utils_spec.rb index bda239b7871..71a743495a2 100644 --- a/spec/lib/gitlab/utils_spec.rb +++ b/spec/lib/gitlab/utils_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Gitlab::Utils do - delegate :to_boolean, :boolean_to_yes_no, :slugify, :random_string, :which, to: :described_class + delegate :to_boolean, :boolean_to_yes_no, :slugify, :random_string, :which, :ensure_array_from_string, to: :described_class describe '.slugify' do { @@ -83,4 +83,18 @@ describe Gitlab::Utils do expect(which('sh', 'PATH' => '/bin')).to eq('/bin/sh') end end + + describe '.ensure_array_from_string' do + it 'returns the same array if given one' do + arr = ['a', 4, true, { test: 1 }] + + expect(ensure_array_from_string(arr)).to eq(arr) + end + + it 'turns comma-separated strings into arrays' do + str = 'seven, eight, 9, 10' + + expect(ensure_array_from_string(str)).to eq(%w[seven eight 9 10]) + end + end end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index ceb570ac777..412eca4a56b 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -142,15 +142,15 @@ describe Environment do let(:commit) { project.commit.parent } it 'returns deployment id for the environment' do - expect(environment.first_deployment_for(commit)).to eq deployment1 + expect(environment.first_deployment_for(commit.id)).to eq deployment1 end it 'return nil when no deployment is found' do - expect(environment.first_deployment_for(head_commit)).to eq nil + expect(environment.first_deployment_for(head_commit.id)).to eq nil end it 'returns a UTF-8 ref' do - expect(environment.first_deployment_for(commit).ref).to be_utf8 + expect(environment.first_deployment_for(commit.id).ref).to be_utf8 end end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 67f49348acb..8ea92410022 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -49,6 +49,22 @@ describe Event do end end end + + describe 'after_create :track_user_interacted_projects' do + let(:event) { build(:push_event, project: project, author: project.owner) } + + it 'passes event to UserInteractedProject.track' do + expect(UserInteractedProject).to receive(:available?).and_return(true) + expect(UserInteractedProject).to receive(:track).with(event) + event.save + end + + it 'does not call UserInteractedProject.track if its not yet available' do + expect(UserInteractedProject).to receive(:available?).and_return(false) + expect(UserInteractedProject).not_to receive(:track) + event.save + end + end end describe "Push event" do diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb index 3e46fa36375..b8b0e63f92e 100644 --- a/spec/models/members/project_member_spec.rb +++ b/spec/models/members/project_member_spec.rb @@ -45,14 +45,6 @@ describe ProjectMember do let(:project) { owner.project } let(:master) { create(:project_member, project: project) } - let(:owner_todos) { (0...2).map { create(:todo, user: owner.user, project: project) } } - let(:master_todos) { (0...3).map { create(:todo, user: master.user, project: project) } } - - before do - owner_todos - master_todos - end - it "creates an expired event when left due to expiry" do expired = create(:project_member, project: project, expires_at: Time.now - 6.days) expired.destroy @@ -63,21 +55,6 @@ describe ProjectMember do master.destroy expect(Event.recent.first.action).to eq(Event::LEFT) end - - it "destroys itself and delete associated todos" do - expect(owner.user.todos.size).to eq(2) - expect(master.user.todos.size).to eq(3) - expect(Todo.count).to eq(5) - - master_todo_ids = master_todos.map(&:id) - master.destroy - - expect(owner.user.todos.size).to eq(2) - expect(Todo.count).to eq(2) - master_todo_ids.each do |id| - expect(Todo.exists?(id)).to eq(false) - end - end end describe '.import_team' do diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 243eeddc7a8..7986aa31e16 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -2084,4 +2084,82 @@ describe MergeRequest do it_behaves_like 'checking whether a rebase is in progress' end end + + describe '#allow_maintainer_to_push' do + let(:merge_request) do + build(:merge_request, source_branch: 'fixes', allow_maintainer_to_push: true) + end + + it 'is false when pushing by a maintainer is not possible' do + expect(merge_request).to receive(:maintainer_push_possible?) { false } + + expect(merge_request.allow_maintainer_to_push).to be_falsy + end + + it 'is true when pushing by a maintainer is possible' do + expect(merge_request).to receive(:maintainer_push_possible?) { true } + + expect(merge_request.allow_maintainer_to_push).to be_truthy + end + end + + describe '#maintainer_push_possible?' do + let(:merge_request) do + build(:merge_request, source_branch: 'fixes') + end + + before do + allow(ProtectedBranch).to receive(:protected?) { false } + end + + it 'does not allow maintainer to push if the source project is the same as the target' do + merge_request.target_project = merge_request.source_project = create(:project, :public) + + expect(merge_request.maintainer_push_possible?).to be_falsy + end + + it 'allows maintainer to push when both source and target are public' do + merge_request.target_project = build(:project, :public) + merge_request.source_project = build(:project, :public) + + expect(merge_request.maintainer_push_possible?).to be_truthy + end + + it 'is not available for protected branches' do + merge_request.target_project = build(:project, :public) + merge_request.source_project = build(:project, :public) + + expect(ProtectedBranch).to receive(:protected?) + .with(merge_request.source_project, 'fixes') + .and_return(true) + + expect(merge_request.maintainer_push_possible?).to be_falsy + end + end + + describe '#can_allow_maintainer_to_push?' do + let(:target_project) { create(:project, :public) } + let(:source_project) { fork_project(target_project) } + let(:merge_request) do + create(:merge_request, + source_project: source_project, + source_branch: 'fixes', + target_project: target_project) + end + let(:user) { create(:user) } + + before do + allow(merge_request).to receive(:maintainer_push_possible?) { true } + end + + it 'is false if the user does not have push access to the source project' do + expect(merge_request.can_allow_maintainer_to_push?(user)).to be_falsy + end + + it 'is true when the user has push access to the source project' do + source_project.add_developer(user) + + expect(merge_request.can_allow_maintainer_to_push?(user)).to be_truthy + end + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index b1c9e6754b9..e970cd7dfdb 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Project do + include ProjectForksHelper + describe 'associations' do it { is_expected.to belong_to(:group) } it { is_expected.to belong_to(:namespace) } @@ -3378,4 +3380,103 @@ describe Project do end end end + + context 'with cross project merge requests' do + let(:user) { create(:user) } + let(:target_project) { create(:project, :repository) } + let(:project) { fork_project(target_project, nil, repository: true) } + let!(:merge_request) do + create( + :merge_request, + target_project: target_project, + target_branch: 'target-branch', + source_project: project, + source_branch: 'awesome-feature-1', + allow_maintainer_to_push: true + ) + end + + before do + target_project.add_developer(user) + end + + describe '#merge_requests_allowing_push_to_user' do + it 'returns open merge requests for which the user has developer access to the target project' do + expect(project.merge_requests_allowing_push_to_user(user)).to include(merge_request) + end + + it 'does not include closed merge requests' do + merge_request.close + + expect(project.merge_requests_allowing_push_to_user(user)).to be_empty + end + + it 'does not include merge requests for guest users' do + guest = create(:user) + target_project.add_guest(guest) + + expect(project.merge_requests_allowing_push_to_user(guest)).to be_empty + end + + it 'does not include the merge request for other users' do + other_user = create(:user) + + expect(project.merge_requests_allowing_push_to_user(other_user)).to be_empty + end + + it 'is empty when no user is passed' do + expect(project.merge_requests_allowing_push_to_user(nil)).to be_empty + end + end + + describe '#branch_allows_maintainer_push?' do + it 'allows access if the user can merge the merge request' do + expect(project.branch_allows_maintainer_push?(user, 'awesome-feature-1')) + .to be_truthy + end + + it 'does not allow guest users access' do + guest = create(:user) + target_project.add_guest(guest) + + expect(project.branch_allows_maintainer_push?(guest, 'awesome-feature-1')) + .to be_falsy + end + + it 'does not allow access to branches for which the merge request was closed' do + create(:merge_request, :closed, + target_project: target_project, + target_branch: 'target-branch', + source_project: project, + source_branch: 'rejected-feature-1', + allow_maintainer_to_push: true) + + expect(project.branch_allows_maintainer_push?(user, 'rejected-feature-1')) + .to be_falsy + end + + it 'does not allow access if the user cannot merge the merge request' do + create(:protected_branch, :masters_can_push, project: target_project, name: 'target-branch') + + expect(project.branch_allows_maintainer_push?(user, 'awesome-feature-1')) + .to be_falsy + end + + it 'caches the result' do + control = ActiveRecord::QueryRecorder.new { project.branch_allows_maintainer_push?(user, 'awesome-feature-1') } + + expect { 3.times { project.branch_allows_maintainer_push?(user, 'awesome-feature-1') } } + .not_to exceed_query_limit(control) + end + + context 'when the requeststore is active', :request_store do + it 'only queries per project across instances' do + control = ActiveRecord::QueryRecorder.new { project.branch_allows_maintainer_push?(user, 'awesome-feature-1') } + + expect { 2.times { described_class.find(project.id).branch_allows_maintainer_push?(user, 'awesome-feature-1') } } + .not_to exceed_query_limit(control).with_threshold(2) + end + end + end + end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 38653e18306..579069ffa14 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -1004,7 +1004,7 @@ describe Repository do end end - context 'with Gitaly disabled', :skip_gitaly_mock do + context 'with Gitaly disabled', :disable_gitaly do context 'when pre hooks were successful' do it 'runs without errors' do hook = double(trigger: [true, nil]) @@ -1896,7 +1896,7 @@ describe Repository do it_behaves_like 'adding tag' end - context 'when Gitaly operation_user_add_tag feature is disabled', :skip_gitaly_mock do + context 'when Gitaly operation_user_add_tag feature is disabled', :disable_gitaly do it_behaves_like 'adding tag' it 'passes commit SHA to pre-receive and update hooks and tag SHA to post-receive hook' do @@ -1955,7 +1955,7 @@ describe Repository do end end - context 'with gitaly disabled', :skip_gitaly_mock do + context 'with gitaly disabled', :disable_gitaly do it_behaves_like "user deleting a branch" let(:old_rev) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' } # git rev-parse feature diff --git a/spec/models/user_interacted_project_spec.rb b/spec/models/user_interacted_project_spec.rb new file mode 100644 index 00000000000..cb4bb3372d4 --- /dev/null +++ b/spec/models/user_interacted_project_spec.rb @@ -0,0 +1,60 @@ +require 'spec_helper' + +describe UserInteractedProject do + describe '.track' do + subject { described_class.track(event) } + let(:event) { build(:event) } + + Event::ACTIONS.each do |action| + context "for all actions (event types)" do + let(:event) { build(:event, action: action) } + it 'creates a record' do + expect { subject }.to change { described_class.count }.from(0).to(1) + end + end + end + + it 'sets project accordingly' do + subject + expect(described_class.first.project).to eq(event.project) + end + + it 'sets user accordingly' do + subject + expect(described_class.first.user).to eq(event.author) + end + + it 'only creates a record once per user/project' do + expect do + subject + described_class.track(event) + end.to change { described_class.count }.from(0).to(1) + end + + describe 'with an event without a project' do + let(:event) { build(:event, project: nil) } + + it 'ignores the event' do + expect { subject }.not_to change { described_class.count } + end + end + end + + describe '.available?' do + before do + described_class.instance_variable_set('@available_flag', nil) + end + + it 'checks schema version and properly caches positive result' do + expect(ActiveRecord::Migrator).to receive(:current_version).and_return(described_class::REQUIRED_SCHEMA_VERSION - 1 - rand(1000)) + expect(described_class.available?).to be_falsey + expect(ActiveRecord::Migrator).to receive(:current_version).and_return(described_class::REQUIRED_SCHEMA_VERSION + rand(1000)) + expect(described_class.available?).to be_truthy + expect(ActiveRecord::Migrator).not_to receive(:current_version) + expect(described_class.available?).to be_truthy # cached response + end + end + + it { is_expected.to validate_presence_of(:project_id) } + it { is_expected.to validate_presence_of(:user_id) } +end diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index 129344f105f..ea76e604153 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -308,4 +308,41 @@ describe ProjectPolicy do it_behaves_like 'project policies as master' it_behaves_like 'project policies as owner' it_behaves_like 'project policies as admin' + + context 'when a public project has merge requests allowing access' do + include ProjectForksHelper + let(:user) { create(:user) } + let(:target_project) { create(:project, :public) } + let(:project) { fork_project(target_project) } + let!(:merge_request) do + create( + :merge_request, + target_project: target_project, + source_project: project, + allow_maintainer_to_push: true + ) + end + let(:maintainer_abilities) do + %w(create_build update_build create_pipeline update_pipeline) + end + + subject { described_class.new(user, project) } + + it 'does not allow pushing code' do + expect_disallowed(*maintainer_abilities) + end + + it 'allows pushing if the user is a member with push access to the target project' do + target_project.add_developer(user) + + expect_allowed(*maintainer_abilities) + end + + it 'dissallows abilities to a maintainer if the merge request was closed' do + target_project.add_developer(user) + merge_request.close! + + expect_disallowed(*maintainer_abilities) + end + end end diff --git a/spec/requests/api/discussions_spec.rb b/spec/requests/api/discussions_spec.rb new file mode 100644 index 00000000000..4a44b219a67 --- /dev/null +++ b/spec/requests/api/discussions_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe API::Discussions do + let(:user) { create(:user) } + let!(:project) { create(:project, :public, namespace: user.namespace) } + let(:private_user) { create(:user) } + + before do + project.add_reporter(user) + end + + context "when noteable is an Issue" do + let!(:issue) { create(:issue, project: project, author: user) } + let!(:issue_note) { create(:discussion_note_on_issue, noteable: issue, project: project, author: user) } + + it_behaves_like "discussions API", 'projects', 'issues', 'iid' do + let(:parent) { project } + let(:noteable) { issue } + let(:note) { issue_note } + end + end + + context "when noteable is a Snippet" do + let!(:snippet) { create(:project_snippet, project: project, author: user) } + let!(:snippet_note) { create(:discussion_note_on_snippet, noteable: snippet, project: project, author: user) } + + it_behaves_like "discussions API", 'projects', 'snippets', 'id' do + let(:parent) { project } + let(:noteable) { snippet } + let(:note) { snippet_note } + end + end +end diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb index 827f4c04324..ca0aac87ba9 100644 --- a/spec/requests/api/internal_spec.rb +++ b/spec/requests/api/internal_spec.rb @@ -335,21 +335,8 @@ describe API::Internal do end context "git push" do - context "gitaly disabled", :disable_gitaly do - it "has the correct payload" do - push(key, project) - - expect(response).to have_gitlab_http_status(200) - expect(json_response["status"]).to be_truthy - expect(json_response["repository_path"]).to eq(project.repository.path_to_repo) - expect(json_response["gl_repository"]).to eq("project-#{project.id}") - expect(json_response["gitaly"]).to be_nil - expect(user).not_to have_an_activity_record - end - end - - context "gitaly enabled" do - it "has the correct payload" do + context 'project as namespace/project' do + it do push(key, project) expect(response).to have_gitlab_http_status(200) @@ -365,17 +352,6 @@ describe API::Internal do expect(user).not_to have_an_activity_record end end - - context 'project as namespace/project' do - it do - push(key, project) - - expect(response).to have_gitlab_http_status(200) - expect(json_response["status"]).to be_truthy - expect(json_response["repository_path"]).to eq(project.repository.path_to_repo) - expect(json_response["gl_repository"]).to eq("project-#{project.id}") - end - end end end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 484322752c0..3764aec0c71 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -616,6 +616,25 @@ describe API::MergeRequests do expect(json_response['changes_count']).to eq('5+') end end + + context 'for forked projects' do + let(:user2) { create(:user) } + let(:project) { create(:project, :public, :repository) } + let(:forked_project) { fork_project(project, user2, repository: true) } + let(:merge_request) do + create(:merge_request, + source_project: forked_project, + target_project: project, + source_branch: 'fixes', + allow_maintainer_to_push: true) + end + + it 'includes the `allow_maintainer_to_push` field' do + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user) + + expect(json_response['allow_maintainer_to_push']).to be_truthy + end + end end describe 'GET /projects/:id/merge_requests/:merge_request_iid/participants' do @@ -815,6 +834,7 @@ describe API::MergeRequests do context 'forked projects' do let!(:user2) { create(:user) } + let(:project) { create(:project, :public, :repository) } let!(:forked_project) { fork_project(project, user2, repository: true) } let!(:unrelated_project) { create(:project, namespace: create(:user).namespace, creator_id: user2.id) } @@ -872,6 +892,14 @@ describe API::MergeRequests do expect(response).to have_gitlab_http_status(400) end + it 'allows setting `allow_maintainer_to_push`' do + post api("/projects/#{forked_project.id}/merge_requests", user2), + title: 'Test merge_request', source_branch: "feature_conflict", target_branch: "master", + author: user2, target_project_id: project.id, allow_maintainer_to_push: true + expect(response).to have_gitlab_http_status(201) + expect(json_response['allow_maintainer_to_push']).to be_truthy + end + context 'when target_branch and target_project_id is specified' do let(:params) do { title: 'Test merge_request', diff --git a/spec/requests/api/notes_spec.rb b/spec/requests/api/notes_spec.rb index 981c9c27325..dd568c24c72 100644 --- a/spec/requests/api/notes_spec.rb +++ b/spec/requests/api/notes_spec.rb @@ -3,117 +3,86 @@ require 'spec_helper' describe API::Notes do let(:user) { create(:user) } let!(:project) { create(:project, :public, namespace: user.namespace) } - let!(:issue) { create(:issue, project: project, author: user) } - let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, author: user) } - let!(:snippet) { create(:project_snippet, project: project, author: user) } - let!(:issue_note) { create(:note, noteable: issue, project: project, author: user) } - let!(:merge_request_note) { create(:note, noteable: merge_request, project: project, author: user) } - let!(:snippet_note) { create(:note, noteable: snippet, project: project, author: user) } - - # For testing the cross-reference of a private issue in a public issue let(:private_user) { create(:user) } - let(:private_project) do - create(:project, namespace: private_user.namespace) - .tap { |p| p.add_master(private_user) } - end - let(:private_issue) { create(:issue, project: private_project) } - - let(:ext_proj) { create(:project, :public) } - let(:ext_issue) { create(:issue, project: ext_proj) } - - let!(:cross_reference_note) do - create :note, - noteable: ext_issue, project: ext_proj, - note: "mentioned in issue #{private_issue.to_reference(ext_proj)}", - system: true - end before do project.add_reporter(user) end - describe "GET /projects/:id/noteable/:noteable_id/notes" do - context "when noteable is an Issue" do - context 'sorting' do - before do - create_list(:note, 3, noteable: issue, project: project, author: user) - end - - it 'sorts by created_at in descending order by default' do - get api("/projects/#{project.id}/issues/#{issue.iid}/notes", user) - - response_dates = json_response.map { |noteable| noteable['created_at'] } - - expect(json_response.length).to eq(4) - expect(response_dates).to eq(response_dates.sort.reverse) - end - - it 'sorts by ascending order when requested' do - get api("/projects/#{project.id}/issues/#{issue.iid}/notes?sort=asc", user) - - response_dates = json_response.map { |noteable| noteable['created_at'] } - - expect(json_response.length).to eq(4) - expect(response_dates).to eq(response_dates.sort) - end - - it 'sorts by updated_at in descending order when requested' do - get api("/projects/#{project.id}/issues/#{issue.iid}/notes?order_by=updated_at", user) - - response_dates = json_response.map { |noteable| noteable['updated_at'] } + context "when noteable is an Issue" do + let!(:issue) { create(:issue, project: project, author: user) } + let!(:issue_note) { create(:note, noteable: issue, project: project, author: user) } - expect(json_response.length).to eq(4) - expect(response_dates).to eq(response_dates.sort.reverse) - end + it_behaves_like "noteable API", 'projects', 'issues', 'iid' do + let(:parent) { project } + let(:noteable) { issue } + let(:note) { issue_note } + end - it 'sorts by updated_at in ascending order when requested' do - get api("/projects/#{project.id}/issues/#{issue.iid}/notes??order_by=updated_at&sort=asc", user) + context 'when user does not have access to create noteable' do + let(:private_issue) { create(:issue, project: create(:project, :private)) } - response_dates = json_response.map { |noteable| noteable['updated_at'] } + ## + # We are posting to project user has access to, but we use issue id + # from a different project, see #15577 + # + before do + post api("/projects/#{private_issue.project.id}/issues/#{private_issue.iid}/notes", user), + body: 'Hi!' + end - expect(json_response.length).to eq(4) - expect(response_dates).to eq(response_dates.sort) - end + it 'responds with resource not found error' do + expect(response.status).to eq 404 end - it "returns an array of issue notes" do - get api("/projects/#{project.id}/issues/#{issue.iid}/notes", user) + it 'does not create new note' do + expect(private_issue.notes.reload).to be_empty + end + end - expect(response).to have_gitlab_http_status(200) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - expect(json_response.first['body']).to eq(issue_note.note) + context "when referencing other project" do + # For testing the cross-reference of a private issue in a public project + let(:private_project) do + create(:project, namespace: private_user.namespace) + .tap { |p| p.add_master(private_user) } end + let(:private_issue) { create(:issue, project: private_project) } - it "returns a 404 error when issue id not found" do - get api("/projects/#{project.id}/issues/12345/notes", user) + let(:ext_proj) { create(:project, :public) } + let(:ext_issue) { create(:issue, project: ext_proj) } - expect(response).to have_gitlab_http_status(404) + let!(:cross_reference_note) do + create :note, + noteable: ext_issue, project: ext_proj, + note: "mentioned in issue #{private_issue.to_reference(ext_proj)}", + system: true end - context "and current user cannot view the notes" do - it "returns an empty array" do - get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes", user) - - expect(response).to have_gitlab_http_status(200) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - expect(json_response).to be_empty - end + describe "GET /projects/:id/noteable/:noteable_id/notes" do + context "current user cannot view the notes" do + it "returns an empty array" do + get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes", user) - context "and issue is confidential" do - before do - ext_issue.update_attributes(confidential: true) + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response).to be_empty end - it "returns 404" do - get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes", user) + context "issue is confidential" do + before do + ext_issue.update_attributes(confidential: true) + end - expect(response).to have_gitlab_http_status(404) + it "returns 404" do + get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes", user) + + expect(response).to have_gitlab_http_status(404) + end end end - context "and current user can view the note" do + context "current user can view the note" do it "returns an empty array" do get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes", private_user) @@ -124,172 +93,29 @@ describe API::Notes do end end end - end - - context "when noteable is a Snippet" do - context 'sorting' do - before do - create_list(:note, 3, noteable: snippet, project: project, author: user) - end - - it 'sorts by created_at in descending order by default' do - get api("/projects/#{project.id}/snippets/#{snippet.id}/notes", user) - - response_dates = json_response.map { |noteable| noteable['created_at'] } - - expect(json_response.length).to eq(4) - expect(response_dates).to eq(response_dates.sort.reverse) - end - - it 'sorts by ascending order when requested' do - get api("/projects/#{project.id}/snippets/#{snippet.id}/notes?sort=asc", user) - - response_dates = json_response.map { |noteable| noteable['created_at'] } - - expect(json_response.length).to eq(4) - expect(response_dates).to eq(response_dates.sort) - end - - it 'sorts by updated_at in descending order when requested' do - get api("/projects/#{project.id}/snippets/#{snippet.id}/notes?order_by=updated_at", user) - - response_dates = json_response.map { |noteable| noteable['updated_at'] } - - expect(json_response.length).to eq(4) - expect(response_dates).to eq(response_dates.sort.reverse) - end - it 'sorts by updated_at in ascending order when requested' do - get api("/projects/#{project.id}/snippets/#{snippet.id}/notes??order_by=updated_at&sort=asc", user) + describe "GET /projects/:id/noteable/:noteable_id/notes/:note_id" do + context "current user cannot view the notes" do + it "returns a 404 error" do + get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes/#{cross_reference_note.id}", user) - response_dates = json_response.map { |noteable| noteable['updated_at'] } - - expect(json_response.length).to eq(4) - expect(response_dates).to eq(response_dates.sort) - end - end - it "returns an array of snippet notes" do - get api("/projects/#{project.id}/snippets/#{snippet.id}/notes", user) - - expect(response).to have_gitlab_http_status(200) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - expect(json_response.first['body']).to eq(snippet_note.note) - end - - it "returns a 404 error when snippet id not found" do - get api("/projects/#{project.id}/snippets/42/notes", user) - - expect(response).to have_gitlab_http_status(404) - end - - it "returns 404 when not authorized" do - get api("/projects/#{project.id}/snippets/#{snippet.id}/notes", private_user) - - expect(response).to have_gitlab_http_status(404) - end - end - - context "when noteable is a Merge Request" do - context 'sorting' do - before do - create_list(:note, 3, noteable: merge_request, project: project, author: user) - end - - it 'sorts by created_at in descending order by default' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/notes", user) - - response_dates = json_response.map { |noteable| noteable['created_at'] } - - expect(json_response.length).to eq(4) - expect(response_dates).to eq(response_dates.sort.reverse) - end - - it 'sorts by ascending order when requested' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/notes?sort=asc", user) - - response_dates = json_response.map { |noteable| noteable['created_at'] } - - expect(json_response.length).to eq(4) - expect(response_dates).to eq(response_dates.sort) - end - - it 'sorts by updated_at in descending order when requested' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/notes?order_by=updated_at", user) - - response_dates = json_response.map { |noteable| noteable['updated_at'] } - - expect(json_response.length).to eq(4) - expect(response_dates).to eq(response_dates.sort.reverse) - end - - it 'sorts by updated_at in ascending order when requested' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/notes??order_by=updated_at&sort=asc", user) - - response_dates = json_response.map { |noteable| noteable['updated_at'] } - - expect(json_response.length).to eq(4) - expect(response_dates).to eq(response_dates.sort) - end - end - it "returns an array of merge_requests notes" do - get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/notes", user) - - expect(response).to have_gitlab_http_status(200) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - expect(json_response.first['body']).to eq(merge_request_note.note) - end - - it "returns a 404 error if merge request id not found" do - get api("/projects/#{project.id}/merge_requests/4444/notes", user) - - expect(response).to have_gitlab_http_status(404) - end - - it "returns 404 when not authorized" do - get api("/projects/#{project.id}/merge_requests/4444/notes", private_user) - - expect(response).to have_gitlab_http_status(404) - end - end - end - - describe "GET /projects/:id/noteable/:noteable_id/notes/:note_id" do - context "when noteable is an Issue" do - it "returns an issue note by id" do - get api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{issue_note.id}", user) - - expect(response).to have_gitlab_http_status(200) - expect(json_response['body']).to eq(issue_note.note) - end - - it "returns a 404 error if issue note not found" do - get api("/projects/#{project.id}/issues/#{issue.iid}/notes/12345", user) - - expect(response).to have_gitlab_http_status(404) - end - - context "and current user cannot view the note" do - it "returns a 404 error" do - get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes/#{cross_reference_note.id}", user) - - expect(response).to have_gitlab_http_status(404) - end - - context "when issue is confidential" do - before do - issue.update_attributes(confidential: true) + expect(response).to have_gitlab_http_status(404) end - it "returns 404" do - get api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{issue_note.id}", private_user) + context "when issue is confidential" do + before do + issue.update_attributes(confidential: true) + end - expect(response).to have_gitlab_http_status(404) + it "returns 404" do + get api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{issue_note.id}", private_user) + + expect(response).to have_gitlab_http_status(404) + end end end - context "and current user can view the note" do + context "current user can view the note" do it "returns an issue note by id" do get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes/#{cross_reference_note.id}", private_user) @@ -299,132 +125,27 @@ describe API::Notes do end end end - - context "when noteable is a Snippet" do - it "returns a snippet note by id" do - get api("/projects/#{project.id}/snippets/#{snippet.id}/notes/#{snippet_note.id}", user) - - expect(response).to have_gitlab_http_status(200) - expect(json_response['body']).to eq(snippet_note.note) - end - - it "returns a 404 error if snippet note not found" do - get api("/projects/#{project.id}/snippets/#{snippet.id}/notes/12345", user) - - expect(response).to have_gitlab_http_status(404) - end - end end - describe "POST /projects/:id/noteable/:noteable_id/notes" do - context "when noteable is an Issue" do - it "creates a new issue note" do - post api("/projects/#{project.id}/issues/#{issue.iid}/notes", user), body: 'hi!' - - expect(response).to have_gitlab_http_status(201) - expect(json_response['body']).to eq('hi!') - expect(json_response['author']['username']).to eq(user.username) - end - - it "returns a 400 bad request error if body not given" do - post api("/projects/#{project.id}/issues/#{issue.iid}/notes", user) - - expect(response).to have_gitlab_http_status(400) - end - - it "returns a 401 unauthorized error if user not authenticated" do - post api("/projects/#{project.id}/issues/#{issue.iid}/notes"), body: 'hi!' - - expect(response).to have_gitlab_http_status(401) - end - - context 'when an admin or owner makes the request' do - it 'accepts the creation date to be set' do - creation_time = 2.weeks.ago - post api("/projects/#{project.id}/issues/#{issue.iid}/notes", user), - body: 'hi!', created_at: creation_time - - expect(response).to have_gitlab_http_status(201) - expect(json_response['body']).to eq('hi!') - expect(json_response['author']['username']).to eq(user.username) - expect(Time.parse(json_response['created_at'])).to be_like_time(creation_time) - end - end - - context 'when the user is posting an award emoji on an issue created by someone else' do - let(:issue2) { create(:issue, project: project) } - - it 'creates a new issue note' do - post api("/projects/#{project.id}/issues/#{issue2.iid}/notes", user), body: ':+1:' - - expect(response).to have_gitlab_http_status(201) - expect(json_response['body']).to eq(':+1:') - end - end - - context 'when the user is posting an award emoji on his/her own issue' do - it 'creates a new issue note' do - post api("/projects/#{project.id}/issues/#{issue.iid}/notes", user), body: ':+1:' - - expect(response).to have_gitlab_http_status(201) - expect(json_response['body']).to eq(':+1:') - end - end - end - - context "when noteable is a Snippet" do - it "creates a new snippet note" do - post api("/projects/#{project.id}/snippets/#{snippet.id}/notes", user), body: 'hi!' + context "when noteable is a Snippet" do + let!(:snippet) { create(:project_snippet, project: project, author: user) } + let!(:snippet_note) { create(:note, noteable: snippet, project: project, author: user) } - expect(response).to have_gitlab_http_status(201) - expect(json_response['body']).to eq('hi!') - expect(json_response['author']['username']).to eq(user.username) - end - - it "returns a 400 bad request error if body not given" do - post api("/projects/#{project.id}/snippets/#{snippet.id}/notes", user) - - expect(response).to have_gitlab_http_status(400) - end - - it "returns a 401 unauthorized error if user not authenticated" do - post api("/projects/#{project.id}/snippets/#{snippet.id}/notes"), body: 'hi!' - - expect(response).to have_gitlab_http_status(401) - end + it_behaves_like "noteable API", 'projects', 'snippets', 'id' do + let(:parent) { project } + let(:noteable) { snippet } + let(:note) { snippet_note } end + end - context 'when user does not have access to read the noteable' do - it 'responds with 404' do - project = create(:project, :private) { |p| p.add_guest(user) } - issue = create(:issue, :confidential, project: project) - - post api("/projects/#{project.id}/issues/#{issue.iid}/notes", user), - body: 'Foo' - - expect(response).to have_gitlab_http_status(404) - end - end - - context 'when user does not have access to create noteable' do - let(:private_issue) { create(:issue, project: create(:project, :private)) } - - ## - # We are posting to project user has access to, but we use issue id - # from a different project, see #15577 - # - before do - post api("/projects/#{private_issue.project.id}/issues/#{private_issue.iid}/notes", user), - body: 'Hi!' - end - - it 'responds with resource not found error' do - expect(response.status).to eq 404 - end + context "when noteable is a Merge Request" do + let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, author: user) } + let!(:merge_request_note) { create(:note, noteable: merge_request, project: project, author: user) } - it 'does not create new note' do - expect(private_issue.notes.reload).to be_empty - end + it_behaves_like "noteable API", 'projects', 'merge_requests', 'iid' do + let(:parent) { project } + let(:noteable) { merge_request } + let(:note) { merge_request_note } end context 'when the merge request discussion is locked' do @@ -461,145 +182,4 @@ describe API::Notes do end end end - - describe "POST /projects/:id/noteable/:noteable_id/notes to test observer on create" do - it "creates an activity event when an issue note is created" do - expect(Event).to receive(:create!) - - post api("/projects/#{project.id}/issues/#{issue.iid}/notes", user), body: 'hi!' - end - end - - describe 'PUT /projects/:id/noteable/:noteable_id/notes/:note_id' do - context 'when noteable is an Issue' do - it 'returns modified note' do - put api("/projects/#{project.id}/issues/#{issue.iid}/"\ - "notes/#{issue_note.id}", user), body: 'Hello!' - - expect(response).to have_gitlab_http_status(200) - expect(json_response['body']).to eq('Hello!') - end - - it 'returns a 404 error when note id not found' do - put api("/projects/#{project.id}/issues/#{issue.iid}/notes/12345", user), - body: 'Hello!' - - expect(response).to have_gitlab_http_status(404) - end - - it 'returns a 400 bad request error if body not given' do - put api("/projects/#{project.id}/issues/#{issue.iid}/"\ - "notes/#{issue_note.id}", user) - - expect(response).to have_gitlab_http_status(400) - end - end - - context 'when noteable is a Snippet' do - it 'returns modified note' do - put api("/projects/#{project.id}/snippets/#{snippet.id}/"\ - "notes/#{snippet_note.id}", user), body: 'Hello!' - - expect(response).to have_gitlab_http_status(200) - expect(json_response['body']).to eq('Hello!') - end - - it 'returns a 404 error when note id not found' do - put api("/projects/#{project.id}/snippets/#{snippet.id}/"\ - "notes/12345", user), body: "Hello!" - - expect(response).to have_gitlab_http_status(404) - end - end - - context 'when noteable is a Merge Request' do - it 'returns modified note' do - put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/"\ - "notes/#{merge_request_note.id}", user), body: 'Hello!' - - expect(response).to have_gitlab_http_status(200) - expect(json_response['body']).to eq('Hello!') - end - - it 'returns a 404 error when note id not found' do - put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/"\ - "notes/12345", user), body: "Hello!" - - expect(response).to have_gitlab_http_status(404) - end - end - end - - describe 'DELETE /projects/:id/noteable/:noteable_id/notes/:note_id' do - context 'when noteable is an Issue' do - it 'deletes a note' do - delete api("/projects/#{project.id}/issues/#{issue.iid}/"\ - "notes/#{issue_note.id}", user) - - expect(response).to have_gitlab_http_status(204) - # Check if note is really deleted - delete api("/projects/#{project.id}/issues/#{issue.iid}/"\ - "notes/#{issue_note.id}", user) - expect(response).to have_gitlab_http_status(404) - end - - it 'returns a 404 error when note id not found' do - delete api("/projects/#{project.id}/issues/#{issue.iid}/notes/12345", user) - - expect(response).to have_gitlab_http_status(404) - end - - it_behaves_like '412 response' do - let(:request) { api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{issue_note.id}", user) } - end - end - - context 'when noteable is a Snippet' do - it 'deletes a note' do - delete api("/projects/#{project.id}/snippets/#{snippet.id}/"\ - "notes/#{snippet_note.id}", user) - - expect(response).to have_gitlab_http_status(204) - # Check if note is really deleted - delete api("/projects/#{project.id}/snippets/#{snippet.id}/"\ - "notes/#{snippet_note.id}", user) - expect(response).to have_gitlab_http_status(404) - end - - it 'returns a 404 error when note id not found' do - delete api("/projects/#{project.id}/snippets/#{snippet.id}/"\ - "notes/12345", user) - - expect(response).to have_gitlab_http_status(404) - end - - it_behaves_like '412 response' do - let(:request) { api("/projects/#{project.id}/snippets/#{snippet.id}/notes/#{snippet_note.id}", user) } - end - end - - context 'when noteable is a Merge Request' do - it 'deletes a note' do - delete api("/projects/#{project.id}/merge_requests/"\ - "#{merge_request.iid}/notes/#{merge_request_note.id}", user) - - expect(response).to have_gitlab_http_status(204) - # Check if note is really deleted - delete api("/projects/#{project.id}/merge_requests/"\ - "#{merge_request.iid}/notes/#{merge_request_note.id}", user) - expect(response).to have_gitlab_http_status(404) - end - - it 'returns a 404 error when note id not found' do - delete api("/projects/#{project.id}/merge_requests/"\ - "#{merge_request.iid}/notes/12345", user) - - expect(response).to have_gitlab_http_status(404) - end - - it_behaves_like '412 response' do - let(:request) { api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/notes/#{merge_request_note.id}", user) } - end - end - end end diff --git a/spec/serializers/merge_request_widget_entity_spec.rb b/spec/serializers/merge_request_widget_entity_spec.rb index 80a271ba7fb..d2072198d83 100644 --- a/spec/serializers/merge_request_widget_entity_spec.rb +++ b/spec/serializers/merge_request_widget_entity_spec.rb @@ -147,9 +147,9 @@ describe MergeRequestWidgetEntity do allow(resource).to receive(:diff_head_sha) { 'sha' } end - context 'when no diff head commit' do + context 'when diff head commit is empty' do it 'returns nil' do - allow(resource).to receive(:diff_head_commit) { nil } + allow(resource).to receive(:diff_head_sha) { '' } expect(subject[:diff_head_sha]).to be_nil end @@ -157,8 +157,6 @@ describe MergeRequestWidgetEntity do context 'when diff head commit present' do it 'returns diff head commit short id' do - allow(resource).to receive(:diff_head_commit) { double } - expect(subject[:diff_head_sha]).to eq('sha') end end diff --git a/spec/services/members/destroy_service_spec.rb b/spec/services/members/destroy_service_spec.rb index 10c264a90c5..36b6e5a701e 100644 --- a/spec/services/members/destroy_service_spec.rb +++ b/spec/services/members/destroy_service_spec.rb @@ -19,32 +19,11 @@ describe Members::DestroyService do end end - def number_of_assigned_issuables(user) - Issue.assigned_to(user).count + MergeRequest.assigned_to(user).count - end - shared_examples 'a service destroying a member' do it 'destroys the member' do expect { described_class.new(current_user).execute(member, opts) }.to change { member.source.members_and_requesters.count }.by(-1) end - it 'unassigns issues and merge requests' do - if member.invite? - expect { described_class.new(current_user).execute(member, opts) } - .not_to change { number_of_assigned_issuables(member_user) } - else - create :issue, assignees: [member_user] - issue = create :issue, project: group_project, assignees: [member_user] - merge_request = create :merge_request, target_project: group_project, source_project: group_project, assignee: member_user - - expect { described_class.new(current_user).execute(member, opts) } - .to change { number_of_assigned_issuables(member_user) }.from(3).to(1) - - expect(issue.reload.assignee_ids).to be_empty - expect(merge_request.reload.assignee_id).to be_nil - end - end - it 'destroys member notification_settings' do if member_user.notification_settings.any? expect { described_class.new(current_user).execute(member, opts) } @@ -56,6 +35,29 @@ describe Members::DestroyService do end end + shared_examples 'a service destroying a member with access' do + it_behaves_like 'a service destroying a member' + + it 'invalidates cached counts for todos and assigned issues and merge requests', :aggregate_failures do + create(:issue, project: group_project, assignees: [member_user]) + create(:merge_request, source_project: group_project, assignee: member_user) + create(:todo, :pending, project: group_project, user: member_user) + create(:todo, :done, project: group_project, user: member_user) + + expect(member_user.assigned_open_merge_requests_count).to be(1) + expect(member_user.assigned_open_issues_count).to be(1) + expect(member_user.todos_pending_count).to be(1) + expect(member_user.todos_done_count).to be(1) + + described_class.new(current_user).execute(member, opts) + + expect(member_user.assigned_open_merge_requests_count).to be(0) + expect(member_user.assigned_open_issues_count).to be(0) + expect(member_user.todos_pending_count).to be(0) + expect(member_user.todos_done_count).to be(0) + end + end + shared_examples 'a service destroying an access requester' do it_behaves_like 'a service destroying a member' @@ -74,29 +76,39 @@ describe Members::DestroyService do end end - context 'with a member' do + context 'with a member with access' do before do - group_project.add_developer(member_user) - group.add_developer(member_user) + group_project.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PRIVATE) + group.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PRIVATE) end context 'when current user cannot destroy the given member' do - it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do + context 'with a project member' do let(:member) { group_project.members.find_by(user_id: member_user.id) } - end - it_behaves_like 'a service destroying a member' do - let(:opts) { { skip_authorization: true } } - let(:member) { group_project.members.find_by(user_id: member_user.id) } - end + before do + group_project.add_developer(member_user) + end - it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do - let(:member) { group.members.find_by(user_id: member_user.id) } + it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' + + it_behaves_like 'a service destroying a member with access' do + let(:opts) { { skip_authorization: true } } + end end - it_behaves_like 'a service destroying a member' do - let(:opts) { { skip_authorization: true } } + context 'with a group member' do let(:member) { group.members.find_by(user_id: member_user.id) } + + before do + group.add_developer(member_user) + end + + it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' + + it_behaves_like 'a service destroying a member with access' do + let(:opts) { { skip_authorization: true } } + end end end @@ -106,12 +118,24 @@ describe Members::DestroyService do group.add_owner(current_user) end - it_behaves_like 'a service destroying a member' do + context 'with a project member' do let(:member) { group_project.members.find_by(user_id: member_user.id) } + + before do + group_project.add_developer(member_user) + end + + it_behaves_like 'a service destroying a member with access' end - it_behaves_like 'a service destroying a member' do + context 'with a group member' do let(:member) { group.members.find_by(user_id: member_user.id) } + + before do + group.add_developer(member_user) + end + + it_behaves_like 'a service destroying a member with access' end end end diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index c31259239ee..5279ea6164e 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe MergeRequests::UpdateService, :mailer do + include ProjectForksHelper + let(:project) { create(:project, :repository) } let(:user) { create(:user) } let(:user2) { create(:user) } @@ -538,5 +540,40 @@ describe MergeRequests::UpdateService, :mailer do let(:open_issuable) { merge_request } let(:closed_issuable) { create(:closed_merge_request, source_project: project) } end + + context 'setting `allow_maintainer_to_push`' do + let(:target_project) { create(:project, :public) } + let(:source_project) { fork_project(target_project) } + let(:user) { create(:user) } + let(:merge_request) do + create(:merge_request, + source_project: source_project, + source_branch: 'fixes', + target_project: target_project) + end + + before do + allow(ProtectedBranch).to receive(:protected?).with(source_project, 'fixes') { false } + end + + it 'does not allow a maintainer of the target project to set `allow_maintainer_to_push`' do + target_project.add_developer(user) + + update_merge_request(allow_maintainer_to_push: true, title: 'Updated title') + + expect(merge_request.title).to eq('Updated title') + expect(merge_request.allow_maintainer_to_push).to be_falsy + end + + it 'is allowed by a user that can push to the source and can update the merge request' do + merge_request.update!(assignee: user) + source_project.add_developer(user) + + update_merge_request(allow_maintainer_to_push: true, title: 'Updated title') + + expect(merge_request.title).to eq('Updated title') + expect(merge_request.allow_maintainer_to_push).to be_truthy + end + end end end diff --git a/spec/support/matchers/match_ids.rb b/spec/support/matchers/match_ids.rb new file mode 100644 index 00000000000..d8424405b96 --- /dev/null +++ b/spec/support/matchers/match_ids.rb @@ -0,0 +1,24 @@ +RSpec::Matchers.define :match_ids do |*expected| + match do |actual| + actual_ids = map_ids(actual) + expected_ids = map_ids(expected) + + expect(actual_ids).to match_array(expected_ids) + end + + description do + 'matches elements by ids' + end + + def map_ids(elements) + elements = elements.flatten if elements.respond_to?(:flatten) + + if elements.respond_to?(:map) + elements.map(&:id) + elsif elements.respond_to?(:id) + [elements.id] + else + raise ArgumentError, "could not map elements to ids: #{elements}" + end + end +end diff --git a/spec/support/shared_examples/requests/api/discussions.rb b/spec/support/shared_examples/requests/api/discussions.rb new file mode 100644 index 00000000000..b6aeb30d69c --- /dev/null +++ b/spec/support/shared_examples/requests/api/discussions.rb @@ -0,0 +1,169 @@ +shared_examples 'discussions API' do |parent_type, noteable_type, id_name| + describe "GET /#{parent_type}/:id/#{noteable_type}/:noteable_id/discussions" do + it "returns an array of discussions" do + get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.first['id']).to eq(note.discussion_id) + end + + it "returns a 404 error when noteable id not found" do + get api("/#{parent_type}/#{parent.id}/#{noteable_type}/12345/discussions", user) + + expect(response).to have_gitlab_http_status(404) + end + + it "returns 404 when not authorized" do + parent.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + + get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", private_user) + + expect(response).to have_gitlab_http_status(404) + end + end + + describe "GET /#{parent_type}/:id/#{noteable_type}/:noteable_id/discussions/:discussion_id" do + it "returns a discussion by id" do + get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions/#{note.discussion_id}", user) + + expect(response).to have_gitlab_http_status(200) + expect(json_response['id']).to eq(note.discussion_id) + expect(json_response['notes'].first['body']).to eq(note.note) + end + + it "returns a 404 error if discussion not found" do + get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions/12345", user) + + expect(response).to have_gitlab_http_status(404) + end + end + + describe "POST /#{parent_type}/:id/#{noteable_type}/:noteable_id/discussions" do + it "creates a new note" do + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", user), body: 'hi!' + + expect(response).to have_gitlab_http_status(201) + expect(json_response['notes'].first['body']).to eq('hi!') + expect(json_response['notes'].first['author']['username']).to eq(user.username) + end + + it "returns a 400 bad request error if body not given" do + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", user) + + expect(response).to have_gitlab_http_status(400) + end + + it "returns a 401 unauthorized error if user not authenticated" do + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions"), body: 'hi!' + + expect(response).to have_gitlab_http_status(401) + end + + context 'when an admin or owner makes the request' do + it 'accepts the creation date to be set' do + creation_time = 2.weeks.ago + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", user), + body: 'hi!', created_at: creation_time + + expect(response).to have_gitlab_http_status(201) + expect(json_response['notes'].first['body']).to eq('hi!') + expect(json_response['notes'].first['author']['username']).to eq(user.username) + expect(Time.parse(json_response['notes'].first['created_at'])).to be_like_time(creation_time) + end + end + + context 'when user does not have access to read the discussion' do + before do + parent.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + end + + it 'responds with 404' do + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", private_user), + body: 'Foo' + + expect(response).to have_gitlab_http_status(404) + end + end + end + + describe "POST /#{parent_type}/:id/#{noteable_type}/:noteable_id/discussions/:discussion_id/notes" do + it 'adds a new note to the discussion' do + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\ + "discussions/#{note.discussion_id}/notes", user), body: 'Hello!' + + expect(response).to have_gitlab_http_status(201) + expect(json_response['body']).to eq('Hello!') + expect(json_response['type']).to eq('DiscussionNote') + end + + it 'returns a 400 bad request error if body not given' do + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\ + "discussions/#{note.discussion_id}/notes", user) + + expect(response).to have_gitlab_http_status(400) + end + + it "returns a 400 bad request error if discussion is individual note" do + note.update_attribute(:type, nil) + + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\ + "discussions/#{note.discussion_id}/notes", user), body: 'hi!' + + expect(response).to have_gitlab_http_status(400) + end + end + + describe "PUT /#{parent_type}/:id/#{noteable_type}/:noteable_id/discussions/:discussion_id/notes/:note_id" do + it 'returns modified note' do + put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\ + "discussions/#{note.discussion_id}/notes/#{note.id}", user), body: 'Hello!' + + expect(response).to have_gitlab_http_status(200) + expect(json_response['body']).to eq('Hello!') + end + + it 'returns a 404 error when note id not found' do + put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\ + "discussions/#{note.discussion_id}/notes/12345", user), + body: 'Hello!' + + expect(response).to have_gitlab_http_status(404) + end + + it 'returns a 400 bad request error if body not given' do + put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\ + "discussions/#{note.discussion_id}/notes/#{note.id}", user) + + expect(response).to have_gitlab_http_status(400) + end + end + + describe "DELETE /#{parent_type}/:id/#{noteable_type}/:noteable_id/discussions/:discussion_id/notes/:note_id" do + it 'deletes a note' do + delete api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\ + "discussions/#{note.discussion_id}/notes/#{note.id}", user) + + expect(response).to have_gitlab_http_status(204) + # Check if note is really deleted + delete api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\ + "discussions/#{note.discussion_id}/notes/#{note.id}", user) + expect(response).to have_gitlab_http_status(404) + end + + it 'returns a 404 error when note id not found' do + delete api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\ + "discussions/#{note.discussion_id}/notes/12345", user) + + expect(response).to have_gitlab_http_status(404) + end + + it_behaves_like '412 response' do + let(:request) do + api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\ + "discussions/#{note.discussion_id}/notes/#{note.id}", user) + end + end + end +end diff --git a/spec/support/shared_examples/requests/api/notes.rb b/spec/support/shared_examples/requests/api/notes.rb new file mode 100644 index 00000000000..79b2196660c --- /dev/null +++ b/spec/support/shared_examples/requests/api/notes.rb @@ -0,0 +1,206 @@ +shared_examples 'noteable API' do |parent_type, noteable_type, id_name| + describe "GET /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes" do + context 'sorting' do + before do + params = { noteable: noteable, author: user } + params[:project] = parent if parent.is_a?(Project) + + create_list(:note, 3, params) + end + + it 'sorts by created_at in descending order by default' do + get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user) + + response_dates = json_response.map { |note| note['created_at'] } + + expect(json_response.length).to eq(4) + expect(response_dates).to eq(response_dates.sort.reverse) + end + + it 'sorts by ascending order when requested' do + get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes?sort=asc", user) + + response_dates = json_response.map { |note| note['created_at'] } + + expect(json_response.length).to eq(4) + expect(response_dates).to eq(response_dates.sort) + end + + it 'sorts by updated_at in descending order when requested' do + get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes?order_by=updated_at", user) + + response_dates = json_response.map { |note| note['updated_at'] } + + expect(json_response.length).to eq(4) + expect(response_dates).to eq(response_dates.sort.reverse) + end + + it 'sorts by updated_at in ascending order when requested' do + get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes?order_by=updated_at&sort=asc", user) + + response_dates = json_response.map { |note| note['updated_at'] } + + expect(json_response.length).to eq(4) + expect(response_dates).to eq(response_dates.sort) + end + end + + it "returns an array of notes" do + get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.first['body']).to eq(note.note) + end + + it "returns a 404 error when noteable id not found" do + get api("/#{parent_type}/#{parent.id}/#{noteable_type}/12345/notes", user) + + expect(response).to have_gitlab_http_status(404) + end + + it "returns 404 when not authorized" do + parent.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + + get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", private_user) + + expect(response).to have_gitlab_http_status(404) + end + end + + describe "GET /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes/:note_id" do + it "returns a note by id" do + get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/#{note.id}", user) + + expect(response).to have_gitlab_http_status(200) + expect(json_response['body']).to eq(note.note) + end + + it "returns a 404 error if note not found" do + get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/12345", user) + + expect(response).to have_gitlab_http_status(404) + end + end + + describe "POST /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes" do + it "creates a new note" do + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), body: 'hi!' + + expect(response).to have_gitlab_http_status(201) + expect(json_response['body']).to eq('hi!') + expect(json_response['author']['username']).to eq(user.username) + end + + it "returns a 400 bad request error if body not given" do + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user) + + expect(response).to have_gitlab_http_status(400) + end + + it "returns a 401 unauthorized error if user not authenticated" do + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes"), body: 'hi!' + + expect(response).to have_gitlab_http_status(401) + end + + it "creates an activity event when a note is created" do + expect(Event).to receive(:create!) + + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), body: 'hi!' + end + + context 'when an admin or owner makes the request' do + it 'accepts the creation date to be set' do + creation_time = 2.weeks.ago + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), + body: 'hi!', created_at: creation_time + + expect(response).to have_gitlab_http_status(201) + expect(json_response['body']).to eq('hi!') + expect(json_response['author']['username']).to eq(user.username) + expect(Time.parse(json_response['created_at'])).to be_like_time(creation_time) + end + end + + context 'when the user is posting an award emoji on a noteable created by someone else' do + it 'creates a new note' do + parent.add_developer(private_user) + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", private_user), body: ':+1:' + + expect(response).to have_gitlab_http_status(201) + expect(json_response['body']).to eq(':+1:') + end + end + + context 'when the user is posting an award emoji on his/her own noteable' do + it 'creates a new note' do + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), body: ':+1:' + + expect(response).to have_gitlab_http_status(201) + expect(json_response['body']).to eq(':+1:') + end + end + + context 'when user does not have access to read the noteable' do + before do + parent.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + end + + it 'responds with 404' do + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", private_user), + body: 'Foo' + + expect(response).to have_gitlab_http_status(404) + end + end + end + + describe "PUT /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes/:note_id" do + it 'returns modified note' do + put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\ + "notes/#{note.id}", user), body: 'Hello!' + + expect(response).to have_gitlab_http_status(200) + expect(json_response['body']).to eq('Hello!') + end + + it 'returns a 404 error when note id not found' do + put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/12345", user), + body: 'Hello!' + + expect(response).to have_gitlab_http_status(404) + end + + it 'returns a 400 bad request error if body not given' do + put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\ + "notes/#{note.id}", user) + + expect(response).to have_gitlab_http_status(400) + end + end + + describe "DELETE /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes/:note_id" do + it 'deletes a note' do + delete api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\ + "notes/#{note.id}", user) + + expect(response).to have_gitlab_http_status(204) + # Check if note is really deleted + delete api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\ + "notes/#{note.id}", user) + expect(response).to have_gitlab_http_status(404) + end + + it 'returns a 404 error when note id not found' do + delete api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/12345", user) + + expect(response).to have_gitlab_http_status(404) + end + + it_behaves_like '412 response' do + let(:request) { api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/#{note.id}", user) } + end + end +end diff --git a/spec/views/projects/tree/show.html.haml_spec.rb b/spec/views/projects/tree/show.html.haml_spec.rb index 44b32df0395..3b098320ad7 100644 --- a/spec/views/projects/tree/show.html.haml_spec.rb +++ b/spec/views/projects/tree/show.html.haml_spec.rb @@ -13,6 +13,7 @@ describe 'projects/tree/show' do allow(view).to receive(:can?).and_return(true) allow(view).to receive(:can_collaborate_with_project?).and_return(true) + allow(view).to receive_message_chain('user_access.can_push_to_branch?').and_return(true) allow(view).to receive(:current_application_settings).and_return(Gitlab::CurrentSettings.current_application_settings) end diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb index 5d9b0679796..cd6661f09a1 100644 --- a/spec/workers/post_receive_spec.rb +++ b/spec/workers/post_receive_spec.rb @@ -114,6 +114,18 @@ describe PostReceive do end end + describe '#process_wiki_changes' do + let(:gl_repository) { "wiki-#{project.id}" } + + it 'updates project activity' do + described_class.new.perform(gl_repository, key_id, base64_changes) + + expect { project.reload } + .to change(project, :last_activity_at) + .and change(project, :last_repository_updated_at) + end + end + context "webhook" do it "fetches the correct project" do expect(Project).to receive(:find_by).with(id: project.id.to_s) |