diff options
470 files changed, 6665 insertions, 2468 deletions
diff --git a/.gitlab/ci/review.gitlab-ci.yml b/.gitlab/ci/review.gitlab-ci.yml index 80356fa1dc2..f5ed3e1ad9a 100644 --- a/.gitlab/ci/review.gitlab-ci.yml +++ b/.gitlab/ci/review.gitlab-ci.yml @@ -165,7 +165,10 @@ review-qa-all: <<: *review-qa-base allow_failure: true when: manual + parallel: 5 script: + - export KNAPSACK_REPORT_PATH=knapsack/${CI_PROJECT_NAME}/review-qa-all_master_report.json + - export KNAPSACK_TEST_FILE_PATTERN=qa/specs/features/**/*_spec.rb - gitlab-qa Test::Instance::Any "${QA_IMAGE}" "${CI_ENVIRONMENT_URL}" .review-performance-base: &review-performance-base diff --git a/.gitlab/issue_templates/Feature Flag Roll Out.md b/.gitlab/issue_templates/Feature Flag Roll Out.md new file mode 100644 index 00000000000..b7db5a33faf --- /dev/null +++ b/.gitlab/issue_templates/Feature Flag Roll Out.md @@ -0,0 +1,43 @@ +<!-- Title suggestion: [Feature flag] Enable description of feature --> + +## What + +Remove the `:feature_name` feature flag ... + +## Owners + +- Team: NAME_OF_TEAM +- Most appropriate slack channel to reach out to: `#g_TEAM_NAME` +- Best individual to reach out to: NAME + +## Expectations + +### What are we expecting to happen? + +### What might happen if this goes wrong? + +### What can we monitor to detect problems with this? + +<!-- Which dashboards from https://dashboards.gitlab.net are most relevant? --> + +## Beta groups/projects + +If applicable, any groups/projects that are happy to have this feature turned on early. Some organizations may wish to test big changes they are interested in with a small subset of users ahead of time for example. + +- `gitlab-org/gitlab-ce`/`gitlab-org/gitlab-ee` projects +- `gitlab-org`/`gitlab-com` groups +- ... + +## Roll Out Steps + +- [ ] Enable on staging +- [ ] Test on staging +- [ ] Ensure that documentation has been updated +- [ ] Enable on GitLab.com for individual groups/projects listed above and verify behaviour +- [ ] Announce on the issue an estimated time this will be enabled on GitLab.com +- [ ] Enable on GitLab.com by running chatops command in `#production` +- [ ] Cross post chatops slack command to `#support_gitlab-com` and in your team channel +- [ ] Announce on the issue that the flag has been enabled +- [ ] Remove feature flag and add changelog entry + +/label ~"feature flag" diff --git a/CHANGELOG.md b/CHANGELOG.md index 88521222b8a..fd9c4df2f3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 11.11.2 (2019-06-04) + +### Fixed (7 changes) + +- Update SAST.gitlab-ci.yml - Add SAST_GITLEAKS_ENTROPY_LEVEL. !28607 +- Fix OmniAuth OAuth2Generic strategy not loading. !28680 +- Use source ref in pipeline webhook. !28772 +- Fix migration failure when groups are missing route. !29022 +- Stop two-step rebase from hanging when errors occur. !29068 +- Fix project settings not being able to update. !29097 +- Fix display of 'Promote to group label' button. + +### Other (1 change) + +- Fix input group height. + + ## 11.11.0 (2019-05-22) ### Security (1 change) @@ -187,6 +204,23 @@ entry. - Add some frozen string to spec/**/*.rb. (gfyoung) +## 11.10.6 (2019-06-04) + +### Fixed (7 changes, 1 of them is from the community) + +- Allow a member to have an access level equal to parent group. !27913 +- Fix uploading of LFS tracked file through UI. !28052 +- Use 3-way merge for squashing commits. !28078 +- Use a path for the related merge requests endpoint. !28171 +- Fix project visibility level validation. !28305 (Peter Marko) +- Fix Rugged get_tree_entries recursive flag not working. !28494 +- Use source ref in pipeline webhook. !28772 + +### Other (1 change) + +- Fix input group height. + + ## 11.10.4 (2019-05-01) ### Fixed (12 changes) @@ -478,6 +512,24 @@ entry. - Removes EE differences for environment_item.vue. +## 11.9.12 (2019-05-30) + +### Security (12 changes, 1 of them is from the community) + +- Protect Gitlab::HTTP against DNS rebinding attack. +- Fix project visibility level validation. (Peter Marko) +- Update Knative version. +- Add DNS rebinding protection settings. +- Prevent XSS injection in note imports. +- Prevent invalid branch for merge request. +- Filter relative links in wiki for XSS. +- Fix confidential issue label disclosure on milestone view. +- Fix url redaction for issue links. +- Resolve: Milestones leaked via search API. +- Prevent bypass of restriction disabling web password sign in. +- Hide confidential issue title on unsubscribe for anonymous users. + + ## 11.9.10 (2019-04-26) ### Security (5 changes) diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index e640847f99c..50aceaa7b71 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -1.42.1 +1.45.0 diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION index bc80560fad6..dc1e644a101 100644 --- a/GITLAB_PAGES_VERSION +++ b/GITLAB_PAGES_VERSION @@ -1 +1 @@ -1.5.0 +1.6.0 @@ -403,6 +403,7 @@ gem 'ruby-prof', '~> 0.17.0' gem 'rbtrace', '~> 0.4', require: false gem 'memory_profiler', '~> 0.9', require: false gem 'benchmark-memory', '~> 0.1', require: false +gem 'activerecord-explain-analyze', '~> 0.1', require: false # OAuth gem 'oauth2', '~> 1.4' diff --git a/Gemfile.lock b/Gemfile.lock index 3f85fa958bd..3b37cec3b7f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -38,6 +38,9 @@ GEM activemodel (= 5.1.7) activesupport (= 5.1.7) arel (~> 8.0) + activerecord-explain-analyze (0.1.0) + activerecord (>= 4) + pg activerecord_sane_schema_dumper (1.0) rails (>= 5, < 6) activesupport (5.1.7) @@ -139,9 +142,9 @@ GEM concord (0.1.5) adamantium (~> 0.2.0) equalizer (~> 0.0.9) - concurrent-ruby (1.1.3) - concurrent-ruby-ext (1.1.3) - concurrent-ruby (= 1.1.3) + concurrent-ruby (1.1.5) + concurrent-ruby-ext (1.1.5) + concurrent-ruby (= 1.1.5) connection_pool (2.2.2) crack (0.4.3) safe_yaml (~> 1.0.0) @@ -1012,6 +1015,7 @@ DEPENDENCIES RedCloth (~> 4.3.2) ace-rails-ap (~> 4.1.0) acme-client (~> 2.0.2) + activerecord-explain-analyze (~> 0.1) activerecord_sane_schema_dumper (= 1.0) acts-as-taggable-on (~> 6.0) addressable (~> 2.5.2) @@ -1 +1 @@ -11.12.0-pre +12.0.0-pre diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index e583a8affd4..7cebb88f3a4 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -12,7 +12,7 @@ const Api = { groupProjectsPath: '/api/:version/groups/:id/projects.json', projectsPath: '/api/:version/projects.json', projectPath: '/api/:version/projects/:id', - projectLabelsPath: '/:namespace_path/:project_path/labels', + projectLabelsPath: '/:namespace_path/:project_path/-/labels', projectMergeRequestsPath: '/api/:version/projects/:id/merge_requests', projectMergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid', projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes', diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index 670f66b005e..c8eb96a625c 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -37,7 +37,7 @@ export default class ShortcutsIssuable extends Shortcuts { } // Sanity check: Make sure the selected text comes from a discussion : it can either contain a message... - let foundMessage = !!documentFragment.querySelector('.md'); + let foundMessage = Boolean(documentFragment.querySelector('.md')); // ... Or come from a message if (!foundMessage) { diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index c9972d051aa..b1a8b13f3ac 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -142,8 +142,10 @@ export default { const card = this.$refs.issue[e.oldIndex]; card.showDetail = false; - boardsStore.moving.list = card.list; - boardsStore.moving.issue = boardsStore.moving.list.findIssue(+e.item.dataset.issueId); + + const { list } = card; + const issue = list.findIssue(Number(e.item.dataset.issueId)); + boardsStore.startMoving(list, issue); sortableStart(); }, diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index dc1bdc23b5e..63dc99db086 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -72,7 +72,7 @@ export default { // Need this because our jQuery very kindly disables buttons on ALL form submissions $(this.$refs.submitButton).enable(); - boardsStore.detail.issue = issue; + boardsStore.setIssueDetail(issue); boardsStore.detail.list = this.list; }) .catch(() => { diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 17de7b2cf1e..a8516f178fc 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -6,7 +6,6 @@ import Icon from '~/vue_shared/components/icon.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; -import eventHub from '../eventhub'; import IssueDueDate from './issue_due_date.vue'; import IssueTimeEstimate from './issue_time_estimate.vue'; import boardsStore from '../stores/boards_store'; @@ -136,23 +135,7 @@ export default { const labelTitle = encodeURIComponent(label.title); const filter = `label_name[]=${labelTitle}`; - this.applyFilter(filter); - }, - applyFilter(filter) { - const filterPath = boardsStore.filter.path.split('&'); - const filterIndex = filterPath.indexOf(filter); - - if (filterIndex === -1) { - filterPath.push(filter); - } else { - filterPath.splice(filterIndex, 1); - } - - boardsStore.filter.path = filterPath.join('&'); - - boardsStore.updateFiltersUrl(); - - eventHub.$emit('updateTokens'); + boardsStore.toggleFilter(filter); }, labelStyle(label) { return { diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue index 8e09e265cfb..defa1f75ba2 100644 --- a/app/assets/javascripts/boards/components/modal/index.vue +++ b/app/assets/javascripts/boards/components/modal/index.vue @@ -124,7 +124,7 @@ export default { data.issues.forEach(issueObj => { const issue = new ListIssue(issueObj); const foundSelectedIssue = ModalStore.findSelectedIssue(issue); - issue.selected = !!foundSelectedIssue; + issue.selected = Boolean(foundSelectedIssue); this.issues.push(issue); }); diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index bc6a3cf212e..e9cab3e3bba 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -168,7 +168,7 @@ export default () => { }); } - boardsStore.detail.issue = newIssue; + boardsStore.setIssueDetail(newIssue); }, clearDetailIssue() { boardsStore.clearDetailIssue(); diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 7e5d0e0f888..08aecfab8a4 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -37,8 +37,8 @@ class List { this.type = obj.list_type; const typeInfo = this.getTypeInfo(this.type); - this.preset = !!typeInfo.isPreset; - this.isExpandable = !!typeInfo.isExpandable; + this.preset = Boolean(typeInfo.isPreset); + this.isExpandable = Boolean(typeInfo.isExpandable); this.isExpanded = true; this.page = 1; this.loading = true; diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 51565c597e6..da82b52330a 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,7 +1,5 @@ -import { __ } from '~/locale'; - const notImplemented = () => { - throw new Error(__('Not implemented!')); + throw new Error('Not implemented!'); }; export default { diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 05efcbaa3cc..f72ab189015 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -8,6 +8,7 @@ import Cookies from 'js-cookie'; import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee'; import { getUrlParamsArray, parseBoolean } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; +import eventHub from '../eventhub'; const boardsStore = { disabled: false, @@ -109,6 +110,11 @@ const boardsStore = { }); listFrom.update(); }, + + startMoving(list, issue) { + Object.assign(this.moving, { list, issue }); + }, + moveIssueToList(listFrom, listTo, issue, newIndex) { const issueTo = listTo.findIssue(issue.id); const issueLists = issue.getLists(); @@ -183,12 +189,34 @@ const boardsStore = { findListByLabelId(id) { return this.state.lists.find(list => list.type === 'label' && list.label.id === id); }, + + toggleFilter(filter) { + const filterPath = this.filter.path.split('&'); + const filterIndex = filterPath.indexOf(filter); + + if (filterIndex === -1) { + filterPath.push(filter); + } else { + filterPath.splice(filterIndex, 1); + } + + this.filter.path = filterPath.join('&'); + + this.updateFiltersUrl(); + + eventHub.$emit('updateTokens'); + }, + updateFiltersUrl() { window.history.pushState(null, null, `?${this.filter.path}`); }, clearDetailIssue() { - this.detail.issue = {}; + this.setIssueDetail({}); + }, + + setIssueDetail(issueDetail) { + this.detail.issue = issueDetail; }, }; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 8e61b93e824..77ba68be07e 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -1,8 +1,7 @@ import * as mutationTypes from './mutation_types'; -import { __ } from '~/locale'; const notImplemented = () => { - throw new Error(__('Not implemented!')); + throw new Error('Not implemented!'); }; export default { diff --git a/app/assets/javascripts/branches/branches_delete_modal.js b/app/assets/javascripts/branches/branches_delete_modal.js index f34496f84c6..f4c3fa185d8 100644 --- a/app/assets/javascripts/branches/branches_delete_modal.js +++ b/app/assets/javascripts/branches/branches_delete_modal.js @@ -23,7 +23,7 @@ class DeleteModal { const branchData = e.currentTarget.dataset; this.branchName = branchData.branchName || ''; this.deletePath = branchData.deletePath || ''; - this.isMerged = !!branchData.isMerged; + this.isMerged = Boolean(branchData.isMerged); this.updateModal(); } diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 70af333a0dd..bc2e71b99f2 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -353,8 +353,10 @@ export default class Clusters { saveKnativeDomain(data) { const appId = data.id; - this.store.updateAppProperty(appId, 'status', APPLICATION_STATUS.UPDATING); - this.service.updateApplication(appId, data.params); + this.store.updateApplication(appId); + this.service.updateApplication(appId, data.params).catch(() => { + this.store.notifyUpdateFailure(appId); + }); } setKnativeHostname(data) { diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 5f7675bb432..7b173be599a 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -89,6 +89,10 @@ export default { type: Boolean, required: false, }, + updateable: { + type: Boolean, + default: true, + }, updateSuccessful: { type: Boolean, required: false, @@ -138,7 +142,7 @@ export default { ); }, hasLogo() { - return !!this.logoUrl; + return Boolean(this.logoUrl); }, identiconId() { // generate a deterministic integer id for the identicon background @@ -326,36 +330,38 @@ export default { </ul> </div> - <div - v-if="shouldShowUpgradeDetails" - class="form-text text-muted label p-0 js-cluster-application-upgrade-details" - > - {{ versionLabel }} - <span v-if="updateSuccessful">to</span> - - <gl-link - v-if="updateSuccessful" - :href="chartRepo" - target="_blank" - class="js-cluster-application-upgrade-version" - >chart v{{ version }}</gl-link + <div v-if="updateable"> + <div + v-if="shouldShowUpgradeDetails" + class="form-text text-muted label p-0 js-cluster-application-upgrade-details" > - </div> + {{ versionLabel }} + <span v-if="updateSuccessful">to</span> - <div - v-if="updateFailed && !isUpgrading" - class="bs-callout bs-callout-danger cluster-application-banner mt-2 mb-0 js-cluster-application-upgrade-failure-message" - > - {{ upgradeFailureDescription }} + <gl-link + v-if="updateSuccessful" + :href="chartRepo" + target="_blank" + class="js-cluster-application-upgrade-version" + >chart v{{ version }}</gl-link + > + </div> + + <div + v-if="updateFailed && !isUpgrading" + class="bs-callout bs-callout-danger cluster-application-banner mt-2 mb-0 js-cluster-application-upgrade-failure-message" + > + {{ upgradeFailureDescription }} + </div> + <loading-button + v-if="upgradeAvailable || updateFailed || isUpgrading" + class="btn btn-primary js-cluster-application-upgrade-button mt-2" + :loading="isUpgrading" + :disabled="isUpgrading" + :label="upgradeButtonLabel" + @click="upgradeClicked" + /> </div> - <loading-button - v-if="upgradeAvailable || updateFailed || isUpgrading" - class="btn btn-primary js-cluster-application-upgrade-button mt-2" - :loading="isUpgrading" - :disabled="isUpgrading" - :label="upgradeButtonLabel" - @click="upgradeClicked" - /> </div> <div :class="{ 'section-25': showManageButton, 'section-15': !showManageButton }" diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 73760da9b98..2d129245d37 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -15,6 +15,7 @@ import prometheusLogo from 'images/cluster_app_logos/prometheus.png'; import { s__, sprintf } from '../../locale'; import applicationRow from './application_row.vue'; import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; +import KnativeDomainEditor from './knative_domain_editor.vue'; import { CLUSTER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; import eventHub from '~/clusters/event_hub'; @@ -25,6 +26,7 @@ export default { clipboardButton, LoadingButton, GlLoadingIcon, + KnativeDomainEditor, }, props: { type: { @@ -154,64 +156,21 @@ export default { knative() { return this.applications.knative; }, - knativeInstalled() { - return ( - this.knative.status === APPLICATION_STATUS.INSTALLED || - this.knativeUpgrading || - this.knativeUpgradeFailed || - this.knative.status === APPLICATION_STATUS.UPDATED - ); - }, - knativeUpgrading() { - return ( - this.knative.status === APPLICATION_STATUS.UPDATING || - this.knative.status === APPLICATION_STATUS.SCHEDULED - ); - }, - knativeUpgradeFailed() { - return this.knative.status === APPLICATION_STATUS.UPDATE_ERRORED; - }, - knativeExternalEndpoint() { - return this.knative.externalIp || this.knative.externalHostname; - }, - knativeDescription() { - return sprintf( - _.escape( - s__( - `ClusterIntegration|Installing Knative may incur additional costs. Learn more about %{pricingLink}.`, - ), - ), - { - pricingLink: `<strong><a href="https://cloud.google.com/compute/pricing#lb" - target="_blank" rel="noopener noreferrer"> - ${_.escape(s__('ClusterIntegration|pricing'))}</a></strong>`, - }, - false, - ); - }, - canUpdateKnativeEndpoint() { - return this.knativeExternalEndpoint && !this.knativeUpgradeFailed && !this.knativeUpgrading; - }, - knativeHostname: { - get() { - return this.knative.hostname; - }, - set(hostname) { - eventHub.$emit('setKnativeHostname', { - id: 'knative', - hostname, - }); - }, - }, }, created() { this.helmInstallIllustration = helmInstallIllustration; }, methods: { - saveKnativeDomain() { + saveKnativeDomain(hostname) { eventHub.$emit('saveKnativeDomain', { id: 'knative', - params: { hostname: this.knative.hostname }, + params: { hostname }, + }); + }, + setKnativeHostname(hostname) { + eventHub.$emit('setKnativeHostname', { + id: 'knative', + hostname, }); }, }, @@ -318,9 +277,9 @@ export default { generated endpoint in order to access your application after it has been deployed.`) }} - <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{ - __('More information') - }}</a> + <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> + {{ __('More information') }} + </a> </p> </div> @@ -330,9 +289,9 @@ export default { the process of being assigned. Please check your Kubernetes cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) }} - <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{ - __('More information') - }}</a> + <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> + {{ __('More information') }} + </a> </p> </template> <template v-if="!ingressInstalled"> @@ -361,9 +320,9 @@ export default { <div slot="description"> <p v-html="certManagerDescription"></p> <div class="form-group"> - <label for="cert-manager-issuer-email">{{ - s__('ClusterIntegration|Issuer Email') - }}</label> + <label for="cert-manager-issuer-email"> + {{ s__('ClusterIntegration|Issuer Email') }} + </label> <div class="input-group"> <input v-model="applications.cert_manager.email" @@ -491,9 +450,9 @@ export default { s__(`ClusterIntegration|Replace this with your own hostname if you want. If you do so, point hostname to Ingress IP Address from above.`) }} - <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{ - __('More information') - }}</a> + <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> + {{ __('More information') }} + </a> </p> </div> </template> @@ -514,6 +473,7 @@ export default { :uninstallable="applications.knative.uninstallable" :uninstall-successful="applications.knative.uninstallSuccessful" :uninstall-failed="applications.knative.uninstallFailed" + :updateable="false" :disabled="!helmInstalled" v-bind="applications.knative" title-link="https://github.com/knative/docs" @@ -525,9 +485,9 @@ export default { s__(`ClusterIntegration|You must have an RBAC-enabled cluster to install Knative.`) }} - <a :href="helpPath" target="_blank" rel="noopener noreferrer">{{ - __('More information') - }}</a> + <a :href="helpPath" target="_blank" rel="noopener noreferrer"> + {{ __('More information') }} + </a> </p> <br /> </span> @@ -540,83 +500,13 @@ export default { }} </p> - <div class="row"> - <template v-if="knativeInstalled || (helmInstalled && rbac)"> - <div - :class="{ 'col-md-6': knativeInstalled, 'col-12': helmInstalled && rbac }" - class="form-group col-sm-12 mb-0" - > - <label for="knative-domainname"> - <strong>{{ s__('ClusterIntegration|Knative Domain Name:') }}</strong> - </label> - <input - id="knative-domainname" - v-model="knativeHostname" - type="text" - class="form-control js-knative-domainname" - /> - </div> - </template> - <template v-if="knativeInstalled"> - <div class="form-group col-sm-12 col-md-6 pl-md-0 mb-0 mt-3 mt-md-0"> - <label for="knative-endpoint"> - <strong>{{ s__('ClusterIntegration|Knative Endpoint:') }}</strong> - </label> - <div v-if="knativeExternalEndpoint" class="input-group"> - <input - id="knative-endpoint" - :value="knativeExternalEndpoint" - type="text" - class="form-control js-knative-endpoint" - readonly - /> - <span class="input-group-append"> - <clipboard-button - :text="knativeExternalEndpoint" - :title="s__('ClusterIntegration|Copy Knative Endpoint to clipboard')" - class="input-group-text js-knative-endpoint-clipboard-btn" - /> - </span> - </div> - <div v-else class="input-group"> - <input type="text" class="form-control js-endpoint" readonly /> - <gl-loading-icon - class="position-absolute align-self-center ml-2 js-knative-ip-loading-icon" - /> - </div> - </div> - - <p class="form-text text-muted col-12"> - {{ - s__( - `ClusterIntegration|To access your application after deployment, point a wildcard DNS to the Knative Endpoint.`, - ) - }} - <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{ - __('More information') - }}</a> - </p> - - <p - v-if="!knativeExternalEndpoint" - class="settings-message js-no-knative-endpoint-message mt-2 mr-3 mb-0 ml-3" - > - {{ - s__(`ClusterIntegration|The endpoint is in - the process of being assigned. Please check your Kubernetes - cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) - }} - </p> - - <button - v-if="canUpdateKnativeEndpoint" - class="btn btn-success js-knative-save-domain-button mt-3 ml-3" - @click="saveKnativeDomain" - > - {{ s__('ClusterIntegration|Save changes') }} - </button> - </template> - </div> + <knative-domain-editor + v-if="knative.installed || (helmInstalled && rbac)" + :knative="knative" + :ingress-dns-help-path="ingressDnsHelpPath" + @save="saveKnativeDomain" + @set="setKnativeHostname" + /> </div> </application-row> </div> diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue new file mode 100644 index 00000000000..480228619a5 --- /dev/null +++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue @@ -0,0 +1,150 @@ +<script> +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +import { APPLICATION_STATUS } from '~/clusters/constants'; + +const { UPDATING, UNINSTALLING } = APPLICATION_STATUS; + +export default { + components: { + LoadingButton, + ClipboardButton, + GlLoadingIcon, + }, + props: { + knative: { + type: Object, + required: true, + }, + ingressDnsHelpPath: { + type: String, + default: '', + }, + }, + computed: { + saveButtonDisabled() { + return [UNINSTALLING, UPDATING].includes(this.knative.status); + }, + saving() { + return [UPDATING].includes(this.knative.status); + }, + saveButtonLabel() { + return this.saving ? this.__('Saving') : this.__('Save changes'); + }, + knativeInstalled() { + return this.knative.installed; + }, + knativeExternalEndpoint() { + return this.knative.externalIp || this.knative.externalHostname; + }, + knativeUpdateSuccessful() { + return this.knative.updateSuccessful; + }, + knativeHostname: { + get() { + return this.knative.hostname; + }, + set(hostname) { + this.$emit('set', hostname); + }, + }, + }, + watch: { + knativeUpdateSuccessful(updateSuccessful) { + if (updateSuccessful) { + this.$toast.show(s__('ClusterIntegration|Knative domain name was updated successfully.')); + } + }, + }, +}; +</script> + +<template> + <div class="row"> + <div + v-if="knative.updateFailed" + class="bs-callout bs-callout-danger cluster-application-banner col-12 mt-2 mb-2 js-cluster-knative-domain-name-failure-message" + > + {{ s__('ClusterIntegration|Something went wrong while updating Knative domain name.') }} + </div> + + <template> + <div + :class="{ 'col-md-6': knativeInstalled, 'col-12': !knativeInstalled }" + class="form-group col-sm-12 mb-0" + > + <label for="knative-domainname"> + <strong>{{ s__('ClusterIntegration|Knative Domain Name:') }}</strong> + </label> + <input + id="knative-domainname" + v-model="knativeHostname" + type="text" + class="form-control js-knative-domainname" + /> + </div> + </template> + <template v-if="knativeInstalled"> + <div class="form-group col-sm-12 col-md-6 pl-md-0 mb-0 mt-3 mt-md-0"> + <label for="knative-endpoint"> + <strong>{{ s__('ClusterIntegration|Knative Endpoint:') }}</strong> + </label> + <div v-if="knativeExternalEndpoint" class="input-group"> + <input + id="knative-endpoint" + :value="knativeExternalEndpoint" + type="text" + class="form-control js-knative-endpoint" + readonly + /> + <span class="input-group-append"> + <clipboard-button + :text="knativeExternalEndpoint" + :title="s__('ClusterIntegration|Copy Knative Endpoint to clipboard')" + class="input-group-text js-knative-endpoint-clipboard-btn" + /> + </span> + </div> + <div v-else class="input-group"> + <input type="text" class="form-control js-endpoint" readonly /> + <gl-loading-icon + class="position-absolute align-self-center ml-2 js-knative-ip-loading-icon" + /> + </div> + </div> + + <p class="form-text text-muted col-12"> + {{ + s__( + `ClusterIntegration|To access your application after deployment, point a wildcard DNS to the Knative Endpoint.`, + ) + }} + <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> + {{ __('More information') }} + </a> + </p> + + <p + v-if="!knativeExternalEndpoint" + class="settings-message js-no-knative-endpoint-message mt-2 mr-3 mb-0 ml-3" + > + {{ + s__(`ClusterIntegration|The endpoint is in + the process of being assigned. Please check your Kubernetes + cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) + }} + </p> + + <loading-button + class="btn-success js-knative-save-domain-button mt-3 ml-3" + :loading="saving" + :disabled="saveButtonDisabled" + :label="saveButtonLabel" + @click="$emit('save', knativeHostname)" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 1b4d7e8372c..89e61c10a46 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -77,6 +77,8 @@ export default class ClusterStore { isEditingHostName: false, externalIp: null, externalHostname: null, + updateSuccessful: false, + updateFailed: false, }, }, }; diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js index 37a3ceb5341..5bfe158ceda 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/compare_autocomplete.js @@ -40,7 +40,7 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = ( }, selectable: true, filterable: true, - filterRemote: !!$dropdown.data('refsUrl'), + filterRemote: Boolean($dropdown.data('refsUrl')), fieldName: $dropdown.data('fieldName'), filterInput: 'input[type="search"]', renderRow: function(ref) { diff --git a/app/assets/javascripts/create_item_dropdown.js b/app/assets/javascripts/create_item_dropdown.js index 916b190f469..fa0f04c7d82 100644 --- a/app/assets/javascripts/create_item_dropdown.js +++ b/app/assets/javascripts/create_item_dropdown.js @@ -12,7 +12,7 @@ export default class CreateItemDropdown { this.fieldName = options.fieldName; this.onSelect = options.onSelect || (() => {}); this.getDataOption = options.getData; - this.getDataRemote = !!options.filterRemote; + this.getDataRemote = Boolean(options.filterRemote); this.createNewItemFromValueOption = options.createNewItemFromValue; this.$dropdown = options.$dropdown; this.$dropdownContainer = this.$dropdown.parent(); diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index a767379d662..bd7259ce3ee 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -69,7 +69,7 @@ export default { :link-href="authorUrl" :img-src="authorAvatar" :img-alt="authorName" - :img-size="36" + :img-size="40" class="avatar-cell d-none d-sm-block" /> <div class="commit-detail flex-list"> diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index d26b58d461a..eb9f1465945 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -254,16 +254,17 @@ export default { <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" /> <div class="btn-group" role="group"> <template v-if="diffFile.blob && diffFile.blob.readable_text"> - <button - :disabled="!diffHasDiscussions(diffFile)" - :class="{ active: hasExpandedDiscussions }" - :title="s__('MergeRequests|Toggle comments for this file')" - class="js-btn-vue-toggle-comments btn" - type="button" - @click="handleToggleDiscussions" - > - <icon name="comment" /> - </button> + <span v-gl-tooltip.hover :title="s__('MergeRequests|Toggle comments for this file')"> + <gl-button + :disabled="!diffHasDiscussions(diffFile)" + :class="{ active: hasExpandedDiscussions }" + class="js-btn-vue-toggle-comments btn" + type="button" + @click="handleToggleDiscussions" + > + <icon name="comment" /> + </gl-button> + </span> <edit-button v-if="!diffFile.deleted_file" diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue index 0c0a0faa59d..7cf3d90d468 100644 --- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue +++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue @@ -86,7 +86,6 @@ export default { :key="note.id" :img-src="note.author.avatar_url" :tooltip-text="getTooltipText(note)" - :size="19" class="diff-comment-avatar js-diff-comment-avatar" @click.native="toggleDiscussions" /> diff --git a/app/assets/javascripts/diffs/components/edit_button.vue b/app/assets/javascripts/diffs/components/edit_button.vue index f0cc5de4b33..dcb79cd5e16 100644 --- a/app/assets/javascripts/diffs/components/edit_button.vue +++ b/app/assets/javascripts/diffs/components/edit_button.vue @@ -38,7 +38,7 @@ export default { <template> <gl-button - v-gl-tooltip.bottom + v-gl-tooltip.top :href="editPath" :title="__('Edit file')" class="js-edit-blob" diff --git a/app/assets/javascripts/error_tracking_settings/index.js b/app/assets/javascripts/error_tracking_settings/index.js index e39452353f5..ce315963723 100644 --- a/app/assets/javascripts/error_tracking_settings/index.js +++ b/app/assets/javascripts/error_tracking_settings/index.js @@ -1,10 +1,8 @@ import Vue from 'vue'; import ErrorTrackingSettings from './components/app.vue'; import createStore from './store'; -import initSettingsPanels from '~/settings_panels'; export default () => { - initSettingsPanels(); const formContainerEl = document.querySelector('.js-error-tracking-form'); const { dataset: { apiHost, enabled, project, token, listProjectsEndpoint, operationsSettingsEndpoint }, diff --git a/app/assets/javascripts/error_tracking_settings/store/getters.js b/app/assets/javascripts/error_tracking_settings/store/getters.js index a008b181907..d77e5f15469 100644 --- a/app/assets/javascripts/error_tracking_settings/store/getters.js +++ b/app/assets/javascripts/error_tracking_settings/store/getters.js @@ -2,10 +2,10 @@ import _ from 'underscore'; import { __, s__, sprintf } from '~/locale'; import { getDisplayName } from '../utils'; -export const hasProjects = state => !!state.projects && state.projects.length > 0; +export const hasProjects = state => Boolean(state.projects) && state.projects.length > 0; export const isProjectInvalid = (state, getters) => - !!state.selectedProject && + Boolean(state.selectedProject) && getters.hasProjects && !state.projects.some(project => _.isMatch(state.selectedProject, project)); diff --git a/app/assets/javascripts/frequent_items/store/actions.js b/app/assets/javascripts/frequent_items/store/actions.js index 3dd89a82a42..ba62ab67e50 100644 --- a/app/assets/javascripts/frequent_items/store/actions.js +++ b/app/assets/javascripts/frequent_items/store/actions.js @@ -51,7 +51,7 @@ export const fetchSearchedItems = ({ state, dispatch }, searchQuery) => { const params = { simple: true, per_page: 20, - membership: !!gon.current_user_id, + membership: Boolean(gon.current_user_id), }; if (state.namespace === 'projects') { diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index f437954881c..0af9aabd8cf 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import 'at.js'; import _ from 'underscore'; import glRegexp from './lib/utils/regexp'; import AjaxCache from './lib/utils/ajax_cache'; diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index a143d79097b..18fa6265108 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -307,8 +307,8 @@ GitLabDropdown = (function() { // Set Defaults this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT); this.noFilterInput = this.options.noFilterInput || this.getElement(NO_FILTER_INPUT); - this.highlight = !!this.options.highlight; - this.icon = !!this.options.icon; + this.highlight = Boolean(this.options.highlight); + this.icon = Boolean(this.options.icon); this.filterInputBlur = this.options.filterInputBlur != null ? this.options.filterInputBlur : true; // If no input is passed create a default one diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 5a6d44ef838..a66555838ba 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -13,7 +13,7 @@ export default class GLForm { const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {}; Object.keys(this.enableGFM).forEach(item => { if (item !== 'emojis') { - this.enableGFM[item] = !!dataSources[item]; + this.enableGFM[item] = Boolean(dataSources[item]); } }); // Before we start, we should clean up any previous data for this form diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue index c98dda00817..6999746f115 100644 --- a/app/assets/javascripts/ide/components/preview/clientside.vue +++ b/app/assets/javascripts/ide/components/preview/clientside.vue @@ -105,7 +105,7 @@ export default { .then(() => { this.initManager('#ide-preview', this.sandboxOpts, { fileResolver: { - isFile: p => Promise.resolve(!!this.entries[createPathWithExt(p)]), + isFile: p => Promise.resolve(Boolean(this.entries[createPathWithExt(p)])), readFile: p => this.loadFileContent(createPathWithExt(p)).then(content => content), }, }); diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 99f1d4a573d..5201c33b1b4 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -30,7 +30,7 @@ export default { ...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommittedChanges', 'activeFile']), ...mapGetters('commit', ['discardDraftButtonDisabled']), showStageUnstageArea() { - return !!(this.someUncommittedChanges || this.lastCommitMsg || !this.unusedSeal); + return Boolean(this.someUncommittedChanges || this.lastCommitMsg || !this.unusedSeal); }, activeFileKey() { return this.activeFile ? this.activeFile.key : null; diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js index e35595ab1fd..dac2a8e8b51 100644 --- a/app/assets/javascripts/ide/lib/editor_options.js +++ b/app/assets/javascripts/ide/lib/editor_options.js @@ -11,7 +11,7 @@ export const defaultEditorOptions = { export default [ { - readOnly: model => !!model.file.file_lock, + readOnly: model => Boolean(model.file.file_lock), quickSuggestions: model => !(model.language === 'markdown'), }, ]; diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 7a88ac5b116..5a736805fdc 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -42,9 +42,10 @@ export const emptyRepo = state => export const currentTree = state => state.trees[`${state.currentProjectId}/${state.currentBranchId}`]; -export const hasChanges = state => !!state.changedFiles.length || !!state.stagedFiles.length; +export const hasChanges = state => + Boolean(state.changedFiles.length) || Boolean(state.stagedFiles.length); -export const hasMergeRequest = state => !!state.currentMergeRequestId; +export const hasMergeRequest = state => Boolean(state.currentMergeRequestId); export const allBlobs = state => Object.keys(state.entries) @@ -70,7 +71,7 @@ export const isCommitModeActive = state => state.currentActivityView === activit export const isReviewModeActive = state => state.currentActivityView === activityBarViews.review; export const someUncommittedChanges = state => - !!(state.changedFiles.length || state.stagedFiles.length); + Boolean(state.changedFiles.length || state.stagedFiles.length); export const getChangesInFolder = state => path => { const changedFilesCount = state.changedFiles.filter(f => filePathMatches(f.path, path)).length; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index f81bdb8a30e..77ea2084877 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -102,7 +102,7 @@ export const updateFilesAfterCommit = ({ commit, dispatch, rootState, rootGetter eventHub.$emit(`editor.update.model.content.${file.key}`, { content: file.content, - changed: !!changedFile, + changed: Boolean(changedFile), }); }); }; diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/getters.js b/app/assets/javascripts/ide/stores/modules/pipelines/getters.js index ef7cd4ff8e8..1d127d915d7 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/getters.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/getters.js @@ -1,6 +1,6 @@ import { states } from './constants'; -export const hasLatestPipeline = state => !state.isLoadingPipeline && !!state.latestPipeline; +export const hasLatestPipeline = state => !state.isLoadingPipeline && Boolean(state.latestPipeline); export const pipelineFailed = state => state.latestPipeline && state.latestPipeline.details.status.text === states.failed; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index 344b189decf..ae42b87c9a7 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -142,7 +142,7 @@ export default { Object.assign(state.entries[file.path], { raw: file.content, - changed: !!changedFile, + changed: Boolean(changedFile), staged: false, prevPath: '', moved: false, diff --git a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js index 05000c73052..7051a968dac 100644 --- a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js +++ b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js @@ -14,7 +14,7 @@ export function addCommentIndicator(containerEl, { x, y }) { export function removeCommentIndicator(imageFrameEl) { const commentIndicatorEl = imageFrameEl.querySelector('.comment-indicator'); const imageEl = imageFrameEl.querySelector('img'); - const willRemove = !!commentIndicatorEl; + const willRemove = Boolean(commentIndicatorEl); let meta = {}; if (willRemove) { diff --git a/app/assets/javascripts/image_diff/image_diff.js b/app/assets/javascripts/image_diff/image_diff.js index 3587f073a00..26c1b0ec7be 100644 --- a/app/assets/javascripts/image_diff/image_diff.js +++ b/app/assets/javascripts/image_diff/image_diff.js @@ -6,8 +6,8 @@ import { isImageLoaded } from '../lib/utils/image_utility'; export default class ImageDiff { constructor(el, options) { this.el = el; - this.canCreateNote = !!(options && options.canCreateNote); - this.renderCommentBadge = !!(options && options.renderCommentBadge); + this.canCreateNote = Boolean(options && options.canCreateNote); + this.renderCommentBadge = Boolean(options && options.renderCommentBadge); this.$noteContainer = $('.note-container', this.el); this.imageBadges = []; } diff --git a/app/assets/javascripts/image_diff/view_types.js b/app/assets/javascripts/image_diff/view_types.js index ab0a595571f..1a5123de220 100644 --- a/app/assets/javascripts/image_diff/view_types.js +++ b/app/assets/javascripts/image_diff/view_types.js @@ -5,5 +5,5 @@ export const viewTypes = { }; export function isValidViewType(validate) { - return !!Object.getOwnPropertyNames(viewTypes).find(viewType => viewType === validate); + return Boolean(Object.getOwnPropertyNames(viewTypes).find(viewType => viewType === validate)); } diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js index f51c7a2f990..16f88cddce3 100644 --- a/app/assets/javascripts/issuable_index.js +++ b/app/assets/javascripts/issuable_index.js @@ -12,7 +12,7 @@ export default class IssuableIndex { } initBulkUpdate(pagePrefix) { const userCanBulkUpdate = $('.issues-bulk-update').length > 0; - const alreadyInitialized = !!this.bulkUpdateSidebar; + const alreadyInitialized = Boolean(this.bulkUpdateSidebar); if (userCanBulkUpdate && !alreadyInitialized) { IssuableBulkUpdateActions.init({ diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index ab0b4231255..e88ca4747c5 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -156,7 +156,7 @@ export default { return this.store.formState; }, hasUpdated() { - return !!this.state.updatedAt; + return Boolean(this.state.updatedAt); }, issueChanged() { const { diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue index d2f33dc31a7..1e1dce5f4fc 100644 --- a/app/assets/javascripts/issue_show/components/title.vue +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -71,7 +71,7 @@ export default { 'issue-realtime-pre-pulse': preAnimation, 'issue-realtime-trigger-pulse': pulseAnimation, }" - class="title" + class="title qa-title" dir="auto" v-html="titleHtml" ></h2> diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js index c6dd21cd2d4..7064731a5ea 100644 --- a/app/assets/javascripts/label_manager.js +++ b/app/assets/javascripts/label_manager.js @@ -53,7 +53,7 @@ export default class LabelManager { toggleEmptyState($label, $btn, action) { this.emptyState.classList.toggle( 'hidden', - !!this.prioritizedLabels[0].querySelector(':scope > li'), + Boolean(this.prioritizedLabels[0].querySelector(':scope > li')), ); } diff --git a/app/assets/javascripts/lib/utils/accessor.js b/app/assets/javascripts/lib/utils/accessor.js index 1d18992af63..39cffedcac6 100644 --- a/app/assets/javascripts/lib/utils/accessor.js +++ b/app/assets/javascripts/lib/utils/accessor.js @@ -2,7 +2,7 @@ function isPropertyAccessSafe(base, property) { let safe; try { - safe = !!base[property]; + safe = Boolean(base[property]); } catch (error) { safe = false; } diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 32cafb74d91..d3e6851496b 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -513,7 +513,7 @@ export const stringifyTime = (timeObject, fullNameFormat = false) => { const reducedTime = _.reduce( timeObject, (memo, unitValue, unitName) => { - const isNonZero = !!unitValue; + const isNonZero = Boolean(unitValue); if (fullNameFormat && isNonZero) { // Remove traling 's' if unit value is singular diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 84a617acb42..b7922e29bb0 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -223,9 +223,9 @@ export function insertMarkdownText({ return tag.replace(textPlaceholder, val); } if (val.indexOf(tag) === 0) { - return '' + val.replace(tag, ''); + return String(val.replace(tag, '')); } else { - return '' + tag + val; + return String(tag) + val; } }) .join('\n'); @@ -233,7 +233,7 @@ export function insertMarkdownText({ } else if (tag.indexOf(textPlaceholder) > -1) { textToInsert = tag.replace(textPlaceholder, selected); } else { - textToInsert = '' + startChar + tag + selected + (wrap ? tag : ' '); + textToInsert = String(startChar) + tag + selected + (wrap ? tag : ' '); } if (removedFirstNewLine) { diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index bdfd06fc250..4a9cd1b6f42 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -121,4 +121,40 @@ export function webIDEUrl(route = undefined) { return returnUrl; } +/** + * Returns current base URL + */ +export function getBaseURL() { + const { protocol, host } = window.location; + return `${protocol}//${host}`; +} + +/** + * Returns true if url is an absolute or root-relative URL + * + * @param {String} url + */ +export function isAbsoluteOrRootRelative(url) { + return /^(https?:)?\//.test(url); +} + +/** + * Checks if the provided URL is a safe URL (absolute http(s) or root-relative URL) + * + * @param {String} url that will be checked + * @returns {Boolean} + */ +export function isSafeURL(url) { + if (!isAbsoluteOrRootRelative(url)) { + return false; + } + + try { + const parsedUrl = new URL(url, getBaseURL()); + return ['http:', 'https:'].includes(parsedUrl.protocol); + } catch { + return false; + } +} + export { join as joinPaths } from 'path'; diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index a55a47c277d..def810e879a 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -38,7 +38,7 @@ export default { GlModalDirective, }, props: { - externalDashboardPath: { + externalDashboardUrl: { type: String, required: false, default: '', @@ -248,7 +248,7 @@ export default { > </gl-dropdown> </div> - <div v-if="showTimeWindowDropdown" class="d-flex align-items-center"> + <div v-if="showTimeWindowDropdown" class="d-flex align-items-center prepend-left-8"> <strong>{{ s__('Metrics|Show last') }}</strong> <gl-dropdown class="prepend-left-10 js-time-window-dropdown" @@ -299,10 +299,11 @@ export default { </gl-modal> </div> <gl-button - v-if="externalDashboardPath.length" + v-if="externalDashboardUrl.length" class="js-external-dashboard-link prepend-left-8" variant="primary" - :href="externalDashboardPath" + :href="externalDashboardUrl" + target="_blank" > {{ __('View full dashboard') }} <icon name="external-link" /> diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 74c4ae64712..3fd9e07fa8b 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -8,8 +8,5 @@ export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA'; export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS'; export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE'; export const SET_TIME_WINDOW = 'SET_TIME_WINDOW'; -export const SET_METRICS_ENDPOINT = 'SET_METRICS_ENDPOINT'; -export const SET_ENVIRONMENTS_ENDPOINT = 'SET_ENVIRONMENTS_ENDPOINT'; -export const SET_DEPLOYMENTS_ENDPOINT = 'SET_DEPLOYMENTS_ENDPOINT'; export const SET_ENDPOINTS = 'SET_ENDPOINTS'; export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'; diff --git a/app/assets/javascripts/mr_notes/stores/getters.js b/app/assets/javascripts/mr_notes/stores/getters.js index b10e9f9f9f1..e48cfcd9564 100644 --- a/app/assets/javascripts/mr_notes/stores/getters.js +++ b/app/assets/javascripts/mr_notes/stores/getters.js @@ -1,5 +1,5 @@ export default { isLoggedIn(state, getters) { - return !!getters.getUserData.id; + return Boolean(getters.getUserData.id); }, }; diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index 5b6163a6214..228bb652597 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -49,7 +49,7 @@ export default { computed: { ...mapGetters(['userCanReply']), hasReplies() { - return !!this.replies.length; + return Boolean(this.replies.length); }, replies() { return this.discussion.notes.slice(1); diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 47d74c2f892..aa80e25a3e0 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -10,7 +10,7 @@ import Flash from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import noteHeader from './note_header.vue'; import noteActions from './note_actions.vue'; -import noteBody from './note_body.vue'; +import NoteBody from './note_body.vue'; import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; @@ -21,7 +21,7 @@ export default { userAvatarLink, noteHeader, noteActions, - noteBody, + NoteBody, TimelineEntryItem, }, mixins: [noteable, resolvable, draftMixin], @@ -75,7 +75,7 @@ export default { }; }, canReportAsAbuse() { - return !!this.note.report_abuse_path && this.author.id !== this.getUserData.id; + return Boolean(this.note.report_abuse_path) && this.author.id !== this.getUserData.id; }, noteAnchorId() { return `note_${this.note.id}`; @@ -209,7 +209,10 @@ export default { // we need to do this to prevent noteForm inconsistent content warning // this is something we intentionally do so we need to recover the content this.note.note = noteText; - this.$refs.noteBody.note.note = noteText; + const { noteBody } = this.$refs; + if (noteBody) { + noteBody.note.note = noteText; + } }, }, }; diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 0f1976db37d..4d00e957973 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -127,6 +127,9 @@ export default { initUserPopovers(this.$el.querySelectorAll('.js-user-link')); }); }, + beforeDestroy() { + this.stopPolling(); + }, methods: { ...mapActions([ 'setLoadingState', @@ -144,6 +147,7 @@ export default { 'expandDiscussion', 'startTaskList', 'convertToDiscussion', + 'stopPolling', ]), fetchNotes() { if (this.isFetching) return null; diff --git a/app/assets/javascripts/notes/mixins/issuable_state.js b/app/assets/javascripts/notes/mixins/issuable_state.js index 97f3ea0d5de..ded0ac3cfa9 100644 --- a/app/assets/javascripts/notes/mixins/issuable_state.js +++ b/app/assets/javascripts/notes/mixins/issuable_state.js @@ -1,11 +1,11 @@ export default { methods: { isConfidential(issue) { - return !!issue.confidential; + return Boolean(issue.confidential); }, isLocked(issue) { - return !!issue.discussion_locked; + return Boolean(issue.discussion_locked); }, hasWarning(issue) { diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 2d150e64ef7..d7982be3e4b 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -20,7 +20,7 @@ export const getNoteableData = state => state.noteableData; export const getNoteableDataByProp = state => prop => state.noteableData[prop]; -export const userCanReply = state => !!state.noteableData.current_user.can_create_note; +export const userCanReply = state => Boolean(state.noteableData.current_user.can_create_note); export const openState = state => state.noteableData.state; diff --git a/app/assets/javascripts/operation_settings/components/external_dashboard.vue b/app/assets/javascripts/operation_settings/components/external_dashboard.vue index 0a87d193b72..ed518611d0b 100644 --- a/app/assets/javascripts/operation_settings/components/external_dashboard.vue +++ b/app/assets/javascripts/operation_settings/components/external_dashboard.vue @@ -1,4 +1,5 @@ <script> +import { mapState, mapActions } from 'vuex'; import { GlButton, GlFormGroup, GlFormInput, GlLink } from '@gitlab/ui'; export default { @@ -8,26 +9,34 @@ export default { GlFormInput, GlLink, }, - props: { - externalDashboardPath: { - type: String, - required: false, - default: '', - }, - externalDashboardHelpPagePath: { - type: String, - required: true, + computed: { + ...mapState([ + 'externalDashboardHelpPagePath', + 'externalDashboardUrl', + 'operationsSettingsEndpoint', + ]), + userDashboardUrl: { + get() { + return this.externalDashboardUrl; + }, + set(url) { + this.setExternalDashboardUrl(url); + }, }, }, + methods: { + ...mapActions(['setExternalDashboardUrl', 'updateExternalDashboardUrl']), + }, }; </script> <template> - <section class="settings expanded"> + <section class="settings no-animate"> <div class="settings-header"> <h4 class="js-section-header"> {{ s__('ExternalMetrics|External Dashboard') }} </h4> + <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> <p class="js-section-sub-header"> {{ s__( @@ -44,11 +53,12 @@ export default { :description="s__('ExternalMetrics|Enter the URL of the dashboard you want to link to')" > <gl-form-input - :value="externalDashboardPath" + v-model="userDashboardUrl" placeholder="https://my-org.gitlab.io/my-dashboards" + @keydown.enter.native.prevent="updateExternalDashboardUrl" /> </gl-form-group> - <gl-button variant="success"> + <gl-button variant="success" @click="updateExternalDashboardUrl"> {{ __('Save Changes') }} </gl-button> </form> diff --git a/app/assets/javascripts/operation_settings/index.js b/app/assets/javascripts/operation_settings/index.js index 1171f3ece9f..6946578e6d2 100644 --- a/app/assets/javascripts/operation_settings/index.js +++ b/app/assets/javascripts/operation_settings/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import store from './store'; import ExternalDashboardForm from './components/external_dashboard.vue'; export default () => { @@ -14,13 +15,9 @@ export default () => { return new Vue({ el, + store: store(el.dataset), render(createElement) { - return createElement(ExternalDashboardForm, { - props: { - ...el.dataset, - expanded: false, - }, - }); + return createElement(ExternalDashboardForm); }, }); }; diff --git a/app/assets/javascripts/operation_settings/store/actions.js b/app/assets/javascripts/operation_settings/store/actions.js new file mode 100644 index 00000000000..ec05b0c76cf --- /dev/null +++ b/app/assets/javascripts/operation_settings/store/actions.js @@ -0,0 +1,38 @@ +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import createFlash from '~/flash'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import * as mutationTypes from './mutation_types'; + +export const setExternalDashboardUrl = ({ commit }, url) => + commit(mutationTypes.SET_EXTERNAL_DASHBOARD_URL, url); + +export const updateExternalDashboardUrl = ({ state, dispatch }) => + axios + .patch(state.operationsSettingsEndpoint, { + project: { + metrics_setting_attributes: { + external_dashboard_url: state.externalDashboardUrl, + }, + }, + }) + .then(() => dispatch('receiveExternalDashboardUpdateSuccess')) + .catch(error => dispatch('receiveExternalDashboardUpdateError', error)); + +export const receiveExternalDashboardUpdateSuccess = () => { + /** + * The operations_controller currently handles successful requests + * by creating a flash banner messsage to notify the user. + */ + refreshCurrentPage(); +}; + +export const receiveExternalDashboardUpdateError = (_, error) => { + const { response } = error; + const message = response.data && response.data.message ? response.data.message : ''; + + createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert'); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/operation_settings/store/index.js b/app/assets/javascripts/operation_settings/store/index.js new file mode 100644 index 00000000000..e96bb1e8aad --- /dev/null +++ b/app/assets/javascripts/operation_settings/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import createState from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export const createStore = initialState => + new Vuex.Store({ + state: createState(initialState), + actions, + mutations, + }); + +export default createStore; diff --git a/app/assets/javascripts/operation_settings/store/mutation_types.js b/app/assets/javascripts/operation_settings/store/mutation_types.js new file mode 100644 index 00000000000..237d2b6122f --- /dev/null +++ b/app/assets/javascripts/operation_settings/store/mutation_types.js @@ -0,0 +1,3 @@ +/* eslint-disable import/prefer-default-export */ + +export const SET_EXTERNAL_DASHBOARD_URL = 'SET_EXTERNAL_DASHBOARD_URL'; diff --git a/app/assets/javascripts/operation_settings/store/mutations.js b/app/assets/javascripts/operation_settings/store/mutations.js new file mode 100644 index 00000000000..64bb33bb89f --- /dev/null +++ b/app/assets/javascripts/operation_settings/store/mutations.js @@ -0,0 +1,7 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_EXTERNAL_DASHBOARD_URL](state, url) { + state.externalDashboardUrl = url; + }, +}; diff --git a/app/assets/javascripts/operation_settings/store/state.js b/app/assets/javascripts/operation_settings/store/state.js new file mode 100644 index 00000000000..72167141c48 --- /dev/null +++ b/app/assets/javascripts/operation_settings/store/state.js @@ -0,0 +1,5 @@ +export default (initialState = {}) => ({ + externalDashboardUrl: initialState.externalDashboardUrl || '', + operationsSettingsEndpoint: initialState.operationsSettingsEndpoint, + externalDashboardHelpPagePath: initialState.externalDashboardHelpPagePath, +}); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue index bd4309e47ad..bb490919a9a 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue @@ -29,7 +29,7 @@ export default { // The text input is editable when there's a custom interval, or when it's // a preset interval and the user clicks the 'custom' radio button isEditable() { - return !!(this.customInputEnabled || !this.intervalIsPreset); + return Boolean(this.customInputEnabled || !this.intervalIsPreset); }, }, watch: { diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js index 5270a7924ec..98e19705976 100644 --- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js @@ -1,7 +1,9 @@ import mountErrorTrackingForm from '~/error_tracking_settings'; import mountOperationSettings from '~/operation_settings'; +import initSettingsPanels from '~/settings_panels'; document.addEventListener('DOMContentLoaded', () => { mountErrorTrackingForm(); mountOperationSettings(); + initSettingsPanels(); }); diff --git a/app/assets/javascripts/profile/account/index.js b/app/assets/javascripts/profile/account/index.js index 59c13e1a042..f0d9642a2b2 100644 --- a/app/assets/javascripts/profile/account/index.js +++ b/app/assets/javascripts/profile/account/index.js @@ -35,7 +35,7 @@ export default () => { return createElement('delete-account-modal', { props: { actionUrl: deleteAccountModalEl.dataset.actionUrl, - confirmWithPassword: !!deleteAccountModalEl.dataset.confirmWithPassword, + confirmWithPassword: Boolean(deleteAccountModalEl.dataset.confirmWithPassword), username: deleteAccountModalEl.dataset.username, }, }); diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js index 4834a856271..f05ad7773a2 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js @@ -57,7 +57,7 @@ export const validateProjectBilling = ({ dispatch, commit, state }) => resp => { const { billingEnabled } = resp.result; - commit(types.SET_PROJECT_BILLING_STATUS, !!billingEnabled); + commit(types.SET_PROJECT_BILLING_STATUS, Boolean(billingEnabled)); dispatch('setIsValidatingProjectBilling', false); resolve(); }, diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js index e39f02d0894..f9e2e2f74fb 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js @@ -1,3 +1,3 @@ -export const hasProject = state => !!state.selectedProject.projectId; -export const hasZone = state => !!state.selectedZone; -export const hasMachineType = state => !!state.selectedMachineType; +export const hasProject = state => Boolean(state.selectedProject.projectId); +export const hasZone = state => Boolean(state.selectedZone); +export const hasMachineType = state => Boolean(state.selectedMachineType); diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js index 1ac699c538f..8ace6657ad1 100644 --- a/app/assets/javascripts/registry/stores/mutations.js +++ b/app/assets/javascripts/registry/stores/mutations.js @@ -9,7 +9,7 @@ export default { [types.SET_REPOS_LIST](state, list) { Object.assign(state, { repos: list.map(el => ({ - canDelete: !!el.destroy_path, + canDelete: Boolean(el.destroy_path), destroyPath: el.destroy_path, id: el.id, isLoading: false, @@ -42,7 +42,7 @@ export default { location: element.location, createdAt: element.created_at, destroyPath: element.destroy_path, - canDelete: !!element.destroy_path, + canDelete: Boolean(element.destroy_path), })); }, diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 72e061df573..930c0d5e958 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -82,9 +82,9 @@ Sidebar.prototype.toggleTodo = function(e) { ajaxType = $this.data('deletePath') ? 'delete' : 'post'; if ($this.data('deletePath')) { - url = '' + $this.data('deletePath'); + url = String($this.data('deletePath')); } else { - url = '' + $this.data('createPath'); + url = String($this.data('createPath')); } $this.tooltip('hide'); diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index ab43c2139bf..6aca4067ba7 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -379,7 +379,7 @@ export class SearchAutocomplete { } } } - this.wrap.toggleClass('has-value', !!e.target.value); + this.wrap.toggleClass('has-value', Boolean(e.target.value)); } onSearchInputFocus() { @@ -396,7 +396,7 @@ export class SearchAutocomplete { onClearInputClick(e) { e.preventDefault(); - this.wrap.toggleClass('has-value', !!e.target.value); + this.wrap.toggleClass('has-value', Boolean(e.target.value)); return this.searchInput.val('').focus(); } @@ -405,7 +405,7 @@ export class SearchAutocomplete { this.wrap.removeClass('search-active'); // If input is blank then restore state if (this.searchInput.val() === '') { - return this.restoreOriginalState(); + this.restoreOriginalState(); } this.dropdownMenu.removeClass('show'); } diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index f9b4e789563..94341050b86 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -4,6 +4,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import FunctionRow from './function_row.vue'; import EnvironmentRow from './environment_row.vue'; import EmptyState from './empty_state.vue'; +import { CHECKING_INSTALLED } from '../constants'; export default { components: { @@ -13,10 +14,6 @@ export default { GlLoadingIcon, }, props: { - installed: { - type: Boolean, - required: true, - }, clustersPath: { type: String, required: true, @@ -31,8 +28,15 @@ export default { }, }, computed: { - ...mapState(['isLoading', 'hasFunctionData']), + ...mapState(['installed', 'isLoading', 'hasFunctionData']), ...mapGetters(['getFunctions']), + + checkingInstalled() { + return this.installed === CHECKING_INSTALLED; + }, + isInstalled() { + return this.installed === true; + }, }, created() { this.fetchFunctions({ @@ -47,15 +51,16 @@ export default { <template> <section id="serverless-functions"> - <div v-if="installed"> + <gl-loading-icon + v-if="checkingInstalled" + :size="2" + class="prepend-top-default append-bottom-default" + /> + + <div v-else-if="isInstalled"> <div v-if="hasFunctionData"> - <gl-loading-icon - v-if="isLoading" - :size="2" - class="prepend-top-default append-bottom-default" - /> - <template v-else> - <div class="groups-list-tree-container"> + <template> + <div class="groups-list-tree-container js-functions-wrapper"> <ul class="content-list group-list-tree"> <environment-row v-for="(env, index) in getFunctions" @@ -66,6 +71,11 @@ export default { </ul> </div> </template> + <gl-loading-icon + v-if="isLoading" + :size="2" + class="prepend-top-default append-bottom-default js-functions-loader" + /> </div> <div v-else class="empty-state js-empty-state"> <div class="text-content"> diff --git a/app/assets/javascripts/serverless/constants.js b/app/assets/javascripts/serverless/constants.js index 35f77205f2c..2fa15e56ccb 100644 --- a/app/assets/javascripts/serverless/constants.js +++ b/app/assets/javascripts/serverless/constants.js @@ -1,3 +1,7 @@ export const MAX_REQUESTS = 3; // max number of times to retry export const X_INTERVAL = 5; // Reflects the number of verticle bars on the x-axis + +export const CHECKING_INSTALLED = 'checking'; // The backend is still determining whether or not Knative is installed + +export const TIMEOUT = 'timeout'; diff --git a/app/assets/javascripts/serverless/serverless_bundle.js b/app/assets/javascripts/serverless/serverless_bundle.js index 2d3f086ffee..ed3b633d766 100644 --- a/app/assets/javascripts/serverless/serverless_bundle.js +++ b/app/assets/javascripts/serverless/serverless_bundle.js @@ -45,7 +45,7 @@ export default class Serverless { }, }); } else { - const { statusPath, clustersPath, helpPath, installed } = document.querySelector( + const { statusPath, clustersPath, helpPath } = document.querySelector( '.js-serverless-functions-page', ).dataset; @@ -56,7 +56,6 @@ export default class Serverless { render(createElement) { return createElement(Functions, { props: { - installed: installed !== undefined, clustersPath, helpPath, statusPath, diff --git a/app/assets/javascripts/serverless/store/actions.js b/app/assets/javascripts/serverless/store/actions.js index 826501c9022..a0a9fdf7ace 100644 --- a/app/assets/javascripts/serverless/store/actions.js +++ b/app/assets/javascripts/serverless/store/actions.js @@ -3,13 +3,18 @@ import axios from '~/lib/utils/axios_utils'; import statusCodes from '~/lib/utils/http_status'; import { backOff } from '~/lib/utils/common_utils'; import createFlash from '~/flash'; -import { MAX_REQUESTS } from '../constants'; +import { __ } from '~/locale'; +import { MAX_REQUESTS, CHECKING_INSTALLED, TIMEOUT } from '../constants'; export const requestFunctionsLoading = ({ commit }) => commit(types.REQUEST_FUNCTIONS_LOADING); export const receiveFunctionsSuccess = ({ commit }, data) => commit(types.RECEIVE_FUNCTIONS_SUCCESS, data); -export const receiveFunctionsNoDataSuccess = ({ commit }) => - commit(types.RECEIVE_FUNCTIONS_NODATA_SUCCESS); +export const receiveFunctionsPartial = ({ commit }, data) => + commit(types.RECEIVE_FUNCTIONS_PARTIAL, data); +export const receiveFunctionsTimeout = ({ commit }, data) => + commit(types.RECEIVE_FUNCTIONS_TIMEOUT, data); +export const receiveFunctionsNoDataSuccess = ({ commit }, data) => + commit(types.RECEIVE_FUNCTIONS_NODATA_SUCCESS, data); export const receiveFunctionsError = ({ commit }, error) => commit(types.RECEIVE_FUNCTIONS_ERROR, error); @@ -25,18 +30,25 @@ export const receiveMetricsError = ({ commit }, error) => export const fetchFunctions = ({ dispatch }, { functionsPath }) => { let retryCount = 0; + const functionsPartiallyFetched = data => { + if (data.functions !== null && data.functions.length) { + dispatch('receiveFunctionsPartial', data); + } + }; + dispatch('requestFunctionsLoading'); backOff((next, stop) => { axios .get(functionsPath) .then(response => { - if (response.status === statusCodes.NO_CONTENT) { + if (response.data.knative_installed === CHECKING_INSTALLED) { retryCount += 1; if (retryCount < MAX_REQUESTS) { + functionsPartiallyFetched(response.data); next(); } else { - stop(null); + stop(TIMEOUT); } } else { stop(response.data); @@ -45,10 +57,13 @@ export const fetchFunctions = ({ dispatch }, { functionsPath }) => { .catch(stop); }) .then(data => { - if (data !== null) { + if (data === TIMEOUT) { + dispatch('receiveFunctionsTimeout'); + createFlash(__('Loading functions timed out. Please reload the page to try again.')); + } else if (data.functions !== null && data.functions.length) { dispatch('receiveFunctionsSuccess', data); } else { - dispatch('receiveFunctionsNoDataSuccess'); + dispatch('receiveFunctionsNoDataSuccess', data); } }) .catch(error => { diff --git a/app/assets/javascripts/serverless/store/mutation_types.js b/app/assets/javascripts/serverless/store/mutation_types.js index 25b2f7ac38a..b8fa9ea1a01 100644 --- a/app/assets/javascripts/serverless/store/mutation_types.js +++ b/app/assets/javascripts/serverless/store/mutation_types.js @@ -1,5 +1,7 @@ export const REQUEST_FUNCTIONS_LOADING = 'REQUEST_FUNCTIONS_LOADING'; export const RECEIVE_FUNCTIONS_SUCCESS = 'RECEIVE_FUNCTIONS_SUCCESS'; +export const RECEIVE_FUNCTIONS_PARTIAL = 'RECEIVE_FUNCTIONS_PARTIAL'; +export const RECEIVE_FUNCTIONS_TIMEOUT = 'RECEIVE_FUNCTIONS_TIMEOUT'; export const RECEIVE_FUNCTIONS_NODATA_SUCCESS = 'RECEIVE_FUNCTIONS_NODATA_SUCCESS'; export const RECEIVE_FUNCTIONS_ERROR = 'RECEIVE_FUNCTIONS_ERROR'; diff --git a/app/assets/javascripts/serverless/store/mutations.js b/app/assets/javascripts/serverless/store/mutations.js index 991f32a275d..2685a5b11ff 100644 --- a/app/assets/javascripts/serverless/store/mutations.js +++ b/app/assets/javascripts/serverless/store/mutations.js @@ -5,12 +5,23 @@ export default { state.isLoading = true; }, [types.RECEIVE_FUNCTIONS_SUCCESS](state, data) { - state.functions = data; + state.functions = data.functions; + state.installed = data.knative_installed; state.isLoading = false; state.hasFunctionData = true; }, - [types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state) { + [types.RECEIVE_FUNCTIONS_PARTIAL](state, data) { + state.functions = data.functions; + state.installed = true; + state.isLoading = true; + state.hasFunctionData = true; + }, + [types.RECEIVE_FUNCTIONS_TIMEOUT](state) { + state.isLoading = false; + }, + [types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state, data) { state.isLoading = false; + state.installed = data.knative_installed; state.hasFunctionData = false; }, [types.RECEIVE_FUNCTIONS_ERROR](state, error) { diff --git a/app/assets/javascripts/serverless/store/state.js b/app/assets/javascripts/serverless/store/state.js index afc3f37d7ba..fdd29299749 100644 --- a/app/assets/javascripts/serverless/store/state.js +++ b/app/assets/javascripts/serverless/store/state.js @@ -1,5 +1,6 @@ export default () => ({ error: null, + installed: 'checking', isLoading: true, // functions diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index c03b2a68c78..d84d5344935 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -49,10 +49,10 @@ export default { }, computed: { hasTimeSpent() { - return !!this.timeSpent; + return Boolean(this.timeSpent); }, hasTimeEstimate() { - return !!this.timeEstimate; + return Boolean(this.timeEstimate); }, showComparisonState() { return this.hasTimeEstimate && this.hasTimeSpent; @@ -67,7 +67,7 @@ export default { return !this.hasTimeEstimate && !this.hasTimeSpent; }, showHelpState() { - return !!this.showHelp; + return Boolean(this.showHelp); }, }, created() { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue index ad0464a3a98..abe5bdd2901 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -77,16 +77,16 @@ export default { return this.deployment.external_url; }, hasExternalUrls() { - return !!(this.deployment.external_url && this.deployment.external_url_formatted); + return Boolean(this.deployment.external_url && this.deployment.external_url_formatted); }, hasDeploymentTime() { - return !!(this.deployment.deployed_at && this.deployment.deployed_at_formatted); + return Boolean(this.deployment.deployed_at && this.deployment.deployed_at_formatted); }, hasDeploymentMeta() { - return !!(this.deployment.url && this.deployment.name); + return Boolean(this.deployment.url && this.deployment.name); }, hasMetrics() { - return !!this.deployment.metrics_url; + return Boolean(this.deployment.metrics_url); }, deployedText() { return this.$options.deployedTextMap[this.deployment.status]; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue index 040315b3c66..19a222462b3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue @@ -37,7 +37,7 @@ export default { </script> <template> - <div class="m-3 ml-5" :class="messageClass"> + <div class="m-3 ml-7" :class="messageClass"> <slot></slot> <gl-link v-if="helpPath" :href="helpPath" target="_blank"> <icon :size="16" name="question-o" class="align-middle" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue index 0686409a785..03a15ba81ed 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -56,7 +56,7 @@ export default { return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline; }, showVisualReviewAppLink() { - return !!(this.mr.visualReviewFF && this.mr.visualReviewAppAvailable); + return Boolean(this.mr.visualReviewFF && this.mr.visualReviewAppAvailable); }, }, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue index a3a44dd8e99..83e7d6db9fa 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue @@ -35,9 +35,7 @@ export default { <status-icon status="warning" /> <div class="media-body space-children"> <span class="bold"> - <template v-if="mr.mergeError" - >{{ mr.mergeError }}.</template - > + <template v-if="mr.mergeError">{{ mr.mergeError }}</template> {{ s__('mrWidget|This merge request failed to be merged automatically') }} </span> <button diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue index 1b3af2fccf2..88e1ccbaf35 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue @@ -57,7 +57,7 @@ export default { removeSourceBranch() { const options = { sha: this.mr.sha, - merge_when_pipeline_succeeds: true, + auto_merge_strategy: 'merge_when_pipeline_succeeds', should_remove_source_branch: true, }; @@ -85,7 +85,7 @@ export default { <h4 class="d-flex align-items-start"> <span class="append-right-10"> {{ s__('mrWidget|Set by') }} - <mr-widget-author :author="mr.setToMWPSBy" /> + <mr-widget-author :author="mr.setToAutoMergeBy" /> {{ s__('mrWidget|to be merged automatically when the pipeline succeeds') }} </span> <a diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 851939d5d4e..615d59a7b8e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -31,7 +31,7 @@ export default { return { removeSourceBranch: this.mr.shouldRemoveSourceBranch, mergeWhenBuildSucceeds: false, - setToMergeWhenPipelineSucceeds: false, + autoMergeStrategy: undefined, isMakingRequest: false, isMergingImmediately: false, commitMessage: this.mr.commitMessage, @@ -42,7 +42,7 @@ export default { }; }, computed: { - shouldShowMergeWhenPipelineSucceedsText() { + shouldShowAutoMergeText() { return this.mr.isPipelineActive; }, status() { @@ -87,7 +87,7 @@ export default { mergeButtonText() { if (this.isMergingImmediately) { return __('Merge in progress'); - } else if (this.shouldShowMergeWhenPipelineSucceedsText) { + } else if (this.shouldShowAutoMergeText) { return __('Merge when pipeline succeeds'); } @@ -104,7 +104,7 @@ export default { return enableSquashBeforeMerge && commitsCount > 1; }, shouldShowMergeControls() { - return this.mr.isMergeAllowed || this.shouldShowMergeWhenPipelineSucceedsText; + return this.mr.isMergeAllowed || this.shouldShowAutoMergeText; }, shouldShowSquashEdit() { return this.squashBeforeMerge && this.shouldShowSquashBeforeMerge; @@ -126,12 +126,12 @@ export default { this.isMergingImmediately = true; } - this.setToMergeWhenPipelineSucceeds = mergeWhenBuildSucceeds === true; + this.autoMergeStrategy = mergeWhenBuildSucceeds ? 'merge_when_pipeline_succeeds' : undefined; const options = { sha: this.mr.sha, commit_message: this.commitMessage, - merge_when_pipeline_succeeds: this.setToMergeWhenPipelineSucceeds, + auto_merge_strategy: this.autoMergeStrategy, should_remove_source_branch: this.removeSourceBranch === true, squash: this.squashBeforeMerge, squash_commit_message: this.squashCommitMessage, @@ -330,6 +330,7 @@ export default { :commits-count="mr.commitsCount" :target-branch="mr.targetBranch" :is-fast-forward-enabled="mr.ffOnlyEnabled" + :class="{ 'border-bottom': mr.mergeError }" > <ul class="border-top content-list commits-list flex-list"> <commit-edit diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue index b1f5655a15a..accb9d9fef1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue @@ -29,8 +29,8 @@ export default { </script> <template> - <div class="accept-control inline"> - <label class="merge-param-checkbox"> + <div class="inline"> + <label> <input :checked="value" :disabled="isDisabled" diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index bf175eb5f69..d02bb2f341d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -1,6 +1,6 @@ <script> import _ from 'underscore'; -import { __ } from '~/locale'; +import { sprintf, s__, __ } from '~/locale'; import Project from '~/pages/projects/project'; import SmartInterval from '~/smart_interval'; import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; @@ -97,7 +97,7 @@ export default { return this.mr.hasCI; }, shouldRenderRelatedLinks() { - return !!this.mr.relatedLinks && !this.mr.isNothingToMergeState; + return Boolean(this.mr.relatedLinks) && !this.mr.isNothingToMergeState; }, shouldRenderSourceBranchRemovalStatus() { return ( @@ -125,6 +125,11 @@ export default { this.mr.pipeline.target_sha !== this.mr.targetBranchSha, ); }, + mergeError() { + return sprintf(s__('mrWidget|Merge failed: %{mergeError}. Please try again.'), { + mergeError: this.mr.mergeError, + }); + }, }, watch: { state(newVal, oldVal) { @@ -370,6 +375,10 @@ export default { }} </mr-widget-alert-message> + <mr-widget-alert-message v-if="mr.mergeError" type="danger"> + {{ mergeError }} + </mr-widget-alert-message> + <source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" /> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index 0cc4fd59f5e..3ab229567f6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -23,8 +23,8 @@ export default function deviseState(data) { return stateKey.pipelineBlocked; } else if (this.isSHAMismatch) { return stateKey.shaMismatch; - } else if (this.mergeWhenPipelineSucceeds) { - return this.mergeError ? stateKey.autoMergeFailed : stateKey.mergeWhenPipelineSucceeds; + } else if (this.autoMergeEnabled) { + return this.mergeError ? stateKey.autoMergeFailed : stateKey.autoMergeEnabled; } else if (!this.canMerge) { return stateKey.notAllowedToMerge; } else if (this.canBeMerged) { 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 45708d78886..32badb0fb08 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 @@ -61,7 +61,7 @@ export default class MergeRequestStore { this.updatedAt = data.updated_at; this.metrics = MergeRequestStore.buildMetrics(data.metrics); - this.setToMWPSBy = MergeRequestStore.formatUserObject(data.merge_user || {}); + this.setToAutoMergeBy = MergeRequestStore.formatUserObject(data.merge_user || {}); this.mergeUserId = data.merge_user_id; this.currentUserId = gon.current_user_id; this.sourceBranchPath = data.source_branch_path; @@ -70,15 +70,16 @@ export default class MergeRequestStore { this.targetBranchPath = data.target_branch_commits_path; this.targetBranchTreePath = data.target_branch_tree_path; this.conflictResolutionPath = data.conflict_resolution_path; - this.cancelAutoMergePath = data.cancel_merge_when_pipeline_succeeds_path; + this.cancelAutoMergePath = data.cancel_auto_merge_path; this.removeWIPPath = data.remove_wip_path; this.sourceBranchRemoved = !data.source_branch_exists; this.shouldRemoveSourceBranch = data.remove_source_branch || false; this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false; - this.mergeWhenPipelineSucceeds = data.merge_when_pipeline_succeeds || false; + this.autoMergeEnabled = Boolean(data.auto_merge_enabled); + this.autoMergeStrategy = data.auto_merge_strategy; this.mergePath = data.merge_path; this.ffOnlyEnabled = data.ff_only_enabled; - this.shouldBeRebased = !!data.should_be_rebased; + this.shouldBeRebased = Boolean(data.should_be_rebased); this.statusPath = data.status_path; this.emailPatchesPath = data.email_patches_path; this.plainDiffPath = data.plain_diff_path; @@ -91,9 +92,9 @@ export default class MergeRequestStore { this.isOpen = data.state === 'opened'; this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false; this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false; - this.canMerge = !!data.merge_path; + this.canMerge = Boolean(data.merge_path); this.canCreateIssue = currentUser.can_create_issue || false; - this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path; + this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path); this.isSHAMismatch = this.sha !== data.diff_head_sha; this.canBeMerged = data.can_be_merged || false; this.isMergeAllowed = data.mergeable || false; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js index e080ce5c229..48bc6a867f4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -13,7 +13,7 @@ const stateToComponentMap = { unresolvedDiscussions: 'mr-widget-unresolved-discussions', pipelineBlocked: 'mr-widget-pipeline-blocked', pipelineFailed: 'mr-widget-pipeline-failed', - mergeWhenPipelineSucceeds: 'mr-widget-merge-when-pipeline-succeeds', + autoMergeEnabled: 'mr-widget-merge-when-pipeline-succeeds', failedToMerge: 'mr-widget-failed-to-merge', autoMergeFailed: 'mr-widget-auto-merge-failed', shaMismatch: 'sha-mismatch', @@ -45,7 +45,7 @@ export const stateKey = { pipelineBlocked: 'pipelineBlocked', shaMismatch: 'shaMismatch', autoMergeFailed: 'autoMergeFailed', - mergeWhenPipelineSucceeds: 'mergeWhenPipelineSucceeds', + autoMergeEnabled: 'autoMergeEnabled', notAllowedToMerge: 'notAllowedToMerge', readyToMerge: 'readyToMerge', rebase: 'rebase', diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 3b57b5e8da4..d6c398c8946 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -33,37 +33,36 @@ export default { <div class="comment-toolbar clearfix"> <div class="toolbar-text"> <template v-if="!hasQuickActionsDocsPath && markdownDocsPath"> - <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1"> - Markdown is supported - </gl-link> + <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1" + >Markdown is supported</gl-link + > </template> <template v-if="hasQuickActionsDocsPath && markdownDocsPath"> - <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1"> Markdown </gl-link> - and - <gl-link :href="quickActionsDocsPath" target="_blank" tabindex="-1"> - quick actions - </gl-link> + <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">Markdown</gl-link> and + <gl-link :href="quickActionsDocsPath" target="_blank" tabindex="-1">quick actions</gl-link> are supported </template> </div> <span v-if="canAttachFile" class="uploading-container"> <span class="uploading-progress-container hide"> - <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"> </i> - <span class="attaching-file-message"></span> <span class="uploading-progress">0%</span> + <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i> + <span class="attaching-file-message"></span> + <span class="uploading-progress">0%</span> <span class="uploading-spinner"> - <i class="fa fa-spinner fa-spin toolbar-button-icon" aria-hidden="true"> </i> + <i class="fa fa-spinner fa-spin toolbar-button-icon" aria-hidden="true"></i> </span> </span> <span class="uploading-error-container hide"> <span class="uploading-error-icon"> - <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"> </i> + <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i> </span> <span class="uploading-error-message"></span> <button class="retry-uploading-link" type="button">Try again</button> or <button class="attach-new-file markdown-selector" type="button">attach a new file</button> </span> - <button class="markdown-selector button-attach-file" tabindex="-1" type="button"> - <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"> </i> Attach a file + <button class="markdown-selector button-attach-file btn-link" tabindex="-1" type="button"> + <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i + ><span class="text-attach-file">Attach a file</span> </button> <button class="btn btn-default btn-sm hide button-cancel-uploading-files" type="button"> Cancel diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue index fa502b9beb9..8104d919bf6 100644 --- a/app/assets/javascripts/vue_shared/components/pikaday.vue +++ b/app/assets/javascripts/vue_shared/components/pikaday.vue @@ -34,7 +34,7 @@ export default { format: 'yyyy-mm-dd', container: this.$el, defaultDate: this.selectedDate, - setDefaultDate: !!this.selectedDate, + setDefaultDate: Boolean(this.selectedDate), minDate: this.minDate, maxDate: this.maxDate, parse: dateString => parsePikadayDate(dateString), diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.vue b/app/assets/javascripts/vue_shared/components/table_pagination.vue index 8e0b08032f7..9cce9a4e542 100644 --- a/app/assets/javascripts/vue_shared/components/table_pagination.vue +++ b/app/assets/javascripts/vue_shared/components/table_pagination.vue @@ -121,7 +121,7 @@ export default { this.change(1); break; default: - this.change(+text); + this.change(Number(text)); break; } }, diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index 93377b8dd91..7f6384f4eea 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -22,7 +22,9 @@ body, .form-control, .search form { // Override default font size used in non-csslab UI - font-size: 14px; + // Use rem to keep default font-size at 14px on body so 1rem still + // fits 8px grid, but also allow users to change browser font size + font-size: .875rem; } legend { diff --git a/app/assets/stylesheets/components/avatar.scss b/app/assets/stylesheets/components/avatar.scss index 25ee3ca944d..1afa5ed90f4 100644 --- a/app/assets/stylesheets/components/avatar.scss +++ b/app/assets/stylesheets/components/avatar.scss @@ -7,9 +7,6 @@ $avatar-sizes: ( 18: ( border-radius: $border-radius-small ), - 19: ( - border-radius: $border-radius-small - ), 20: ( border-radius: $border-radius-small ), @@ -28,17 +25,11 @@ $avatar-sizes: ( line-height: 32px, border-radius: $border-radius-default ), - 36: ( - border-radius: $border-radius-default - ), 40: ( font-size: 16px, line-height: 38px, border-radius: $border-radius-default ), - 46: ( - border-radius: $border-radius-default - ), 48: ( font-size: 20px, line-height: 48px, @@ -54,37 +45,16 @@ $avatar-sizes: ( line-height: 64px, border-radius: $border-radius-large ), - 70: ( - font-size: 34px, - line-height: 70px, - border-radius: $border-radius-large - ), 90: ( font-size: 36px, line-height: 88px, border-radius: $border-radius-large ), - 96: ( - font-size: 48px, - line-height: 96px, - border-radius: $border-radius-large - ), 100: ( font-size: 36px, line-height: 98px, border-radius: $border-radius-large ), - 110: ( - font-size: 40px, - line-height: 108px, - font-weight: $gl-font-weight-normal, - border-radius: $border-radius-large - ), - 140: ( - font-size: 72px, - line-height: 138px, - border-radius: $border-radius-large - ), 160: ( font-size: 96px, line-height: 158px, @@ -97,13 +67,13 @@ $identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $i .avatar-circle { float: left; - margin-right: 15px; + margin-right: $gl-padding; border-radius: $avatar-radius; border: 1px solid $gray-normal; @each $size, $size-config in $avatar-sizes { &.s#{$size} { - @include avatar-size(#{$size}px, if($size < 36, 8px, 16px)); + @include avatar-size(#{$size}px, if($size < 48, 8px, 16px)); } } } diff --git a/app/assets/stylesheets/errors.scss b/app/assets/stylesheets/errors.scss index 658e0ff638e..8c32b6c8985 100644 --- a/app/assets/stylesheets/errors.scss +++ b/app/assets/stylesheets/errors.scss @@ -17,7 +17,7 @@ body { text-align: center; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; margin: auto; - font-size: 14px; + font-size: .875rem; } h1 { diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index b85abfd9c14..97a763671ba 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -1,12 +1,12 @@ @mixin btn-comment-icon { border-radius: 50%; background: $white-light; - padding: 1px 5px; + padding: 1px; font-size: 12px; color: $blue-500; + border: 1px solid $blue-500; width: 24px; height: 24px; - border: 1px solid $blue-500; &:hover, &.inverted { diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 8493dfff1c5..ef6f0633150 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -329,7 +329,7 @@ span.idiff { background-color: $gray-light; border-bottom: 1px solid $border-color; border-top: 1px solid $border-color; - padding: 5px $gl-padding; + padding: $gl-padding-8 $gl-padding; margin: 0; border-radius: $border-radius-default $border-radius-default 0 0; diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index 33930871cdc..2a601afff53 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -47,14 +47,6 @@ label { margin: 0; } -.form-label { - @extend label; -} - -.form-control-label { - @extend .col-md-2; -} - .inline-input-group { width: 250px; } @@ -87,44 +79,14 @@ label { margin-left: 0; margin-right: 0; - .form-control-label { - font-weight: $gl-font-weight-bold; - padding-top: 4px; - } - .form-control { height: 29px; background: $white-light; font-family: $monospace-font; } - .input-group-prepend .btn, - .input-group-append .btn { - padding: 3px $gl-btn-padding; - background-color: $gray-light; - border: 1px solid $border-color; - } - - .text-block { - line-height: 0.8; - padding-top: 9px; - - code { - line-height: 1.8; - } - - img { - margin-right: $gl-padding; - } - } - @include media-breakpoint-down(xs) { padding: 0 $gl-padding; - - .form-control-label, - .text-block { - padding-left: 0; - } } } @@ -146,19 +108,6 @@ label { } } -.select-wrapper { - position: relative; - - .fa-chevron-down { - position: absolute; - font-size: 10px; - right: 10px; - top: 12px; - color: $gray-darkest; - pointer-events: none; - } -} - .select-control { padding-left: 10px; padding-right: 10px; @@ -181,12 +130,6 @@ label { margin-top: 35px; } -.form-group .form-control-label, -.form-group .form-control-label-full-width { - font-weight: $gl-font-weight-normal; -} - - .form-control::placeholder { color: $gl-text-color-tertiary; } @@ -230,7 +173,8 @@ label { border: 1px solid $green-600; &:focus { - box-shadow: 0 0 0 1px $green-600 inset, 0 1px 1px $gl-field-focus-shadow inset, 0 0 4px 0 $green-600; + box-shadow: 0 0 0 1px $green-600 inset, 0 1px 1px $gl-field-focus-shadow inset, + 0 0 4px 0 $green-600; border: 0 none; } } @@ -239,7 +183,8 @@ label { border: 1px solid $red-500; &:focus { - box-shadow: 0 0 0 1px $red-500 inset, 0 1px 1px $gl-field-focus-shadow inset, 0 0 4px 0 $gl-field-focus-shadow-error; + box-shadow: 0 0 0 1px $red-500 inset, 0 1px 1px $gl-field-focus-shadow inset, + 0 0 4px 0 $gl-field-focus-shadow-error; border: 0 none; } } @@ -265,16 +210,26 @@ label { } } -.input-icon-wrapper { +.input-icon-wrapper, +.select-wrapper { position: relative; +} - .input-icon-right { - position: absolute; - right: 0.8em; - top: 50%; - transform: translateY(-50%); - color: $gray-600; - } +.select-wrapper > .fa-chevron-down { + position: absolute; + font-size: 10px; + right: 10px; + top: 12px; + color: $gray-darkest; + pointer-events: none; +} + +.input-icon-wrapper > .input-icon-right { + position: absolute; + right: 0.8em; + top: 50%; + transform: translateY(-50%); + color: $gray-600; } .input-md { @@ -290,3 +245,17 @@ label { .input-group-text { max-height: $input-height; } + +.gl-form-checkbox { + align-items: baseline; + + &.form-check-inline .form-check-input { + align-self: flex-start; + margin-right: $gl-padding-8; + height: 1.5 * $gl-font-size; + } + + .help-text { + margin-bottom: 0; + } +} diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 18671f7c4d8..df40149f0a6 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -385,3 +385,8 @@ height: $size; margin-right: $margin-right; } + +@mixin code-icon-size() { + width: $gl-font-size * $code-line-height * 0.9; + height: $gl-font-size * $code-line-height * 0.9; +} diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 66cd113db84..77a36e59b03 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -154,8 +154,6 @@ } .avatar-cell { - width: 46px; - img { margin-right: 0; } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index b3a634e23a3..5e5d298f8f2 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -15,7 +15,6 @@ position: sticky; top: $mr-file-header-top; z-index: 102; - height: $mr-version-controls-height; &::before { content: ''; @@ -615,10 +614,9 @@ table.code { .diff-comment-avatar-holders { position: absolute; - height: 19px; - width: 19px; - margin-left: -15px; + margin-left: -$gl-padding; z-index: 100; + @include code-icon-size(); &:hover { .diff-comment-avatar, @@ -652,26 +650,28 @@ table.code { .diff-comments-more-count { position: absolute; left: 0; - width: 19px; - height: 19px; margin-right: 0; border-color: $white-light; cursor: pointer; transition: all 0.1s ease-out; + @include code-icon-size(); @for $i from 1 through 4 { &:nth-child(#{$i}) { z-index: (4 - $i); } } + + .avatar { + @include code-icon-size(); + } } .diff-comments-more-count { - width: 19px; - min-width: 19px; padding-left: 0; padding-right: 0; overflow: hidden; + @include code-icon-size(); } .diff-comments-more-count, @@ -680,12 +680,15 @@ table.code { } .diff-notes-collapse { - width: 24px; - height: 24px; + border: 0; border-radius: 50%; padding: 0; transition: transform 0.1s ease-out; z-index: 100; + display: flex; + justify-content: center; + align-items: center; + @include code-icon-size(); .collapse-icon { height: 50%; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index ab5a9e170f0..77b40fe2d30 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -185,46 +185,6 @@ } } } - - .accept-control { - display: inline-block; - float: left; - margin: 0; - margin-left: 20px; - padding: 5px; - padding-top: 8px; - line-height: 20px; - - &.right { - float: right; - padding-right: 0; - } - - .modify-merge-commit-link { - padding: 0; - background-color: transparent; - border: 0; - color: $gl-text-color; - - &:hover, - &:focus { - text-decoration: underline; - } - } - - .merge-param-checkbox { - margin: 0; - } - - a .fa-question-circle { - color: $gl-text-color-secondary; - - &:hover, - &:focus { - color: $link-hover-color; - } - } - } } .ci-widget { @@ -407,12 +367,6 @@ width: 100%; text-align: center; } - - .accept-control { - width: 100%; - text-align: center; - margin: 0; - } } .commit-message-editor { diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 3343b55d24b..8c7b124dd33 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -334,7 +334,7 @@ table { .toolbar-button-icon { position: relative; top: 1px; - margin-right: 3px; + margin-right: $gl-padding-4; color: inherit; font-size: 16px; } @@ -461,6 +461,15 @@ table { border: 0; font-size: 14px; line-height: 16px; + + &:hover, + &:focus { + text-decoration: none; + + .text-attach-file { + text-decoration: underline; + } + } } .markdown-selector { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 170432a9e62..32477c20db6 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -437,7 +437,9 @@ $note-form-margin-left: 72px; .diff-file { .is-over { .add-diff-note { - display: inline-block; + display: inline-flex; + justify-content: center; + align-items: center; } } @@ -741,7 +743,7 @@ $note-form-margin-left: 72px; .add-diff-note { @include btn-comment-icon; opacity: 0; - margin-left: -50px; + margin-left: -52px; position: absolute; top: 50%; transform: translateY(-50%); @@ -900,10 +902,6 @@ $note-form-margin-left: 72px; .diff-comment-form { display: block; } - - .add-diff-note svg { - margin-top: 4px; - } } .discussion-filter-container { diff --git a/app/controllers/concerns/import_url_params.rb b/app/controllers/concerns/import_url_params.rb new file mode 100644 index 00000000000..e51e4157f50 --- /dev/null +++ b/app/controllers/concerns/import_url_params.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module ImportUrlParams + def import_url_params + return {} unless params.dig(:project, :import_url).present? + + { import_url: import_params_to_full_url(params[:project]) } + end + + def import_params_to_full_url(params) + Gitlab::UrlSanitizer.new( + params[:import_url], + credentials: { + user: params[:import_url_user], + password: params[:import_url_password] + } + ).full_url + end +end diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb index cfff154c3dd..8b8b7db72f8 100644 --- a/app/controllers/concerns/milestone_actions.rb +++ b/app/controllers/concerns/milestone_actions.rb @@ -26,16 +26,22 @@ module MilestoneActions end end + # rubocop:disable Gitlab/ModuleWithInstanceVariables def labels respond_to do |format| format.html { redirect_to milestone_redirect_path } format.json do + milestone_labels = @milestone.issue_labels_visible_by_user(current_user) + render json: tabs_json("shared/milestones/_labels_tab", { - labels: @milestone.labels.map { |label| label.present(issuable_subject: @milestone.parent) } # rubocop:disable Gitlab/ModuleWithInstanceVariables + labels: milestone_labels.map do |label| + label.present(issuable_subject: @milestone.parent) + end }) end end end + # rubocop:enable Gitlab/ModuleWithInstanceVariables private diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb index 4640be015de..afbf9fd7720 100644 --- a/app/controllers/projects/imports_controller.rb +++ b/app/controllers/projects/imports_controller.rb @@ -2,6 +2,7 @@ class Projects::ImportsController < Projects::ApplicationController include ContinueParams + include ImportUrlParams # Authorize before_action :authorize_admin_project! @@ -67,10 +68,12 @@ class Projects::ImportsController < Projects::ApplicationController end def import_params_attributes - [:import_url] + [] end def import_params - params.require(:project).permit(import_params_attributes) + params.require(:project) + .permit(import_params_attributes) + .merge(import_url_params) end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 8f177895b08..135117926be 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -33,7 +33,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo def show close_merge_request_if_no_source_project - mark_merge_request_mergeable + @merge_request.check_mergeability respond_to do |format| format.html do @@ -145,14 +145,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo render partial: 'projects/merge_requests/widget/commit_change_content', layout: false end - def cancel_merge_when_pipeline_succeeds - unless @merge_request.can_cancel_merge_when_pipeline_succeeds?(current_user) + def cancel_auto_merge + unless @merge_request.can_cancel_auto_merge?(current_user) return access_denied! end - ::MergeRequests::MergeWhenPipelineSucceedsService - .new(@project, current_user) - .cancel(@merge_request) + AutoMergeService.new(project, current_user).cancel(@merge_request) render json: serialize_widget(@merge_request) end @@ -229,12 +227,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def merge_params_attributes - [:should_remove_source_branch, :commit_message, :squash_commit_message, :squash] + [:should_remove_source_branch, :commit_message, :squash_commit_message, :squash, :auto_merge_strategy] end - def merge_when_pipeline_succeeds_active? - params[:merge_when_pipeline_succeeds].present? && - @merge_request.head_pipeline && @merge_request.head_pipeline.active? + def auto_merge_requested? + # Support params[:merge_when_pipeline_succeeds] during the transition period + params[:auto_merge_strategy].present? || params[:merge_when_pipeline_succeeds].present? end def close_merge_request_if_no_source_project @@ -253,14 +251,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @merge_request.has_no_commits? && !@merge_request.target_branch_exists? end - def mark_merge_request_mergeable - @merge_request.check_if_can_be_merged - end - def merge! - # Disable the CI check if merge_when_pipeline_succeeds is enabled since we have + # Disable the CI check if auto_merge_strategy is specified since we have # to wait until CI completes to know - unless @merge_request.mergeable?(skip_ci_check: merge_when_pipeline_succeeds_active?) + unless @merge_request.mergeable?(skip_ci_check: auto_merge_requested?) return :failed end @@ -274,24 +268,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @merge_request.update(merge_error: nil, squash: merge_params.fetch(:squash, false)) - if params[:merge_when_pipeline_succeeds].present? - return :failed unless @merge_request.actual_head_pipeline - - if @merge_request.actual_head_pipeline.active? - ::MergeRequests::MergeWhenPipelineSucceedsService - .new(@project, current_user, merge_params) - .execute(@merge_request) - - :merge_when_pipeline_succeeds - elsif @merge_request.actual_head_pipeline.success? - # This can be triggered when a user clicks the auto merge button while - # the tests finish at about the same time - @merge_request.merge_async(current_user.id, merge_params) - - :success - else - :failed - end + if auto_merge_requested? + AutoMergeService.new(project, current_user, merge_params) + .execute(merge_request, + params[:auto_merge_strategy] || AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) else @merge_request.merge_async(current_user.id, merge_params) diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb index 79030da64d3..4b0d001fca6 100644 --- a/app/controllers/projects/serverless/functions_controller.rb +++ b/app/controllers/projects/serverless/functions_controller.rb @@ -10,15 +10,13 @@ module Projects format.json do functions = finder.execute - if functions.any? - render json: serialize_function(functions) - else - head :no_content - end + render json: { + knative_installed: finder.knative_installed, + functions: serialize_function(functions) + }.to_json end format.html do - @installed = finder.installed? render end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index e88c46144ef..12db493978b 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -7,6 +7,7 @@ class ProjectsController < Projects::ApplicationController include PreviewMarkdown include SendFileUpload include RecordUserLastActivity + include ImportUrlParams prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } @@ -333,6 +334,7 @@ class ProjectsController < Projects::ApplicationController def project_params(attributes: []) params.require(:project) .permit(project_params_attributes + attributes) + .merge(import_url_params) end def project_params_attributes diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 6fea61cf45d..a841859621e 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -18,6 +18,7 @@ class SessionsController < Devise::SessionsController prepend_before_action :store_redirect_uri, only: [:new] prepend_before_action :ldap_servers, only: [:new, :create] prepend_before_action :require_no_authentication_without_flash, only: [:new, :create] + prepend_before_action :ensure_password_authentication_enabled!, if: :password_based_login?, only: [:create] before_action :auto_sign_in_with_provider, only: [:new] before_action :load_recaptcha @@ -138,6 +139,14 @@ class SessionsController < Devise::SessionsController end # rubocop: enable CodeReuse/ActiveRecord + def ensure_password_authentication_enabled! + render_403 unless Gitlab::CurrentSettings.password_authentication_enabled_for_web? + end + + def password_based_login? + user_params[:login].present? || user_params[:password].present? + end + def user_params params.require(:user).permit(:login, :password, :remember_me, :otp_attempt, :device_response) end diff --git a/app/finders/clusters/knative_services_finder.rb b/app/finders/clusters/knative_services_finder.rb new file mode 100644 index 00000000000..7d3b53ef663 --- /dev/null +++ b/app/finders/clusters/knative_services_finder.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true +module Clusters + class KnativeServicesFinder + include ReactiveCaching + include Gitlab::Utils::StrongMemoize + + KNATIVE_STATES = { + 'checking' => 'checking', + 'installed' => 'installed', + 'not_found' => 'not_found' + }.freeze + + self.reactive_cache_key = ->(finder) { finder.model_name } + self.reactive_cache_worker_finder = ->(_id, *cache_args) { from_cache(*cache_args) } + + attr_reader :cluster, :project + + def initialize(cluster, project) + @cluster = cluster + @project = project + end + + def with_reactive_cache_memoized(*cache_args, &block) + strong_memoize(:reactive_cache) do + with_reactive_cache(*cache_args, &block) + end + end + + def clear_cache! + clear_reactive_cache!(*cache_args) + end + + def self.from_cache(cluster_id, project_id) + cluster = Clusters::Cluster.find(cluster_id) + project = ::Project.find(project_id) + + new(cluster, project) + end + + def calculate_reactive_cache(*) + # read_services calls knative_client.discover implicitily. If we stop + # detecting services but still want to detect knative, we'll need to + # explicitily call: knative_client.discover + # + # We didn't create it separately to avoid 2 cluster requests. + ksvc = read_services + pods = knative_client.discovered ? read_pods : [] + { services: ksvc, pods: pods, knative_detected: knative_client.discovered } + end + + def services + return [] unless search_namespace + + cached_data = with_reactive_cache_memoized(*cache_args) { |data| data } + cached_data.to_h.fetch(:services, []) + end + + def cache_args + [cluster.id, project.id] + end + + def service_pod_details(service) + cached_data = with_reactive_cache_memoized(*cache_args) { |data| data } + cached_data.to_h.fetch(:pods, []).select do |pod| + filter_pods(pod, service) + end + end + + def knative_detected + cached_data = with_reactive_cache_memoized(*cache_args) { |data| data } + + knative_state = cached_data.to_h[:knative_detected] + + return KNATIVE_STATES['checking'] if knative_state.nil? + return KNATIVE_STATES['installed'] if knative_state + + KNATIVE_STATES['uninstalled'] + end + + def model_name + self.class.name.underscore.tr('/', '_') + end + + private + + def search_namespace + @search_namespace ||= cluster.kubernetes_namespace_for(project) + end + + def knative_client + cluster.kubeclient.knative_client + end + + def filter_pods(pod, service) + pod["metadata"]["labels"]["serving.knative.dev/service"] == service + end + + def read_services + knative_client.get_services(namespace: search_namespace).as_json + rescue Kubeclient::ResourceNotFoundError + [] + end + + def read_pods + cluster.kubeclient.core_client.get_pods(namespace: search_namespace).as_json + end + + def id + nil + end + end +end diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb index e5bffccabfe..ebe50806ca1 100644 --- a/app/finders/projects/serverless/functions_finder.rb +++ b/app/finders/projects/serverless/functions_finder.rb @@ -14,8 +14,16 @@ module Projects knative_services.flatten.compact end - def installed? - clusters_with_knative_installed.exists? + # Possible return values: Clusters::KnativeServicesFinder::KNATIVE_STATE + def knative_installed + states = @clusters.map do |cluster| + cluster.application_knative + cluster.knative_services_finder(project).knative_detected.tap do |state| + return state if state == ::Clusters::KnativeServicesFinder::KNATIVE_STATES['checking'] # rubocop:disable Cop/AvoidReturnFromBlocks + end + end + + states.any? { |state| state == ::Clusters::KnativeServicesFinder::KNATIVE_STATES['installed'] } end def service(environment_scope, name) @@ -25,7 +33,7 @@ module Projects def invocation_metrics(environment_scope, name) return unless prometheus_adapter&.can_query? - cluster = clusters_with_knative_installed.preload_knative.find do |c| + cluster = @clusters.find do |c| environment_scope == c.environment_scope end @@ -34,7 +42,7 @@ module Projects end def has_prometheus?(environment_scope) - clusters_with_knative_installed.preload_knative.to_a.any? do |cluster| + @clusters.any? do |cluster| environment_scope == cluster.environment_scope && cluster.application_prometheus_available? end end @@ -42,10 +50,12 @@ module Projects private def knative_service(environment_scope, name) - clusters_with_knative_installed.preload_knative.map do |cluster| + @clusters.map do |cluster| next if environment_scope != cluster.environment_scope - services = cluster.application_knative.services_for(ns: cluster.kubernetes_namespace_for(project)) + services = cluster + .knative_services_finder(project) + .services .select { |svc| svc["metadata"]["name"] == name } add_metadata(cluster, services).first unless services.nil? @@ -53,8 +63,11 @@ module Projects end def knative_services - clusters_with_knative_installed.preload_knative.map do |cluster| - services = cluster.application_knative.services_for(ns: cluster.kubernetes_namespace_for(project)) + @clusters.map do |cluster| + services = cluster + .knative_services_finder(project) + .services + add_metadata(cluster, services) unless services.nil? end end @@ -65,17 +78,14 @@ module Projects s["cluster_id"] = cluster.id if services.length == 1 - s["podcount"] = cluster.application_knative.service_pod_details( - cluster.kubernetes_namespace_for(project), - s["metadata"]["name"]).length + s["podcount"] = cluster + .knative_services_finder(project) + .service_pod_details(s["metadata"]["name"]) + .length end end end - def clusters_with_knative_installed - @clusters.with_knative_installed - end - # rubocop: disable CodeReuse/ServiceClass def prometheus_adapter @prometheus_adapter ||= ::Prometheus::AdapterService.new(project).prometheus_adapter diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 31850c2cadb..5b7eb57841c 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -10,7 +10,7 @@ module Resolvers end end - def self.resolver_complexity(args) + def self.resolver_complexity(args, child_complexity:) complexity = 1 complexity += 1 if args[:sort] complexity += 5 if args[:search] diff --git a/app/graphql/resolvers/concerns/resolves_pipelines.rb b/app/graphql/resolvers/concerns/resolves_pipelines.rb index a166211fc18..a6f82cc8505 100644 --- a/app/graphql/resolvers/concerns/resolves_pipelines.rb +++ b/app/graphql/resolvers/concerns/resolves_pipelines.rb @@ -20,7 +20,7 @@ module ResolvesPipelines end class_methods do - def resolver_complexity(args) + def resolver_complexity(args, child_complexity:) complexity = super complexity += 2 if args[:sha] complexity += 2 if args[:ref] diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index f7e49166ca0..3ee3849f483 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -58,7 +58,7 @@ module Resolvers IssuesFinder.new(context[:current_user], args).execute end - def self.resolver_complexity(args) + def self.resolver_complexity(args, child_complexity:) complexity = super complexity += 2 if args[:labelName] diff --git a/app/graphql/resolvers/namespace_projects_resolver.rb b/app/graphql/resolvers/namespace_projects_resolver.rb new file mode 100644 index 00000000000..677ea808aeb --- /dev/null +++ b/app/graphql/resolvers/namespace_projects_resolver.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Resolvers + class NamespaceProjectsResolver < BaseResolver + argument :include_subgroups, GraphQL::BOOLEAN_TYPE, + required: false, + default_value: false, + description: 'Include also subgroup projects' + + type Types::ProjectType, null: true + + alias_method :namespace, :object + + def resolve(include_subgroups:) + # The namespace could have been loaded in batch by `BatchLoader`. + # At this point we need the `id` or the `full_path` of the namespace + # to query for projects, so make sure it's loaded and not `nil` before continuing. + namespace.sync if namespace.respond_to?(:sync) + return Project.none if namespace.nil? + + if include_subgroups + namespace.all_projects.with_route + else + namespace.projects.with_route + end + end + + def self.resolver_complexity(args, child_complexity:) + complexity = super + complexity + 10 + end + end +end diff --git a/app/graphql/resolvers/namespace_resolver.rb b/app/graphql/resolvers/namespace_resolver.rb new file mode 100644 index 00000000000..17b3800d151 --- /dev/null +++ b/app/graphql/resolvers/namespace_resolver.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Resolvers + class NamespaceResolver < BaseResolver + prepend FullPathResolver + + type Types::NamespaceType, null: true + + def resolve(full_path:) + model_by_full_path(Namespace, full_path) + end + end +end diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb index 15331129134..a374851e835 100644 --- a/app/graphql/types/base_field.rb +++ b/app/graphql/types/base_field.rb @@ -33,7 +33,7 @@ module Types limit_value = [args[:first], args[:last], page_size].compact.min # Resolvers may add extra complexity depending on used arguments - complexity = child_complexity + self.resolver&.try(:resolver_complexity, args).to_i + complexity = child_complexity + self.resolver&.try(:resolver_complexity, args, child_complexity: child_complexity).to_i # Resolvers may add extra complexity depending on number of items being loaded. multiplier = self.resolver&.try(:complexity_multiplier, args).to_f diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index b21a226d07f..dd5133189dc 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -15,6 +15,10 @@ module Types field :description, GraphQL::STRING_TYPE, null: true field :state, IssueStateEnum, null: false + field :reference, GraphQL::STRING_TYPE, null: false, method: :to_reference do + argument :full, GraphQL::BOOLEAN_TYPE, required: false, default_value: false + end + field :author, Types::UserType, null: false, resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find } @@ -37,7 +41,9 @@ module Types field :upvotes, GraphQL::INT_TYPE, null: false field :downvotes, GraphQL::INT_TYPE, null: false field :user_notes_count, GraphQL::INT_TYPE, null: false + field :web_path, GraphQL::STRING_TYPE, null: false, method: :issue_path field :web_url, GraphQL::STRING_TYPE, null: false + field :relative_position, GraphQL::INT_TYPE, null: true field :closed_at, Types::TimeType, null: true diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb index 36d8ee8c878..f6d91320e50 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -15,5 +15,10 @@ module Types field :visibility, GraphQL::STRING_TYPE, null: true field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled? field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true + + field :projects, + Types::ProjectType.connection_type, + null: false, + resolver: ::Resolvers::NamespaceProjectsResolver end end diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb new file mode 100644 index 00000000000..62537361918 --- /dev/null +++ b/app/graphql/types/project_statistics_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + class ProjectStatisticsType < BaseObject + graphql_name 'ProjectStatistics' + + field :commit_count, GraphQL::INT_TYPE, null: false + + field :storage_size, GraphQL::INT_TYPE, null: false + field :repository_size, GraphQL::INT_TYPE, null: false + field :lfs_objects_size, GraphQL::INT_TYPE, null: false + field :build_artifacts_size, GraphQL::INT_TYPE, null: false + field :packages_size, GraphQL::INT_TYPE, null: false + field :wiki_size, GraphQL::INT_TYPE, null: true + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 06a1aab09f6..2236ffa394d 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -69,6 +69,10 @@ module Types field :namespace, Types::NamespaceType, null: false field :group, Types::GroupType, null: true + field :statistics, Types::ProjectStatisticsType, + null: false, + resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(obj.id).find } + field :repository, Types::RepositoryType, null: false field :merge_requests, diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 40d7de1a49a..536bdb077ad 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -14,6 +14,11 @@ module Types resolver: Resolvers::GroupResolver, description: "Find a group" + field :namespace, Types::NamespaceType, + null: true, + resolver: Resolvers::NamespaceResolver, + description: "Find a namespace" + field :metadata, Types::MetadataType, null: true, resolver: Resolvers::MetadataResolver, diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 971d1052824..4469118f065 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -160,6 +160,7 @@ module ApplicationSettingsHelper :akismet_api_key, :akismet_enabled, :allow_local_requests_from_hooks_and_services, + :dns_rebinding_protection_enabled, :archive_builds_in_human_readable, :authorized_keys_enabled, :auto_devops_enabled, diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index dc0e5511fcf..2beb081ab77 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -98,16 +98,17 @@ module EmailsHelper case format when :html - " via merge request #{link_to(merge_request.to_reference, merge_request.web_url)}" + merge_request_link = link_to(merge_request.to_reference, merge_request.web_url) + _("via merge request %{link}").html_safe % { link: merge_request_link } else # If it's not HTML nor text then assume it's text to be safe - " via merge request #{merge_request.to_reference} (#{merge_request.web_url})" + _("via merge request %{link}") % { link: "#{merge_request.to_reference} (#{merge_request.web_url})" } end when String # Technically speaking this should be Commit but per # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/15610#note_163812339 # we can't deserialize Commit without custom serializer for ActiveJob - " via #{closed_via}" + _("via %{closed_via}") % { closed_via: closed_via } else "" end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 991ca42c445..2de4e92e33e 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -103,7 +103,7 @@ module MergeRequestsHelper def merge_params(merge_request) { - merge_when_pipeline_succeeds: true, + auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS, should_remove_source_branch: true, sha: merge_request.diff_head_sha, squash: merge_request.squash diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index a7ce7667916..11b9cf22142 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -100,4 +100,8 @@ module NotificationsHelper css_class: "icon notifications-icon js-notifications-icon" ) end + + def show_unsubscribe_title?(noteable) + can?(current_user, "read_#{noteable.to_ability_name}".to_sym, noteable) + end end diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index e51619b0f9c..904d650ef96 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -21,6 +21,7 @@ module ApplicationSettingImplementation after_sign_up_text: nil, akismet_enabled: false, allow_local_requests_from_hooks_and_services: false, + dns_rebinding_protection_enabled: true, authorized_keys_enabled: true, # TODO default to false if the instance is configured to use AuthorizedKeysCommand container_registry_token_expire_delay: 5, default_artifacts_expire_in: '30 days', diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index f743fca423a..aaa326afea5 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -15,7 +15,6 @@ module Ci include Gitlab::Utils::StrongMemoize include Deployable include HasRef - include UpdateProjectStatistics BuildArchivedError = Class.new(StandardError) diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 0dbeab30498..f80e98e5bca 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -59,7 +59,7 @@ module Ci validate :valid_file_format?, unless: :trace?, on: :create before_save :set_size, if: :file_changed? - update_project_statistics stat: :build_artifacts_size + update_project_statistics project_statistics_name: :build_artifacts_size after_save :update_file_store, if: :saved_change_to_file? diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 80401ca0a1e..3727a9861aa 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -166,6 +166,16 @@ module Ci end end + after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline| + pipeline.run_after_commit do + pipeline.all_merge_requests.each do |merge_request| + next unless merge_request.auto_merge_enabled? + + AutoMergeProcessWorker.perform_async(merge_request.id) + end + end + end + after_transition any => [:success, :failed] do |pipeline| pipeline.run_after_commit do PipelineNotificationWorker.perform_async(pipeline.id) diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index c0a0ca9acf6..c40ad39be61 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -27,9 +27,13 @@ module Ci scope :active, -> { where(active: true) } scope :inactive, -> { where(active: false) } + scope :runnable_schedules, -> { active.where("next_run_at < ?", Time.now) } + scope :preloaded, -> { preload(:owner, :project) } accepts_nested_attributes_for :variables, allow_destroy: true + alias_attribute :real_next_run, :next_run_at + def owned_by?(current_user) owner == current_user end @@ -46,8 +50,14 @@ module Ci update_attribute(:active, false) end + ## + # The `next_run_at` column is set to the actual execution date of `PipelineScheduleWorker`. + # This way, a schedule like `*/1 * * * *` won't be triggered in a short interval + # when PipelineScheduleWorker runs irregularly by Sidekiq Memory Killer. def set_next_run_at - self.next_run_at = Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now) + self.next_run_at = Gitlab::Ci::CronParser.new(Settings.cron_jobs['pipeline_schedule_worker']['cron'], + Time.zone.name) + .next_time_from(ideal_next_run_at) end def schedule_next_run! @@ -56,15 +66,14 @@ module Ci update_attribute(:next_run_at, nil) # update without validation end - def real_next_run( - worker_cron: Settings.cron_jobs['pipeline_schedule_worker']['cron'], - worker_time_zone: Time.zone.name) - Gitlab::Ci::CronParser.new(worker_cron, worker_time_zone) - .next_time_from(next_run_at) - end - def job_variables variables&.map(&:to_runner_variable) || [] end + + private + + def ideal_next_run_at + Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now) + end end end diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index 9fbf5d8af04..d5a3bd62e3d 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -15,9 +15,6 @@ module Clusters include ::Clusters::Concerns::ApplicationVersion include ::Clusters::Concerns::ApplicationData include AfterCommitQueue - include ReactiveCaching - - self.reactive_cache_key = ->(knative) { [knative.class.model_name.singular, knative.id] } def set_initial_status return unless not_installable? @@ -41,8 +38,6 @@ module Clusters scope :for_cluster, -> (cluster) { where(cluster: cluster) } - after_save :clear_reactive_cache! - def chart 'knative/knative' end @@ -77,55 +72,12 @@ module Clusters ClusterWaitForIngressIpAddressWorker.perform_async(name, id) end - def client - cluster.kubeclient.knative_client - end - - def services - with_reactive_cache do |data| - data[:services] - end - end - - def calculate_reactive_cache - { services: read_services, pods: read_pods } - end - def ingress_service cluster.kubeclient.get_service('istio-ingressgateway', 'istio-system') end - def services_for(ns: namespace) - return [] unless services - return [] unless ns - - services.select do |service| - service.dig('metadata', 'namespace') == ns - end - end - - def service_pod_details(ns, service) - with_reactive_cache do |data| - data[:pods].select { |pod| filter_pods(pod, ns, service) } - end - end - private - def read_pods - cluster.kubeclient.core_client.get_pods.as_json - end - - def filter_pods(pod, namespace, service) - pod["metadata"]["namespace"] == namespace && pod["metadata"]["labels"]["serving.knative.dev/service"] == service - end - - def read_services - client.get_services.as_json - rescue Kubeclient::ResourceNotFoundError - [] - end - def install_knative_metrics ["kubectl apply -f #{METRICS_CONFIG}"] if cluster.application_prometheus_available? end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 0dff91c3fe2..db7fd8524c2 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ApplicationRecord - VERSION = '0.5.1'.freeze + VERSION = '0.5.2'.freeze self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 57a1e461b2d..e1d6b2a802b 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -223,6 +223,10 @@ module Clusters end end + def knative_services_finder(project) + @knative_services_finder ||= KnativeServicesFinder.new(self, project) + end + private def instance_domain diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index bfd0c36942b..4b428b0af83 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -3,14 +3,16 @@ module Noteable extend ActiveSupport::Concern - # `Noteable` class names that support resolvable notes. - RESOLVABLE_TYPES = %w(MergeRequest).freeze - class_methods do # `Noteable` class names that support replying to individual notes. def replyable_types %w(Issue MergeRequest) end + + # `Noteable` class names that support resolvable notes. + def resolvable_types + %w(MergeRequest) + end end # The timestamp of the note (e.g. the :created_at or :updated_at attribute if provided via @@ -36,7 +38,7 @@ module Noteable end def supports_resolvable_notes? - RESOLVABLE_TYPES.include?(base_class_name) + self.class.resolvable_types.include?(base_class_name) end def supports_discussions? @@ -131,3 +133,5 @@ module Noteable ) end end + +Noteable.extend(Noteable::ClassMethods) diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb index 58143a32fdc..4a506146de3 100644 --- a/app/models/concerns/referable.rb +++ b/app/models/concerns/referable.rb @@ -73,6 +73,7 @@ module Referable (?<url> #{Regexp.escape(Gitlab.config.gitlab.url)} \/#{Project.reference_pattern} + (?:\/\-)? \/#{Regexp.escape(route)} \/#{pattern} (?<path> diff --git a/app/models/concerns/resolvable_note.rb b/app/models/concerns/resolvable_note.rb index 16ea330701d..2d2d5fb7168 100644 --- a/app/models/concerns/resolvable_note.rb +++ b/app/models/concerns/resolvable_note.rb @@ -12,7 +12,7 @@ module ResolvableNote validates :resolved_by, presence: true, if: :resolved? # Keep this scope in sync with `#potentially_resolvable?` - scope :potentially_resolvable, -> { where(type: RESOLVABLE_TYPES).where(noteable_type: Noteable::RESOLVABLE_TYPES) } + scope :potentially_resolvable, -> { where(type: RESOLVABLE_TYPES).where(noteable_type: Noteable.resolvable_types) } # Keep this scope in sync with `#resolvable?` scope :resolvable, -> { potentially_resolvable.user } diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb index 67e1f0ec930..1f881249322 100644 --- a/app/models/concerns/update_project_statistics.rb +++ b/app/models/concerns/update_project_statistics.rb @@ -5,43 +5,47 @@ # It deals with `ProjectStatistics.increment_statistic` making sure not to update statistics on a cascade delete from the # project, and keeping track of value deltas on each save. It updates the DB only when a change is needed. # -# How to use -# - Invoke `update_project_statistics stat: :a_project_statistics_column, attribute: :an_attr_to_track` in a model class body. +# Example: # -# Expectation -# - `attribute` must be an ActiveRecord attribute +# module Ci +# class JobArtifact < ApplicationRecord +# include UpdateProjectStatistics +# +# update_project_statistics project_statistics_name: :build_artifacts_size +# end +# end +# +# Expectation: +# +# - `statistic_attribute` must be an ActiveRecord attribute # - The model must implement `project` and `project_id`. i.e. direct Project relationship or delegation +# module UpdateProjectStatistics extend ActiveSupport::Concern class_methods do - attr_reader :statistic_name, :statistic_attribute + attr_reader :project_statistics_name, :statistic_attribute - # Configure the model to update +stat+ on ProjectStatistics when +attribute+ changes + # Configure the model to update `project_statistics_name` on ProjectStatistics, + # when `statistic_attribute` changes + # + # - project_statistics_name: A column of `ProjectStatistics` to update + # - statistic_attribute: An attribute of the current model, default to `size` # - # +stat+:: a column of ProjectStatistics to update - # +attribute+:: an attribute of the current model, default to +:size+ - def update_project_statistics(stat:, attribute: :size) - @statistic_name = stat - @statistic_attribute = attribute + def update_project_statistics(project_statistics_name:, statistic_attribute: :size) + @project_statistics_name = project_statistics_name + @statistic_attribute = statistic_attribute after_save(:update_project_statistics_after_save, if: :update_project_statistics_attribute_changed?) after_destroy(:update_project_statistics_after_destroy, unless: :project_destroyed?) end + private :update_project_statistics end included do private - def project_destroyed? - project.pending_delete? - end - - def update_project_statistics_attribute_changed? - saved_change_to_attribute?(self.class.statistic_attribute) - end - def update_project_statistics_after_save attr = self.class.statistic_attribute delta = read_attribute(attr).to_i - attribute_before_last_save(attr).to_i @@ -49,12 +53,20 @@ module UpdateProjectStatistics update_project_statistics(delta) end + def update_project_statistics_attribute_changed? + saved_change_to_attribute?(self.class.statistic_attribute) + end + def update_project_statistics_after_destroy update_project_statistics(-read_attribute(self.class.statistic_attribute).to_i) end + def project_destroyed? + project.pending_delete? + end + def update_project_statistics(delta) - ProjectStatistics.increment_statistic(project_id, self.class.statistic_name, delta) + ProjectStatistics.increment_statistic(project_id, self.class.project_statistics_name, delta) end end end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index feabea9b8ba..1a87fc47c56 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -15,7 +15,9 @@ class DiffNote < Note validates :original_position, presence: true validates :position, presence: true validates :line_code, presence: true, line_code: true, if: :on_text? - validates :noteable_type, inclusion: { in: noteable_types } + # We need to evaluate the `noteable` types when running the validation since + # EE might have added a type when the module was prepended + validates :noteable_type, inclusion: { in: -> (_note) { noteable_types } } validate :positions_complete validate :verify_supported validate :diff_refs_match_commit, if: :for_commit? @@ -44,7 +46,7 @@ class DiffNote < Note # Returns the diff file from `position` def latest_diff_file strong_memoize(:latest_diff_file) do - position.diff_file(project.repository) + position.diff_file(repository) end end @@ -111,7 +113,7 @@ class DiffNote < Note if note_diff_file diff = Gitlab::Git::Diff.new(note_diff_file.to_hash) Gitlab::Diff::File.new(diff, - repository: project.repository, + repository: repository, diff_refs: original_position.diff_refs) elsif created_at_diff?(noteable.diff_refs) # We're able to use the already persisted diffs (Postgres) if we're @@ -122,7 +124,7 @@ class DiffNote < Note # `Diff::FileCollection::MergeRequestDiff`. noteable.diffs(original_position.diff_options).diff_files.first else - original_position.diff_file(self.project.repository) + original_position.diff_file(repository) end # Since persisted diff files already have its content "unfolded" @@ -137,7 +139,7 @@ class DiffNote < Note end def set_line_code - self.line_code = self.position.line_code(self.project.repository) + self.line_code = self.position.line_code(repository) end def verify_supported @@ -171,6 +173,10 @@ class DiffNote < Note shas << self.position.head_sha end - project.repository.keep_around(*shas) + repository.keep_around(*shas) + end + + def repository + noteable.respond_to?(:repository) ? noteable.repository : project.repository end end diff --git a/app/models/key.rb b/app/models/key.rb index b097be8cc89..8aa25924c28 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -59,6 +59,11 @@ class Key < ApplicationRecord "key-#{id}" end + # EE overrides this + def can_delete? + true + end + # rubocop: disable CodeReuse/ServiceClass def update_last_used_at Keys::LastUsedService.new(self).execute diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 311ba1ce6bd..59416fb4b51 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -165,7 +165,7 @@ class MergeRequest < ApplicationRecord validates :source_branch, presence: true validates :target_project, presence: true validates :target_branch, presence: true - validates :merge_user, presence: true, if: :merge_when_pipeline_succeeds?, unless: :importing? + validates :merge_user, presence: true, if: :auto_merge_enabled?, unless: :importing? validate :validate_branches, unless: [:allow_broken, :importing?, :closed_without_fork?] validate :validate_fork, unless: :closed_without_fork? validate :validate_target_project, on: :create @@ -196,6 +196,7 @@ class MergeRequest < ApplicationRecord alias_attribute :project, :target_project alias_attribute :project_id, :target_project_id + alias_attribute :auto_merge_enabled, :merge_when_pipeline_succeeds def self.reference_prefix '!' @@ -391,7 +392,7 @@ class MergeRequest < ApplicationRecord def merge_participants participants = [author] - if merge_when_pipeline_succeeds? && !participants.include?(merge_user) + if auto_merge_enabled? && !participants.include?(merge_user) participants << merge_user end @@ -588,6 +589,8 @@ class MergeRequest < ApplicationRecord return end + [:source_branch, :target_branch].each { |attr| validate_branch_name(attr) } + if opened? similar_mrs = target_project .merge_requests @@ -608,6 +611,16 @@ class MergeRequest < ApplicationRecord end end + def validate_branch_name(attr) + return unless changes_include?(attr) + + branch = read_attribute(attr) + + return unless branch + + errors.add(attr) unless Gitlab::GitRefValidator.validate_merge_request_branch(branch) + end + def validate_target_project return true if target_project.merge_requests_enabled? @@ -712,19 +725,16 @@ class MergeRequest < ApplicationRecord MergeRequests::ReloadDiffsService.new(self, current_user).execute end - # rubocop: enable CodeReuse/ServiceClass - - def check_if_can_be_merged - return unless self.class.state_machines[:merge_status].check_state?(merge_status) && Gitlab::Database.read_write? - can_be_merged = - !broken? && project.repository.can_be_merged?(diff_head_sha, target_branch) + def check_mergeability + MergeRequests::MergeabilityCheckService.new(self).execute + end + # rubocop: enable CodeReuse/ServiceClass - if can_be_merged - mark_as_mergeable - else - mark_as_unmergeable - end + # Returns boolean indicating the merge_status should be rechecked in order to + # switch to either can_be_merged or cannot_be_merged. + def recheck_merge_status? + self.class.state_machines[:merge_status].check_state?(merge_status) end def merge_event @@ -750,7 +760,7 @@ class MergeRequest < ApplicationRecord def mergeable?(skip_ci_check: false) return false unless mergeable_state?(skip_ci_check: skip_ci_check) - check_if_can_be_merged + check_mergeability can_be_merged? && !should_be_rebased? end @@ -765,15 +775,6 @@ class MergeRequest < ApplicationRecord true end - def mergeable_to_ref? - return false unless mergeable_state?(skip_ci_check: true, skip_discussions_check: true) - - # Given the `merge_ref_path` will have the same - # state the `target_branch` would have. Ideally - # we need to check if it can be merged to it. - project.repository.can_be_merged?(diff_head_sha, target_branch) - end - def ff_merge_possible? project.repository.ancestor?(target_branch_sha, diff_head_sha) end @@ -782,7 +783,7 @@ class MergeRequest < ApplicationRecord project.ff_merge_must_be_possible? && !ff_merge_possible? end - def can_cancel_merge_when_pipeline_succeeds?(current_user) + def can_cancel_auto_merge?(current_user) can_be_merged_by?(current_user) || self.author == current_user end @@ -801,6 +802,16 @@ class MergeRequest < ApplicationRecord Gitlab::Utils.to_boolean(merge_params['force_remove_source_branch']) end + def auto_merge_strategy + return unless auto_merge_enabled? + + merge_params['auto_merge_strategy'] || AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS + end + + def auto_merge_strategy=(strategy) + merge_params['auto_merge_strategy'] = strategy + end + def remove_source_branch? should_remove_source_branch? || force_remove_source_branch? end @@ -973,15 +984,16 @@ class MergeRequest < ApplicationRecord end end - def reset_merge_when_pipeline_succeeds - return unless merge_when_pipeline_succeeds? + def reset_auto_merge + return unless auto_merge_enabled? - self.merge_when_pipeline_succeeds = false + self.auto_merge_enabled = false self.merge_user = nil if merge_params merge_params.delete('should_remove_source_branch') merge_params.delete('commit_message') merge_params.delete('squash_commit_message') + merge_params.delete('auto_merge_strategy') end self.save @@ -1090,6 +1102,12 @@ class MergeRequest < ApplicationRecord target_project.repository.fetch_source_branch!(source_project.repository, source_branch, ref_path) end + # Returns the current merge-ref HEAD commit. + # + def merge_ref_head + project.repository.commit(merge_ref_path) + end + def ref_path "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/head" end diff --git a/app/models/project.rb b/app/models/project.rb index 20895923d3b..78d54571d94 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -407,6 +407,7 @@ class Project < ApplicationRecord scope :with_builds_enabled, -> { with_feature_enabled(:builds) } scope :with_issues_enabled, -> { with_feature_enabled(:issues) } scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) } + scope :with_merge_requests_available_for_user, ->(current_user) { with_feature_available_for_user(:merge_requests, current_user) } scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) } scope :with_remote_mirrors, -> { joins(:remote_mirrors).where(remote_mirrors: { enabled: true }).distinct } @@ -597,6 +598,17 @@ class Project < ApplicationRecord def group_ids joins(:namespace).where(namespaces: { type: 'Group' }).select(:namespace_id) end + + # Returns ids of projects with milestones available for given user + # + # Used on queries to find milestones which user can see + # For example: Milestone.where(project_id: ids_with_milestone_available_for(user)) + def ids_with_milestone_available_for(user) + with_issues_enabled = with_issues_available_for_user(user).select(:id) + with_merge_requests_enabled = with_merge_requests_available_for_user(user).select(:id) + + from_union([with_issues_enabled, with_merge_requests_enabled]).select(:id) + end end def all_pipelines diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb index 90bcb3067f6..67c12363a3c 100644 --- a/app/models/project_auto_devops.rb +++ b/app/models/project_auto_devops.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class ProjectAutoDevops < ApplicationRecord + include IgnorableColumn + + ignore_column :domain + belongs_to :project enum deploy_strategy: { @@ -12,8 +16,6 @@ class ProjectAutoDevops < ApplicationRecord scope :enabled, -> { where(enabled: true) } scope :disabled, -> { where(enabled: false) } - validates :domain, allow_blank: true, hostname: { allow_numeric_hostname: true } - after_save :create_gitlab_deploy_token, if: :needs_to_create_deploy_token? def predefined_variables diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index dd0654aec0b..11e3737298c 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -16,6 +16,8 @@ class ProjectStatistics < ApplicationRecord COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count].freeze INCREMENTABLE_COLUMNS = { build_artifacts_size: %i[storage_size], packages_size: %i[storage_size] }.freeze + scope :for_project_ids, ->(project_ids) { where(project_id: project_ids) } + def total_repository_size repository_size + lfs_objects_size end diff --git a/app/models/repository.rb b/app/models/repository.rb index d43f991bb3e..e05d3dd58ac 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -1077,7 +1077,7 @@ class Repository end def rebase(user, merge_request) - if Feature.disabled?(:two_step_rebase, default_enabled: false) + if Feature.disabled?(:two_step_rebase, default_enabled: true) return rebase_deprecated(user, merge_request) end diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb index c12a202efbc..c9dc0dbf443 100644 --- a/app/presenters/issue_presenter.rb +++ b/app/presenters/issue_presenter.rb @@ -4,6 +4,16 @@ class IssuePresenter < Gitlab::View::Presenter::Delegated presents :issue def web_url - Gitlab::UrlBuilder.build(issue) + url_builder.url + end + + def issue_path + url_builder.issue_path(issue) + end + + private + + def url_builder + @url_builder ||= Gitlab::UrlBuilder.new(issue) end end diff --git a/app/presenters/member_presenter.rb b/app/presenters/member_presenter.rb index 9e9b6973b8e..2561c3f0244 100644 --- a/app/presenters/member_presenter.rb +++ b/app/presenters/member_presenter.rb @@ -32,6 +32,11 @@ class MemberPresenter < Gitlab::View::Presenter::Delegated request? && can_update? end + # This functionality is only available in EE. + def can_override? + false + end + private def admin_member_permission diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index ba0711ca867..9c44ed711a6 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -22,9 +22,9 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end end - def cancel_merge_when_pipeline_succeeds_path - if can_cancel_merge_when_pipeline_succeeds?(current_user) - cancel_merge_when_pipeline_succeeds_project_merge_request_path(project, merge_request) + def cancel_auto_merge_path + if can_cancel_auto_merge?(current_user) + cancel_auto_merge_project_merge_request_path(project, merge_request) end end diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index b130f447cce..a428930dbbf 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -9,7 +9,11 @@ class MergeRequestWidgetEntity < IssuableEntity expose :merge_params expose :merge_status expose :merge_user_id - expose :merge_when_pipeline_succeeds + expose :auto_merge_enabled + expose :auto_merge_strategy + expose :available_auto_merge_strategies do |merge_request| + AutoMergeService.new(merge_request.project, current_user).available_strategies(merge_request) # rubocop: disable CodeReuse/ServiceClass + end expose :source_branch expose :source_branch_protected do |merge_request| merge_request.source_project.present? && ProtectedBranch.protected?(merge_request.source_project, merge_request.source_branch) @@ -182,8 +186,8 @@ class MergeRequestWidgetEntity < IssuableEntity presenter(merge_request).remove_wip_path end - expose :cancel_merge_when_pipeline_succeeds_path do |merge_request| - presenter(merge_request).cancel_merge_when_pipeline_succeeds_path + expose :cancel_auto_merge_path do |merge_request| + presenter(merge_request).cancel_auto_merge_path end expose :create_issue_to_resolve_discussions_path do |merge_request| diff --git a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb new file mode 100644 index 00000000000..d0586468859 --- /dev/null +++ b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module AutoMerge + class MergeWhenPipelineSucceedsService < BaseService + def execute(merge_request) + return :failed unless merge_request.actual_head_pipeline + + if merge_request.actual_head_pipeline.active? + merge_request.merge_params.merge!(params) + + unless merge_request.auto_merge_enabled? + merge_request.auto_merge_enabled = true + merge_request.merge_user = @current_user + merge_request.auto_merge_strategy = AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS + + SystemNoteService.merge_when_pipeline_succeeds(merge_request, @project, @current_user, merge_request.diff_head_commit) + end + + return :failed unless merge_request.save + + :merge_when_pipeline_succeeds + elsif merge_request.actual_head_pipeline.success? + # This can be triggered when a user clicks the auto merge button while + # the tests finish at about the same time + merge_request.merge_async(current_user.id, merge_params) + + :success + else + :failed + end + end + + def process(merge_request) + return unless merge_request.actual_head_pipeline&.success? + return unless merge_request.mergeable? + + merge_request.merge_async(merge_request.merge_user_id, merge_request.merge_params) + end + + def cancel(merge_request) + if merge_request.reset_auto_merge + SystemNoteService.cancel_merge_when_pipeline_succeeds(merge_request, @project, @current_user) + + success + else + error("Can't cancel the automatic merge", 406) + end + end + + def available_for?(merge_request) + merge_request.actual_head_pipeline&.active? + end + end +end diff --git a/app/services/auto_merge_service.rb b/app/services/auto_merge_service.rb new file mode 100644 index 00000000000..a3a780ff388 --- /dev/null +++ b/app/services/auto_merge_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class AutoMergeService < BaseService + STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS = 'merge_when_pipeline_succeeds'.freeze + STRATEGIES = [STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS].freeze + + class << self + def all_strategies + STRATEGIES + end + + def get_service_class(strategy) + return unless all_strategies.include?(strategy) + + "::AutoMerge::#{strategy.camelize}Service".constantize + end + end + + def execute(merge_request, strategy) + service = get_service_instance(strategy) + + return :failed unless service&.available_for?(merge_request) + + service.execute(merge_request) + end + + def process(merge_request) + return unless merge_request.auto_merge_enabled? + + get_service_instance(merge_request.auto_merge_strategy).process(merge_request) + end + + def cancel(merge_request) + return error("Can't cancel the automatic merge", 406) unless merge_request.auto_merge_enabled? + + get_service_instance(merge_request.auto_merge_strategy).cancel(merge_request) + end + + def available_strategies(merge_request) + self.class.all_strategies.select do |strategy| + get_service_instance(strategy).available_for?(merge_request) + end + end + + private + + def get_service_instance(strategy) + self.class.get_service_class(strategy)&.new(project, current_user, params) + end +end diff --git a/app/services/ci/pipeline_schedule_service.rb b/app/services/ci/pipeline_schedule_service.rb new file mode 100644 index 00000000000..387d0351490 --- /dev/null +++ b/app/services/ci/pipeline_schedule_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Ci + class PipelineScheduleService < BaseService + def execute(schedule) + # Ensure `next_run_at` is set properly before creating a pipeline. + # Otherwise, multiple pipelines could be created in a short interval. + schedule.schedule_next_run! + + RunPipelineScheduleWorker.perform_async(schedule.id, schedule.owner.id) + end + end +end diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index 2a19e57a94f..805721212ba 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -29,7 +29,7 @@ module Issues event_service.close_issue(issue, current_user) create_note(issue, closed_via) if system_note - closed_via = "commit #{closed_via.id}" if closed_via.is_a?(Commit) + closed_via = _("commit %{commit_id}") % { commit_id: closed_via.id } if closed_via.is_a?(Commit) notification_service.async.close_issue(issue, current_user, closed_via: closed_via) if notifications todo_service.close_issue(issue, current_user) diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb index e77051bb1c9..b0f6166ea1c 100644 --- a/app/services/merge_requests/close_service.rb +++ b/app/services/merge_requests/close_service.rb @@ -18,6 +18,7 @@ module MergeRequests invalidate_cache_counts(merge_request, users: merge_request.assignees) merge_request.update_project_counter_caches cleanup_environments(merge_request) + cancel_auto_merge(merge_request) end merge_request @@ -33,5 +34,9 @@ module MergeRequests merge_request_metrics_service(merge_request).close(close_event) end end + + def cancel_auto_merge(merge_request) + AutoMergeService.new(project, current_user).cancel(merge_request) + end end end diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb index 87147d90c32..8670b9ccf3d 100644 --- a/app/services/merge_requests/merge_to_ref_service.rb +++ b/app/services/merge_requests/merge_to_ref_service.rb @@ -20,20 +20,14 @@ module MergeRequests raise_error('Conflicts detected during merge') unless commit_id - commit = project.commit(commit_id) - target_id, source_id = commit.parent_ids - - success(commit_id: commit.id, - target_id: target_id, - source_id: source_id) - rescue MergeError => error + success(commit_id: commit_id) + rescue MergeError, ArgumentError => error error(error.message) end private def validate! - authorization_check! error_check! end @@ -43,21 +37,13 @@ module MergeRequests error = if !hooks_validation_pass?(merge_request) hooks_validation_error(merge_request) - elsif !@merge_request.mergeable_to_ref? - "Merge request is not mergeable to #{target_ref}" - elsif !source + elsif source.blank? 'No source for merge' end raise_error(error) if error end - def authorization_check! - unless Ability.allowed?(current_user, :admin_merge_request, project) - raise_error("You are not allowed to merge to this ref") - end - end - def target_ref merge_request.merge_ref_path end diff --git a/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb b/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb deleted file mode 100644 index 973e5b64e88..00000000000 --- a/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -module MergeRequests - class MergeWhenPipelineSucceedsService < MergeRequests::BaseService - # Marks the passed `merge_request` to be merged when the pipeline succeeds or - # updates the params for the automatic merge - def execute(merge_request) - merge_request.merge_params.merge!(params) - - # The service is also called when the merge params are updated. - already_approved = merge_request.merge_when_pipeline_succeeds? - - unless already_approved - merge_request.merge_when_pipeline_succeeds = true - merge_request.merge_user = @current_user - - SystemNoteService.merge_when_pipeline_succeeds(merge_request, @project, @current_user, merge_request.diff_head_commit) - end - - merge_request.save - end - - # Triggers the automatic merge of merge_request once the pipeline succeeds - def trigger(pipeline) - return unless pipeline.success? - - pipeline_merge_requests(pipeline) do |merge_request| - next unless merge_request.merge_when_pipeline_succeeds? - next unless merge_request.mergeable? - - merge_request.merge_async(merge_request.merge_user_id, merge_request.merge_params) - end - end - - # Cancels the automatic merge - def cancel(merge_request) - if merge_request.merge_when_pipeline_succeeds? && merge_request.open? - merge_request.reset_merge_when_pipeline_succeeds - SystemNoteService.cancel_merge_when_pipeline_succeeds(merge_request, @project, @current_user) - - success - else - error("Can't cancel the automatic merge", 406) - end - end - end -end diff --git a/app/services/merge_requests/mergeability_check_service.rb b/app/services/merge_requests/mergeability_check_service.rb new file mode 100644 index 00000000000..ef833774e65 --- /dev/null +++ b/app/services/merge_requests/mergeability_check_service.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module MergeRequests + class MergeabilityCheckService < ::BaseService + include Gitlab::Utils::StrongMemoize + + delegate :project, to: :@merge_request + delegate :repository, to: :project + + def initialize(merge_request) + @merge_request = merge_request + end + + # Updates the MR merge_status. Whenever it switches to a can_be_merged state, + # the merge-ref is refreshed. + # + # Returns a ServiceResponse indicating merge_status is/became can_be_merged + # and the merge-ref is synced. Success in case of being/becoming mergeable, + # error otherwise. + def execute + return ServiceResponse.error(message: 'Invalid argument') unless merge_request + return ServiceResponse.error(message: 'Unsupported operation') if Gitlab::Database.read_only? + + update_merge_status + + unless merge_request.can_be_merged? + return ServiceResponse.error(message: 'Merge request is not mergeable') + end + + unless payload.fetch(:merge_ref_head) + return ServiceResponse.error(message: 'Merge ref was not found') + end + + ServiceResponse.success(payload: payload) + end + + private + + attr_reader :merge_request + + def payload + strong_memoize(:payload) do + { + merge_ref_head: merge_ref_head_payload + } + end + end + + def merge_ref_head_payload + commit = merge_request.merge_ref_head + + return unless commit + + target_id, source_id = commit.parent_ids + + { + commit_id: commit.id, + source_id: source_id, + target_id: target_id + } + end + + def update_merge_status + return unless merge_request.recheck_merge_status? + + if can_git_merge? + merge_to_ref && merge_request.mark_as_mergeable + else + merge_request.mark_as_unmergeable + end + end + + def can_git_merge? + !merge_request.broken? && repository.can_be_merged?(merge_request.diff_head_sha, merge_request.target_branch) + end + + def merge_to_ref + result = MergeRequests::MergeToRefService.new(project, merge_request.author).execute(merge_request) + result[:status] == :success + end + end +end diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index 3abea1ad1ae..08130a531ee 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -24,7 +24,7 @@ module MergeRequests reload_merge_requests outdate_suggestions refresh_pipelines_on_merge_requests - reset_merge_when_pipeline_succeeds + cancel_auto_merge mark_pending_todos_done cache_merge_requests_closing_issues @@ -142,8 +142,10 @@ module MergeRequests end end - def reset_merge_when_pipeline_succeeds - merge_requests_for_source_branch.each(&:reset_merge_when_pipeline_succeeds) + def cancel_auto_merge + merge_requests_for_source_branch.each do |merge_request| + AutoMergeService.new(project, current_user).cancel(merge_request) + end end def mark_pending_todos_done diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 55546432ce4..6a0f3000ffb 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -89,7 +89,7 @@ module MergeRequests merge_request.update(merge_error: nil) if merge_request.head_pipeline && merge_request.head_pipeline.active? - MergeRequests::MergeWhenPipelineSucceedsService.new(project, current_user).execute(merge_request) + AutoMergeService.new(project, current_user).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) else merge_request.merge_async(current_user.id, {}) end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index f797c0f11c6..5aa804666f0 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -238,7 +238,7 @@ class NotificationService merge_request, current_user, :merged_merge_request_email, - skip_current_user: !merge_request.merge_when_pipeline_succeeds? + skip_current_user: !merge_request.auto_merge_enabled? ) end diff --git a/app/services/service_response.rb b/app/services/service_response.rb index 1de30e68d87..f3437ba16de 100644 --- a/app/services/service_response.rb +++ b/app/services/service_response.rb @@ -1,19 +1,20 @@ # frozen_string_literal: true class ServiceResponse - def self.success(message: nil) - new(status: :success, message: message) + def self.success(message: nil, payload: {}) + new(status: :success, message: message, payload: payload) end - def self.error(message:, http_status: nil) - new(status: :error, message: message, http_status: http_status) + def self.error(message:, payload: {}, http_status: nil) + new(status: :error, message: message, payload: payload, http_status: http_status) end - attr_reader :status, :message, :http_status + attr_reader :status, :message, :http_status, :payload - def initialize(status:, message: nil, http_status: nil) + def initialize(status:, message: nil, payload: {}, http_status: nil) self.status = status self.message = message + self.payload = payload self.http_status = http_status end @@ -27,5 +28,5 @@ class ServiceResponse private - attr_writer :status, :message, :http_status + attr_writer :status, :message, :http_status, :payload end diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml index f4bfb5af385..dd56bb99a06 100644 --- a/app/views/admin/application_settings/_outbound.html.haml +++ b/app/views/admin/application_settings/_outbound.html.haml @@ -8,4 +8,12 @@ = f.label :allow_local_requests_from_hooks_and_services, class: 'form-check-label' do Allow requests to the local network from hooks and services + .form-group + .form-check + = f.check_box :dns_rebinding_protection_enabled, class: 'form-check-input' + = f.label :dns_rebinding_protection_enabled, class: 'form-check-label' do + = _('Enforce DNS rebinding attack protection') + %span.form-text.text-muted + = _('Resolves IP addresses once and uses them to submit requests') + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index a6023a1cbb9..496ec3c78b0 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -16,7 +16,7 @@ mr_path: merge_requests_dashboard_path }, aria: { label: _('Search or jump to…') } %button.hidden.js-dropdown-search-toggle{ type: 'button', data: { toggle: 'dropdown' } } - .dropdown-menu.dropdown-select + .dropdown-menu.dropdown-select.js-dashboard-search-options = dropdown_content do %ul %li.dropdown-menu-empty-item diff --git a/app/views/notify/closed_issue_email.html.haml b/app/views/notify/closed_issue_email.html.haml index f21cf1ad34b..d3733ab3a09 100644 --- a/app/views/notify/closed_issue_email.html.haml +++ b/app/views/notify/closed_issue_email.html.haml @@ -1,2 +1,2 @@ %p - Issue was closed by #{sanitize_name(@updated_by.name)} #{closure_reason_text(@closed_via, format: formats.first)}. + = _("Issue was closed by %{name} %{reason}").html_safe % { name: sanitize_name(@updated_by.name), reason: closure_reason_text(@closed_via, format: formats.first) } diff --git a/app/views/notify/closed_issue_email.text.haml b/app/views/notify/closed_issue_email.text.haml index 5567adc9165..ff2548a4b42 100644 --- a/app/views/notify/closed_issue_email.text.haml +++ b/app/views/notify/closed_issue_email.text.haml @@ -1,3 +1,3 @@ -Issue was closed by #{sanitize_name(@updated_by.name)} #{closure_reason_text(@closed_via, format: formats.first)}. += _("Issue was closed by %{name} %{reason}").html_safe % { name: sanitize_name(@updated_by.name), reason: closure_reason_text(@closed_via, format: formats.first) } Issue ##{@issue.iid}: #{project_issue_url(@issue.project, @issue)} diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml index 47494fc3f06..b9d73d89334 100644 --- a/app/views/profiles/keys/_key.html.haml +++ b/app/views/profiles/keys/_key.html.haml @@ -18,6 +18,7 @@ .float-right %span.key-created-at = s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)} - = link_to path_to_key(key, is_admin), data: { confirm: _('Are you sure?')}, method: :delete, class: "btn btn-transparent prepend-left-10" do - %span.sr-only= _('Remove') - = icon('trash') + - if key.can_delete? + = link_to path_to_key(key, is_admin), data: { confirm: _('Are you sure?')}, method: :delete, class: "btn btn-transparent prepend-left-10" do + %span.sr-only= _('Remove') + = icon('trash') diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index dcdb7fc63b1..0ef01dec493 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -24,4 +24,5 @@ = @key.key .col-md-12 .float-right - = link_to _('Remove'), path_to_key(@key, is_admin), data: {confirm: _('Are you sure?')}, method: :delete, class: "btn btn-remove delete-key qa-delete-key-button" + - if @key.can_delete? + = link_to _('Remove'), path_to_key(@key, is_admin), data: {confirm: _('Are you sure?')}, method: :delete, class: "btn btn-remove delete-key qa-delete-key-button" diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 771e1881e94..87b9920e8b4 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -15,7 +15,7 @@ %li.commit.flex-row.js-toggle-container{ id: "commit-#{commit.short_id}" } .avatar-cell.d-none.d-sm-block - = author_avatar(commit, size: 36, has_tooltip: false) + = author_avatar(commit, size: 40, has_tooltip: false) .commit-detail.flex-list .commit-content.qa-commit-content diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml index 9409418bbcc..82c1d57c97e 100644 --- a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml @@ -18,22 +18,22 @@ .help-form .form-group - = label_tag :display_name, 'Display name', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :display_name, 'Display name', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :display_name, "GitLab / #{@project.full_name}", class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#display_name', class: 'input-group-text') .form-group - = label_tag :description, 'Description', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :description, 'Description', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :description, run_actions_text, class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#description', class: 'input-group-text') .form-group - = label_tag nil, 'Command trigger word', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block + = label_tag nil, 'Command trigger word', class: 'col-12 col-form-label label-bold' + .col-12 %p Fill in the word that works best for your team. %p Suggestions: @@ -42,44 +42,44 @@ %code= @project.full_path .form-group - = label_tag :request_url, 'Request URL', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :request_url, 'Request URL', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :request_url, service_trigger_url(subject), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#request_url', class: 'input-group-text') .form-group - = label_tag nil, 'Request method', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block POST + = label_tag nil, 'Request method', class: 'col-12 col-form-label label-bold' + .col-12 POST .form-group - = label_tag :response_username, 'Response username', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :response_username, 'Response username', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :response_username, 'GitLab', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#response_username', class: 'input-group-text') .form-group - = label_tag :response_icon, 'Response icon', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :response_icon, 'Response icon', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :response_icon, asset_url('gitlab_logo.png'), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#response_icon', class: 'input-group-text') .form-group - = label_tag nil, 'Autocomplete', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block Yes + = label_tag nil, 'Autocomplete', class: 'col-12 col-form-label label-bold' + .col-12 Yes .form-group - = label_tag :autocomplete_hint, 'Autocomplete hint', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :autocomplete_hint, 'Autocomplete hint', class: 'col-12 col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :autocomplete_hint, '[help]', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#autocomplete_hint', class: 'input-group-text') .form-group - = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :autocomplete_description, run_actions_text, class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#autocomplete_description', class: 'input-group-text') diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml index 9a7004f89c0..9b7732abc62 100644 --- a/app/views/projects/services/slack_slash_commands/_help.html.haml +++ b/app/views/projects/services/slack_slash_commands/_help.html.haml @@ -27,8 +27,8 @@ .help-form .form-group - = label_tag nil, 'Command', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block + = label_tag nil, 'Command', class: 'col-12 col-form-label label-bold' + .col-12 %p Fill in the word that works best for your team. %p Suggestions: @@ -37,50 +37,50 @@ %code= @project.full_path .form-group - = label_tag :url, 'URL', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :url, 'URL', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :url, service_trigger_url(subject), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#url', class: 'input-group-text') .form-group - = label_tag nil, 'Method', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block POST + = label_tag nil, 'Method', class: 'col-12 col-form-label label-bold' + .col-12 POST .form-group - = label_tag :customize_name, 'Customize name', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :customize_name, 'Customize name', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :customize_name, 'GitLab', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#customize_name', class: 'input-group-text') .form-group - = label_tag nil, 'Customize icon', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block - = image_tag(asset_url('slash-command-logo.png'), width: 36, height: 36) + = label_tag nil, 'Customize icon', class: 'col-12 col-form-label label-bold' + .col-12 + = image_tag(asset_url('slash-command-logo.png'), width: 36, height: 36, class: 'mr-3') = link_to('Download image', asset_url('gitlab_logo.png'), class: 'btn btn-sm', target: '_blank', rel: 'noopener noreferrer') .form-group - = label_tag nil, 'Autocomplete', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block Show this command in the autocomplete list + = label_tag nil, 'Autocomplete', class: 'col-12 col-form-label label-bold' + .col-12 Show this command in the autocomplete list .form-group - = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :autocomplete_description, run_actions_text, class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#autocomplete_description', class: 'input-group-text') .form-group - = label_tag :autocomplete_usage_hint, 'Autocomplete usage hint', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :autocomplete_usage_hint, 'Autocomplete usage hint', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :autocomplete_usage_hint, '[help]', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#autocomplete_usage_hint', class: 'input-group-text') .form-group - = label_tag :descriptive_label, 'Descriptive label', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :descriptive_label, 'Descriptive label', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :descriptive_label, 'Perform common operations on GitLab project', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#descriptive_label', class: 'input-group-text') diff --git a/app/views/projects/settings/operations/_external_dashboard.html.haml b/app/views/projects/settings/operations/_external_dashboard.html.haml index f049c35b38d..a124283921d 100644 --- a/app/views/projects/settings/operations/_external_dashboard.html.haml +++ b/app/views/projects/settings/operations/_external_dashboard.html.haml @@ -1,2 +1,3 @@ -.js-operation-settings{ data: { external_dashboard: { path: metrics_external_dashboard_url, +.js-operation-settings{ data: { operations_settings_endpoint: project_settings_operations_path(@project), + external_dashboard: { url: metrics_external_dashboard_url, help_page_path: help_page_path('user/project/operations/link_to_external_dashboard') } } } diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index 8ae2807729b..cb8a8a24be8 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -1,5 +1,6 @@ - if @search_objects.to_a.empty? = render partial: "search/results/empty" + = render_if_exists 'shared/promotions/promote_advanced_search' - else .row-content-block - unless @search_objects.is_a?(Kaminari::PaginatableWithoutCount) @@ -11,7 +12,7 @@ - elsif @group - link_to_group = link_to(@group.name, @group) = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } - + = render_if_exists 'shared/promotions/promote_advanced_search' .results.prepend-top-10 - if @scope == 'commits' %ul.content-list.commit-list diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml index ca392e1adfc..22fcfcda297 100644 --- a/app/views/sent_notifications/unsubscribe.html.haml +++ b/app/views/sent_notifications/unsubscribe.html.haml @@ -1,6 +1,6 @@ - noteable = @sent_notification.noteable - noteable_type = @sent_notification.noteable_type.titleize.downcase -- noteable_text = %(#{noteable.title} (#{noteable.to_reference})) +- noteable_text = show_unsubscribe_title?(noteable) ? %(#{noteable.title} (#{noteable.to_reference})) : %(#{noteable.to_reference}) - page_title _("Unsubscribe"), noteable_text, noteable_type.pluralize, @sent_notification.project.full_name %h3.page-title diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml index 3ee713cf499..d0f9374e832 100644 --- a/app/views/shared/_import_form.html.haml +++ b/app/views/shared/_import_form.html.haml @@ -1,11 +1,26 @@ - ci_cd_only = local_assigns.fetch(:ci_cd_only, false) +- import_url = Gitlab::UrlSanitizer.new(f.object.import_url) -.form-group.import-url-data - = f.label :import_url, class: 'label-bold' do - %span - = _('Git repository URL') +.import-url-data + .form-group + = f.label :import_url, class: 'label-bold' do + %span + = _('Git repository URL') + = f.text_field :import_url, value: import_url.sanitized_url, + autocomplete: 'off', class: 'form-control', placeholder: 'https://gitlab.company.com/group/project.git', required: true - = f.text_field :import_url, autocomplete: 'off', class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git', required: true + .row + .form-group.col-md-6 + = f.label :import_url_user, class: 'label-bold' do + %span + = _('Username (optional)') + = f.text_field :import_url_user, value: import_url.user, class: 'form-control', required: false, autocomplete: 'new-password' + + .form-group.col-md-6 + = f.label :import_url_password, class: 'label-bold' do + %span + = _('Password (optional)') + = f.password_field :import_url_password, class: 'form-control', required: false, autocomplete: 'new-password' .info-well.prepend-top-20 .well-segment @@ -13,7 +28,7 @@ %li = _('The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>.').html_safe %li - = _('If your HTTP repository is not publicly accessible, add authentication information to the URL: <code>https://username:password@gitlab.company.com/group/project.git</code>.').html_safe + = _('If your HTTP repository is not publicly accessible, add your credentials.') %li = import_will_timeout_message(ci_cd_only) %li diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml index 311dc69d213..c50826a7cda 100644 --- a/app/views/shared/boards/components/sidebar/_labels.html.haml +++ b/app/views/shared/boards/components/sidebar/_labels.html.haml @@ -32,7 +32,7 @@ %span.dropdown-toggle-text {{ labelDropdownTitle }} = icon('chevron-down') - .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable + .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height = render partial: "shared/issuable/label_page_default" - if can?(current_user, :admin_label, current_board_parent) = render partial: "shared/issuable/label_page_create", locals: { show_add_list: true } diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml index f2c0c77a583..483652852b6 100644 --- a/app/views/shared/issuable/_label_dropdown.html.haml +++ b/app/views/shared/issuable/_label_dropdown.html.haml @@ -25,7 +25,7 @@ %span.dropdown-toggle-text{ class: ("is-default" if apply_is_default_styles) } = multi_label_name(selected, label_name) = icon('chevron-down') - .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable + .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height = render partial: "shared/issuable/label_page_default", locals: { title: dropdown_title, show_footer: show_footer, show_create: show_create } - if show_create && project && can?(current_user, :admin_label, project) = render partial: "shared/issuable/label_page_create" diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 63557c882f4..3a5adb34ad1 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -118,7 +118,7 @@ %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) } = multi_label_name(selected_labels, "Labels") = icon('chevron-down', 'aria-hidden': 'true') - .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable + .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height = render partial: "shared/issuable/label_page_default" - if issuable_sidebar.dig(:current_user, :can_admin_label) = render partial: "shared/issuable/label_page_create" @@ -158,7 +158,7 @@ %button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button', data: { toggle: 'dropdown', display: 'static' } } = _('Move issue') - .dropdown-menu.dropdown-menu-selectable + .dropdown-menu.dropdown-menu-selectable.dropdown-extended-height = dropdown_title(_('Move issue')) = dropdown_filter(_('Search project'), search_id: 'sidebar-move-issue-dropdown-search') = dropdown_content diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 2db1f67a793..afcb2b71472 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -4,8 +4,9 @@ - member = local_assigns.fetch(:member) - user = local_assigns.fetch(:user, member.user) - source = member.source +- override = member.try(:override) -%li.member{ class: dom_class(member), id: dom_id(member) } +%li.member{ class: [dom_class(member), ("is-overridden" if override)], id: dom_id(member) } %span.list-item-name - if user = image_tag avatar_icon_for_user(user, 40), class: "avatar s40", alt: '' @@ -54,6 +55,7 @@ - if show_roles - current_resource = @project || @group .controls.member-controls + = render_if_exists 'shared/members/ee/ldap_tag', can_override: member.can_override? - if show_controls && member.source == current_resource - if member.can_resend_invite? @@ -67,6 +69,7 @@ = f.hidden_field :access_level .member-form-control.dropdown.append-right-5 %button.dropdown-menu-toggle.js-member-permissions-dropdown{ type: "button", + disabled: member.can_override? && !override, data: { toggle: "dropdown", field_name: "#{f.object_name}[access_level]" } } %span.dropdown-toggle-text = member.human_access @@ -80,8 +83,13 @@ = link_to role, "javascript:void(0)", class: ("is-active" if member.access_level == role_id), data: { id: role_id, el_id: dom_id(member) } + = render_if_exists 'shared/members/ee/revert_ldap_group_sync_option', + group: @group, + member: member, + can_override: member.can_override? .prepend-left-5.clearable-input.member-form-control = f.text_field :expires_at, + disabled: member.can_override? && !override, class: 'form-control js-access-expiration-date js-member-update-control', placeholder: _('Expiration date'), id: "member_expires_at_#{member.id}", @@ -116,5 +124,8 @@ = _("Delete") - unless force_mobile_view = icon('trash', class: 'd-none d-sm-block') + = render_if_exists 'shared/members/ee/override_member_buttons', group: @group, member: member, user: user, action: :edit, can_override: member.can_override? - else %span.member-access-text= member.human_access + += render_if_exists 'shared/members/ee/override_member_buttons', group: @group, member: member, user: user, action: :confirm, can_override: member.can_override? diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml index 46f3f8428f1..fae7d6526e8 100644 --- a/app/views/shared/notes/_hints.html.haml +++ b/app/views/shared/notes/_hints.html.haml @@ -28,8 +28,9 @@ or %button.attach-new-file.markdown-selector{ type: 'button' }= _("attach a new file") - %button.markdown-selector.button-attach-file{ type: 'button', tabindex: '-1' } + %button.markdown-selector.button-attach-file.btn-link{ type: 'button', tabindex: '-1' } = icon('file-image-o', class: 'toolbar-button-icon') - = _("Attach a file") + %span.text-attach-file<> + = _("Attach a file") %button.btn.btn-default.btn-sm.hide.button-cancel-uploading-files{ type: 'button' }= _("Cancel") diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index e4e85de93da..fd0cc5fb24e 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -1,6 +1,8 @@ --- - auto_devops:auto_devops_disable +- auto_merge:auto_merge_process + - cronjob:admin_email - cronjob:expire_build_artifacts - cronjob:gitlab_usage_ping diff --git a/app/workers/auto_merge_process_worker.rb b/app/workers/auto_merge_process_worker.rb new file mode 100644 index 00000000000..cd81cdbc60c --- /dev/null +++ b/app/workers/auto_merge_process_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AutoMergeProcessWorker + include ApplicationWorker + + queue_namespace :auto_merge + + def perform(merge_request_id) + MergeRequest.find_by_id(merge_request_id).try do |merge_request| + AutoMergeService.new(merge_request.project, merge_request.merge_user) + .process(merge_request) + end + end +end diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb index 8a9ee7808e4..9410fd1a786 100644 --- a/app/workers/pipeline_schedule_worker.rb +++ b/app/workers/pipeline_schedule_worker.rb @@ -3,47 +3,12 @@ class PipelineScheduleWorker include ApplicationWorker include CronjobQueue - include ::Gitlab::ExclusiveLeaseHelpers - EXCLUSIVE_LOCK_KEY = 'pipeline_schedules:run:lock' - LOCK_TIMEOUT = 50.minutes - - # rubocop: disable CodeReuse/ActiveRecord def perform - in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do - Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now) - .preload(:owner, :project).find_each do |schedule| - - schedule.schedule_next_run! - - Ci::CreatePipelineService.new(schedule.project, - schedule.owner, - ref: schedule.ref) - .execute!(:schedule, ignore_skip_ci: true, save_on_errors: true, schedule: schedule) - rescue => e - error(schedule, e) + Ci::PipelineSchedule.runnable_schedules.preloaded.find_in_batches do |schedules| + schedules.each do |schedule| + Ci::PipelineScheduleService.new(schedule.project, schedule.owner).execute(schedule) end end end - # rubocop: enable CodeReuse/ActiveRecord - - private - - def error(schedule, error) - failed_creation_counter.increment - - Rails.logger.error "Failed to create a scheduled pipeline. " \ - "schedule_id: #{schedule.id} message: #{error.message}" - - Gitlab::Sentry - .track_exception(error, - issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/41231', - extra: { schedule_id: schedule.id }) - end - - def failed_creation_counter - @failed_creation_counter ||= - Gitlab::Metrics.counter(:pipeline_schedule_creation_failed_total, - "Counter of failed attempts of pipeline schedule creation") - end end diff --git a/app/workers/pipeline_success_worker.rb b/app/workers/pipeline_success_worker.rb index 4f349ed922c..666331e6cd4 100644 --- a/app/workers/pipeline_success_worker.rb +++ b/app/workers/pipeline_success_worker.rb @@ -6,13 +6,7 @@ class PipelineSuccessWorker queue_namespace :pipeline_processing - # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) - Ci::Pipeline.find_by(id: pipeline_id).try do |pipeline| - MergeRequests::MergeWhenPipelineSucceedsService - .new(pipeline.project, nil) - .trigger(pipeline) - end + # no-op end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb index f72331c003a..43e0b9db22f 100644 --- a/app/workers/run_pipeline_schedule_worker.rb +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -21,6 +21,30 @@ class RunPipelineScheduleWorker Ci::CreatePipelineService.new(schedule.project, user, ref: schedule.ref) - .execute(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule) + .execute!(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule) + rescue Ci::CreatePipelineService::CreateError + # no-op. This is a user operation error such as corrupted .gitlab-ci.yml. + rescue => e + error(schedule, e) + end + + private + + def error(schedule, error) + failed_creation_counter.increment + + Rails.logger.error "Failed to create a scheduled pipeline. " \ + "schedule_id: #{schedule.id} message: #{error.message}" + + Gitlab::Sentry + .track_exception(error, + issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/41231', + extra: { schedule_id: schedule.id }) + end + + def failed_creation_counter + @failed_creation_counter ||= + Gitlab::Metrics.counter(:pipeline_schedule_creation_failed_total, + "Counter of failed attempts of pipeline schedule creation") end end diff --git a/babel.config.js b/babel.config.js index df30892731d..05554e8763e 100644 --- a/babel.config.js +++ b/babel.config.js @@ -39,7 +39,7 @@ if (BABEL_ENV === 'karma' || BABEL_ENV === 'coverage') { } // Jest is running in node environment, so we need additional plugins -const isJest = !!process.env.JEST_WORKER_ID; +const isJest = Boolean(process.env.JEST_WORKER_ID); if (isJest) { plugins.push('@babel/plugin-transform-modules-commonjs'); /* diff --git a/changelogs/unreleased/10795-add-epic-tree-BE-CE-epic-graphql-support.yml b/changelogs/unreleased/10795-add-epic-tree-BE-CE-epic-graphql-support.yml new file mode 100644 index 00000000000..4c85d4f9acb --- /dev/null +++ b/changelogs/unreleased/10795-add-epic-tree-BE-CE-epic-graphql-support.yml @@ -0,0 +1,5 @@ +--- +title: Added reference, web_path, and relative_position fields to GraphQL Issue +merge_request: 28998 +author: +type: changed diff --git a/changelogs/unreleased/50850-kerrizor-extend-api-to-accept-start_project-option.yml b/changelogs/unreleased/50850-kerrizor-extend-api-to-accept-start_project-option.yml new file mode 100644 index 00000000000..45770e1012c --- /dev/null +++ b/changelogs/unreleased/50850-kerrizor-extend-api-to-accept-start_project-option.yml @@ -0,0 +1,5 @@ +--- +title: Add API support for committing changes to different projects in same fork network +merge_request: 27915 +author: +type: added diff --git a/changelogs/unreleased/51022-added-extended-height-to-labels-dropdown.yml b/changelogs/unreleased/51022-added-extended-height-to-labels-dropdown.yml new file mode 100644 index 00000000000..07bf8b04bbe --- /dev/null +++ b/changelogs/unreleased/51022-added-extended-height-to-labels-dropdown.yml @@ -0,0 +1,5 @@ +--- +title: "Added the `.extended-height` class to the labels-dropdown" +merge_request: 28659 +author: Michel Engelen +type: other diff --git a/changelogs/unreleased/56959-drop-project_auto_devops_domain.yml b/changelogs/unreleased/56959-drop-project_auto_devops_domain.yml new file mode 100644 index 00000000000..c529749670d --- /dev/null +++ b/changelogs/unreleased/56959-drop-project_auto_devops_domain.yml @@ -0,0 +1,5 @@ +--- +title: Removes project_auto_devops#domain column +merge_request: 28574 +author: +type: other diff --git a/changelogs/unreleased/57037-fix-mr-checkboxes-mobile-alignment.yml b/changelogs/unreleased/57037-fix-mr-checkboxes-mobile-alignment.yml new file mode 100644 index 00000000000..a2de6cd6d45 --- /dev/null +++ b/changelogs/unreleased/57037-fix-mr-checkboxes-mobile-alignment.yml @@ -0,0 +1,5 @@ +--- +title: Fix Merge Request merge checkbox alignment on mobile view +merge_request: 28845 +author: +type: fixed diff --git a/changelogs/unreleased/58269-separate-update-patch.yml b/changelogs/unreleased/58269-separate-update-patch.yml new file mode 100644 index 00000000000..e9b44257b07 --- /dev/null +++ b/changelogs/unreleased/58269-separate-update-patch.yml @@ -0,0 +1,5 @@ +--- +title: Do not display Update app button when saving Knative domain name +merge_request: 28904 +author: +type: changed diff --git a/changelogs/unreleased/58941-use-gitlab-serverless-with-existing-knative-installation.yml b/changelogs/unreleased/58941-use-gitlab-serverless-with-existing-knative-installation.yml new file mode 100644 index 00000000000..53be008816d --- /dev/null +++ b/changelogs/unreleased/58941-use-gitlab-serverless-with-existing-knative-installation.yml @@ -0,0 +1,5 @@ +--- +title: Enable function features for external Knative installations +merge_request: 27173 +author: +type: changed diff --git a/changelogs/unreleased/60778-input-text-height.yml b/changelogs/unreleased/60778-input-text-height.yml deleted file mode 100644 index c956ead5db2..00000000000 --- a/changelogs/unreleased/60778-input-text-height.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix input group height -merge_request: -author: -type: other diff --git a/changelogs/unreleased/61045-charts-with-many-overlapping-series-display-incorrectly.yml b/changelogs/unreleased/61045-charts-with-many-overlapping-series-display-incorrectly.yml new file mode 100644 index 00000000000..53cc0a15417 --- /dev/null +++ b/changelogs/unreleased/61045-charts-with-many-overlapping-series-display-incorrectly.yml @@ -0,0 +1,5 @@ +--- +title: Eliminate color inconsistencies in metric graphs +merge_request: 29127 +author: +type: fixed diff --git a/changelogs/unreleased/61339-Add-underline-to-attach-a-file.yml b/changelogs/unreleased/61339-Add-underline-to-attach-a-file.yml new file mode 100644 index 00000000000..c3808709fca --- /dev/null +++ b/changelogs/unreleased/61339-Add-underline-to-attach-a-file.yml @@ -0,0 +1,5 @@ +--- +title: Add hover and focus to Attach a file +merge_request: 28682 +author: Marcel van Remmerden +type: fixed diff --git a/changelogs/unreleased/61821-tooltip-consistency.yml b/changelogs/unreleased/61821-tooltip-consistency.yml new file mode 100644 index 00000000000..9b131907ebf --- /dev/null +++ b/changelogs/unreleased/61821-tooltip-consistency.yml @@ -0,0 +1,5 @@ +--- +title: Resolve Tooltip Consistency +merge_request: 28839 +author: +type: fixed diff --git a/changelogs/unreleased/61960-translatable-strings-in-issue-closure-emails.yml b/changelogs/unreleased/61960-translatable-strings-in-issue-closure-emails.yml new file mode 100644 index 00000000000..50b3efba0a5 --- /dev/null +++ b/changelogs/unreleased/61960-translatable-strings-in-issue-closure-emails.yml @@ -0,0 +1,5 @@ +--- +title: I18n for issue closure reason in emails +merge_request: 28489 +author: Michał Zając +type: changed diff --git a/changelogs/unreleased/61988-collapse-icon-on-merge-request-diff-larger-than-profile-picture.yml b/changelogs/unreleased/61988-collapse-icon-on-merge-request-diff-larger-than-profile-picture.yml new file mode 100644 index 00000000000..46d3f439a44 --- /dev/null +++ b/changelogs/unreleased/61988-collapse-icon-on-merge-request-diff-larger-than-profile-picture.yml @@ -0,0 +1,5 @@ +--- +title: Change collapse icon size to size of profile picture +merge_request: 28512 +author: Marcel van Remmerden +type: other diff --git a/changelogs/unreleased/62092-missing-padding-next-to-time-windows-dropdown-on-metrics-dashboard.yml b/changelogs/unreleased/62092-missing-padding-next-to-time-windows-dropdown-on-metrics-dashboard.yml new file mode 100644 index 00000000000..3317d505924 --- /dev/null +++ b/changelogs/unreleased/62092-missing-padding-next-to-time-windows-dropdown-on-metrics-dashboard.yml @@ -0,0 +1,5 @@ +--- +title: Added padding to time window dropdown in monitor dashboard +merge_request: 28897 +author: +type: fixed diff --git a/changelogs/unreleased/abstract-auto-merge.yml b/changelogs/unreleased/abstract-auto-merge.yml new file mode 100644 index 00000000000..d3069a3e500 --- /dev/null +++ b/changelogs/unreleased/abstract-auto-merge.yml @@ -0,0 +1,5 @@ +--- +title: Refactor and abstract Auto Merge Processes +merge_request: 28595 +author: +type: other diff --git a/changelogs/unreleased/ac-graphql-stats.yml b/changelogs/unreleased/ac-graphql-stats.yml new file mode 100644 index 00000000000..8837dce4d89 --- /dev/null +++ b/changelogs/unreleased/ac-graphql-stats.yml @@ -0,0 +1,5 @@ +--- +title: Add Namespace and ProjectStatistics to GraphQL API +merge_request: 28277 +author: +type: added diff --git a/changelogs/unreleased/ac-graphql-wikisize.yml b/changelogs/unreleased/ac-graphql-wikisize.yml new file mode 100644 index 00000000000..be9c347ec21 --- /dev/null +++ b/changelogs/unreleased/ac-graphql-wikisize.yml @@ -0,0 +1,5 @@ +--- +title: Expose wiki_size on GraphQL API +merge_request: 29123 +author: +type: added diff --git a/changelogs/unreleased/bump-auto-devops-helm-2-14-0.yml b/changelogs/unreleased/bump-auto-devops-helm-2-14-0.yml new file mode 100644 index 00000000000..ecfbc97a8c5 --- /dev/null +++ b/changelogs/unreleased/bump-auto-devops-helm-2-14-0.yml @@ -0,0 +1,5 @@ +--- +title: Bump Helm version in Auto-DevOps.gitlab-ci.yml to 2.14.0 +merge_request: 28527 +author: +type: other diff --git a/changelogs/unreleased/cancel-auto-merge-when-merge-request-is-closed.yml b/changelogs/unreleased/cancel-auto-merge-when-merge-request-is-closed.yml new file mode 100644 index 00000000000..d38046ebcbf --- /dev/null +++ b/changelogs/unreleased/cancel-auto-merge-when-merge-request-is-closed.yml @@ -0,0 +1,5 @@ +--- +title: Cancel auto merge when merge request is closed +merge_request: 28782 +author: +type: fixed diff --git a/changelogs/unreleased/dm-disable-two-step-rebase.yml b/changelogs/unreleased/dm-disable-two-step-rebase.yml deleted file mode 100644 index 342d61a20d6..00000000000 --- a/changelogs/unreleased/dm-disable-two-step-rebase.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Disable two-step rebase which could cause rebases to hang -merge_request: 28778 -author: -type: other diff --git a/changelogs/unreleased/dm-http-hostname-override.yml b/changelogs/unreleased/dm-http-hostname-override.yml new file mode 100644 index 00000000000..f84f36a0010 --- /dev/null +++ b/changelogs/unreleased/dm-http-hostname-override.yml @@ -0,0 +1,5 @@ +--- +title: Protect Gitlab::HTTP against DNS rebinding attack +merge_request: +author: +type: security diff --git a/changelogs/unreleased/fix-search-dropdown-blur-close.yml b/changelogs/unreleased/fix-search-dropdown-blur-close.yml new file mode 100644 index 00000000000..1ac9dc674fe --- /dev/null +++ b/changelogs/unreleased/fix-search-dropdown-blur-close.yml @@ -0,0 +1,5 @@ +--- +title: Fix search dropdown not closing on blur if empty +merge_request: 28730 +author: +type: fixed diff --git a/changelogs/unreleased/gitaly-version-v1.43.0.yml b/changelogs/unreleased/gitaly-version-v1.43.0.yml new file mode 100644 index 00000000000..67acd2725e1 --- /dev/null +++ b/changelogs/unreleased/gitaly-version-v1.43.0.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade to Gitaly v1.43.0 +merge_request: 28867 +author: +type: changed diff --git a/changelogs/unreleased/increase-move-issue-dropdown-height.yml b/changelogs/unreleased/increase-move-issue-dropdown-height.yml new file mode 100644 index 00000000000..bb67e9341b2 --- /dev/null +++ b/changelogs/unreleased/increase-move-issue-dropdown-height.yml @@ -0,0 +1,5 @@ +--- +title: Increase height of move issue dropdown +merge_request: +author: +type: other diff --git a/changelogs/unreleased/jp-label-fix.yml b/changelogs/unreleased/jp-label-fix.yml deleted file mode 100644 index de64286cc1f..00000000000 --- a/changelogs/unreleased/jp-label-fix.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix display of 'Promote to group label' button. -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/osw-reset-merge-status-from-mergeable-mrs.yml b/changelogs/unreleased/osw-reset-merge-status-from-mergeable-mrs.yml new file mode 100644 index 00000000000..6b5f97f24b3 --- /dev/null +++ b/changelogs/unreleased/osw-reset-merge-status-from-mergeable-mrs.yml @@ -0,0 +1,5 @@ +--- +title: Reset merge status from mergeable MRs +merge_request: 28843 +author: +type: other diff --git a/changelogs/unreleased/osw-sync-merge-ref-upon-mergeability-check.yml b/changelogs/unreleased/osw-sync-merge-ref-upon-mergeability-check.yml new file mode 100644 index 00000000000..1f40089adb8 --- /dev/null +++ b/changelogs/unreleased/osw-sync-merge-ref-upon-mergeability-check.yml @@ -0,0 +1,5 @@ +--- +title: Sync merge ref upon mergeability check +merge_request: 28513 +author: +type: added diff --git a/changelogs/unreleased/patch-64.yml b/changelogs/unreleased/patch-64.yml deleted file mode 100644 index 1bf022e7e41..00000000000 --- a/changelogs/unreleased/patch-64.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Update SAST.gitlab-ci.yml - Add SAST_GITLEAKS_ENTROPY_LEVEL -merge_request: 28607 -author: -type: fixed diff --git a/changelogs/unreleased/pb-update-gitaly-1-45-0.yml b/changelogs/unreleased/pb-update-gitaly-1-45-0.yml new file mode 100644 index 00000000000..eaad7a8378b --- /dev/null +++ b/changelogs/unreleased/pb-update-gitaly-1-45-0.yml @@ -0,0 +1,5 @@ +--- +title: Update GITALY_SERVER_VERSION to 1.45.0 +merge_request: 29109 +author: +type: changed diff --git a/changelogs/unreleased/remove-mr-diff-header-height.yml b/changelogs/unreleased/remove-mr-diff-header-height.yml new file mode 100644 index 00000000000..c06c7281c58 --- /dev/null +++ b/changelogs/unreleased/remove-mr-diff-header-height.yml @@ -0,0 +1,5 @@ +--- +title: Remove fixed height from MR diff headers +merge_request: +author: +type: other diff --git a/changelogs/unreleased/security-58856-persistent-xss-in-note-objects.yml b/changelogs/unreleased/security-58856-persistent-xss-in-note-objects.yml new file mode 100644 index 00000000000..d9ad5af256a --- /dev/null +++ b/changelogs/unreleased/security-58856-persistent-xss-in-note-objects.yml @@ -0,0 +1,5 @@ +--- +title: Prevent XSS injection in note imports +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-60039.yml b/changelogs/unreleased/security-60039.yml new file mode 100644 index 00000000000..5edbf32ec97 --- /dev/null +++ b/changelogs/unreleased/security-60039.yml @@ -0,0 +1,5 @@ +--- +title: Prevent invalid branch for merge request +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-60143-address-xss-issue-in-wiki-links.yml b/changelogs/unreleased/security-60143-address-xss-issue-in-wiki-links.yml new file mode 100644 index 00000000000..5b79258af54 --- /dev/null +++ b/changelogs/unreleased/security-60143-address-xss-issue-in-wiki-links.yml @@ -0,0 +1,5 @@ +--- +title: Filter relative links in wiki for XSS +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-fix-confidential-issue-label-visibility-master.yml b/changelogs/unreleased/security-fix-confidential-issue-label-visibility-master.yml new file mode 100644 index 00000000000..adfd8e1298f --- /dev/null +++ b/changelogs/unreleased/security-fix-confidential-issue-label-visibility-master.yml @@ -0,0 +1,5 @@ +--- +title: Fix confidential issue label disclosure on milestone view +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-fix-project-existence-disclosure-master.yml b/changelogs/unreleased/security-fix-project-existence-disclosure-master.yml new file mode 100644 index 00000000000..084439c71d9 --- /dev/null +++ b/changelogs/unreleased/security-fix-project-existence-disclosure-master.yml @@ -0,0 +1,5 @@ +--- +title: Fix url redaction for issue links +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-fix_milestones_search_api_leak.yml b/changelogs/unreleased/security-fix_milestones_search_api_leak.yml new file mode 100644 index 00000000000..5691550b602 --- /dev/null +++ b/changelogs/unreleased/security-fix_milestones_search_api_leak.yml @@ -0,0 +1,5 @@ +--- +title: 'Resolve: Milestones leaked via search API' +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-id-leaked-password-in-import-url-frontend.yml b/changelogs/unreleased/security-id-leaked-password-in-import-url-frontend.yml new file mode 100644 index 00000000000..df636ec37fb --- /dev/null +++ b/changelogs/unreleased/security-id-leaked-password-in-import-url-frontend.yml @@ -0,0 +1,5 @@ +--- +title: Add extra fields for handling basic auth on import by url page +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-jej-prevent-web-sign-in-bypass.yml b/changelogs/unreleased/security-jej-prevent-web-sign-in-bypass.yml new file mode 100644 index 00000000000..02773fa1d7c --- /dev/null +++ b/changelogs/unreleased/security-jej-prevent-web-sign-in-bypass.yml @@ -0,0 +1,5 @@ +--- +title: Prevent bypass of restriction disabling web password sign in +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-unsubscribing-from-issue.yml b/changelogs/unreleased/security-unsubscribing-from-issue.yml new file mode 100644 index 00000000000..3a33a457c69 --- /dev/null +++ b/changelogs/unreleased/security-unsubscribing-from-issue.yml @@ -0,0 +1,5 @@ +--- +title: Hide confidential issue title on unsubscribe for anonymous users +merge_request: +author: +type: security diff --git a/changelogs/unreleased/set-real-next-run-at-for-preventing-duplciate-pipeline-creations.yml b/changelogs/unreleased/set-real-next-run-at-for-preventing-duplciate-pipeline-creations.yml new file mode 100644 index 00000000000..04eb035b157 --- /dev/null +++ b/changelogs/unreleased/set-real-next-run-at-for-preventing-duplciate-pipeline-creations.yml @@ -0,0 +1,5 @@ +--- +title: Make pipeline schedule worker resilient +merge_request: 28407 +author: +type: performance diff --git a/changelogs/unreleased/sh-fix-omniauth-generic-strategy.yml b/changelogs/unreleased/sh-fix-omniauth-generic-strategy.yml deleted file mode 100644 index 561c19c9685..00000000000 --- a/changelogs/unreleased/sh-fix-omniauth-generic-strategy.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix OmniAuth OAuth2Generic strategy not loading -merge_request: 28680 -author: -type: fixed diff --git a/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-5-2.yml b/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-5-2.yml new file mode 100644 index 00000000000..9ca6d18c2a8 --- /dev/null +++ b/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-5-2.yml @@ -0,0 +1,5 @@ +--- +title: Update GitLab Runner Helm Chart to 0.5.2 +merge_request: 29050 +author: +type: other diff --git a/changelogs/unreleased/update-pages.yml b/changelogs/unreleased/update-pages.yml new file mode 100644 index 00000000000..97a20b6b8fa --- /dev/null +++ b/changelogs/unreleased/update-pages.yml @@ -0,0 +1,5 @@ +--- +title: Update GitLab Pages to v1.6.0 +merge_request: 29048 +author: +type: other diff --git a/changelogs/unreleased/use-source-ref-name-in-webhook.yml b/changelogs/unreleased/use-source-ref-name-in-webhook.yml deleted file mode 100644 index 1a5c56d79ca..00000000000 --- a/changelogs/unreleased/use-source-ref-name-in-webhook.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Use source ref in pipeline webhook -merge_request: 28772 -author: -type: fixed diff --git a/changelogs/unreleased/zj-remove-delta-island-feature-flag.yml b/changelogs/unreleased/zj-remove-delta-island-feature-flag.yml new file mode 100644 index 00000000000..e752e01b701 --- /dev/null +++ b/changelogs/unreleased/zj-remove-delta-island-feature-flag.yml @@ -0,0 +1,3 @@ +merge_request: 28871 +title: Improve clone performance by using delta islands +type: performance diff --git a/config/initializers/hipchat_client_patch.rb b/config/initializers/hipchat_client_patch.rb index 1879ecb15fb..51bd48af320 100644 --- a/config/initializers/hipchat_client_patch.rb +++ b/config/initializers/hipchat_client_patch.rb @@ -2,14 +2,14 @@ # This monkey patches the HTTParty used in https://github.com/hipchat/hipchat-rb. module HipChat class Client - connection_adapter ::Gitlab::ProxyHTTPConnectionAdapter + connection_adapter ::Gitlab::HTTPConnectionAdapter end class Room - connection_adapter ::Gitlab::ProxyHTTPConnectionAdapter + connection_adapter ::Gitlab::HTTPConnectionAdapter end class User - connection_adapter ::Gitlab::ProxyHTTPConnectionAdapter + connection_adapter ::Gitlab::HTTPConnectionAdapter end end diff --git a/config/initializers/http_hostname_override.rb b/config/initializers/http_hostname_override.rb new file mode 100644 index 00000000000..58dd380326f --- /dev/null +++ b/config/initializers/http_hostname_override.rb @@ -0,0 +1,49 @@ +# This override allows passing `@hostname_override` to the SNI protocol, +# which is used to lookup the correct SSL certificate in the +# request handshake process. +# +# Given we've forced the HTTP request to be sent to the resolved +# IP address in a few scenarios (e.g.: `Gitlab::HTTP` through +# `Gitlab::UrlBlocker.validate!`), we need to provide the _original_ +# hostname via SNI in order to have a clean connection setup. +# +# This is ultimately needed in order to avoid DNS rebinding attacks +# through HTTP requests. +# +class OpenSSL::SSL::SSLContext + attr_accessor :hostname_override +end + +class OpenSSL::SSL::SSLSocket + module HostnameOverride + # rubocop: disable Gitlab/ModuleWithInstanceVariables + def hostname=(hostname) + super(@context.hostname_override || hostname) + end + + def post_connection_check(hostname) + super(@context.hostname_override || hostname) + end + # rubocop: enable Gitlab/ModuleWithInstanceVariables + end + + prepend HostnameOverride +end + +class Net::HTTP + attr_accessor :hostname_override + SSL_IVNAMES << :@hostname_override + SSL_ATTRIBUTES << :hostname_override + + module HostnameOverride + def addr_port + return super unless hostname_override + + addr = hostname_override + default_port = use_ssl? ? Net::HTTP.https_default_port : Net::HTTP.http_default_port + default_port == port ? addr : "#{addr}:#{port}" + end + end + + prepend HostnameOverride +end diff --git a/config/routes/project.rb b/config/routes/project.rb index 1e94bdc245f..d44ff62bc2a 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -124,6 +124,44 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do put :revoke end end + + resources :milestones, constraints: { id: /\d+/ } do + member do + post :promote + put :sort_issues + put :sort_merge_requests + get :merge_requests + get :participants + get :labels + end + end + + resources :labels, except: [:show], constraints: { id: /\d+/ } do + collection do + post :generate + post :set_priorities + end + + member do + post :promote + post :toggle_subscription + delete :remove_priority + end + end + + resources :services, constraints: { id: %r{[^/]+} }, only: [:edit, :update] do + member do + put :test + end + end + + resources :boards, only: [:index, :show], constraints: { id: /\d+/ } + resources :releases, only: [:index] + resources :forks, only: [:index, :new, :create] + resources :group_links, only: [:index, :create, :update, :destroy], constraints: { id: /\d+/ } + + resource :import, only: [:new, :create, :show] + resource :avatar, only: [:show, :destroy] end # End of the /-/ scope. @@ -132,7 +170,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do # get '/templates/:template_type/:key' => 'templates#show', as: :template, constraints: { key: %r{[^/]+} } - resource :avatar, only: [:show, :destroy] resources :commit, only: [:show], constraints: { id: /\h{7,40}/ } do member do get :branches @@ -159,12 +196,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end end - resources :services, constraints: { id: %r{[^/]+} }, only: [:edit, :update] do - member do - put :test - end - end - resource :mattermost, only: [:new, :create] namespace :prometheus do @@ -173,15 +204,11 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end end - resources :releases, only: [:index] - resources :forks, only: [:index, :new, :create] - resource :import, only: [:new, :create, :show] - resources :merge_requests, concerns: :awardable, except: [:new, :create], constraints: { id: /\d+/ } do member do get :commit_change_content post :merge - post :cancel_merge_when_pipeline_succeeds + post :cancel_auto_merge get :pipeline_status get :ci_environments_status post :toggle_subscription @@ -372,31 +399,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end end - resources :milestones, constraints: { id: /\d+/ } do - member do - post :promote - put :sort_issues - put :sort_merge_requests - get :merge_requests - get :participants - get :labels - end - end - - resources :labels, except: [:show], constraints: { id: /\d+/ } do - collection do - post :generate - post :set_priorities - end - - member do - post :promote - post :toggle_subscription - delete :remove_priority - end - end - get :issues, to: 'issues#calendar', constraints: lambda { |req| req.format == :ics } + resources :issues, concerns: :awardable, constraints: { id: /\d+/ } do member do post :toggle_subscription @@ -408,14 +412,13 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do post :create_merge_request get :discussions, format: :json end + collection do post :bulk_update post :import_csv end end - resources :group_links, only: [:index, :create, :update, :destroy], constraints: { id: /\d+/ } - resources :notes, only: [:create, :destroy, :update], concerns: :awardable, constraints: { id: /\d+/ } do member do delete :delete_attachment @@ -426,8 +429,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do get 'noteable/:target_type/:target_id/notes' => 'notes#index', as: 'noteable_notes' - resources :boards, only: [:index, :show], constraints: { id: /\d+/ } - resources :todos, only: [:create] resources :uploads, only: [:create] do @@ -510,7 +511,9 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do as: :project) do Gitlab::Routing.redirect_legacy_paths(self, :settings, :branches, :tags, :network, :graphs, :autocomplete_sources, - :project_members, :deploy_keys, :deploy_tokens) + :project_members, :deploy_keys, :deploy_tokens, + :labels, :milestones, :services, :boards, :releases, + :forks, :group_links, :import, :avatar) end end end diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 0615da2d241..fd9ce4d3374 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -30,6 +30,7 @@ - [pipeline_default, 3] - [pipeline_cache, 3] - [deployment, 3] + - [auto_merge, 3] - [pipeline_hooks, 2] - [gitlab_shell, 2] - [email_receiver, 2] diff --git a/db/migrate/20180702134423_generate_missing_routes.rb b/db/migrate/20180702134423_generate_missing_routes.rb index a440bc3179c..dd1106c9e6a 100644 --- a/db/migrate/20180702134423_generate_missing_routes.rb +++ b/db/migrate/20180702134423_generate_missing_routes.rb @@ -98,6 +98,7 @@ class GenerateMissingRoutes < ActiveRecord::Migration[4.2] class Namespace < ActiveRecord::Base self.table_name = 'namespaces' + self.inheritance_column = :_type_disabled include EachBatch include GenerateMissingRoutes::Routable diff --git a/db/migrate/20190529142545_add_dns_rebinding_protection_enabled_to_application_settings.rb b/db/migrate/20190529142545_add_dns_rebinding_protection_enabled_to_application_settings.rb new file mode 100644 index 00000000000..8835dc8b7ba --- /dev/null +++ b/db/migrate/20190529142545_add_dns_rebinding_protection_enabled_to_application_settings.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddDnsRebindingProtectionEnabledToApplicationSettings < ActiveRecord::Migration[5.1] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default(:application_settings, :dns_rebinding_protection_enabled, + :boolean, + default: true, + allow_null: false) + end + + def down + remove_column(:application_settings, :dns_rebinding_protection_enabled) + end +end diff --git a/db/migrate/20190530154715_add_index_to_merge_requests_state_and_merge_status.rb b/db/migrate/20190530154715_add_index_to_merge_requests_state_and_merge_status.rb new file mode 100644 index 00000000000..e669f81ca35 --- /dev/null +++ b/db/migrate/20190530154715_add_index_to_merge_requests_state_and_merge_status.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddIndexToMergeRequestsStateAndMergeStatus < ActiveRecord::Migration[5.1] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :merge_requests, [:state, :merge_status], + where: "state = 'opened' AND merge_status = 'can_be_merged'" + end + + def down + remove_concurrent_index :merge_requests, [:state, :merge_status] + end +end diff --git a/db/post_migrate/20190522143720_drop_project_auto_devops_domain.rb b/db/post_migrate/20190522143720_drop_project_auto_devops_domain.rb new file mode 100644 index 00000000000..36278d83927 --- /dev/null +++ b/db/post_migrate/20190522143720_drop_project_auto_devops_domain.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class DropProjectAutoDevopsDomain < ActiveRecord::Migration[5.1] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + remove_column :project_auto_devops, :domain, :string + end +end diff --git a/db/post_migrate/20190528180441_enqueue_reset_merge_status.rb b/db/post_migrate/20190528180441_enqueue_reset_merge_status.rb new file mode 100644 index 00000000000..1b668d85bac --- /dev/null +++ b/db/post_migrate/20190528180441_enqueue_reset_merge_status.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class EnqueueResetMergeStatus < ActiveRecord::Migration[5.1] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + BATCH_SIZE = 10_000 + MIGRATION = 'ResetMergeStatus' + DELAY_INTERVAL = 5.minutes.to_i + + disable_ddl_transaction! + + def up + say 'Scheduling `ResetMergeStatus` jobs' + + # We currently have around 135_000 opened, mergeable MRs in GitLab.com. This iteration + # will schedule around 13 batches of 10_000 MRs, which should take around 1 hour to + # complete. + relation = MergeRequest.where(state: 'opened', merge_status: 'can_be_merged') + + relation.each_batch(of: BATCH_SIZE) do |batch, index| + range = batch.pluck('MIN(id)', 'MAX(id)').first + + BackgroundMigrationWorker.perform_in(index * DELAY_INTERVAL, MIGRATION, range) + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 39d9743bcc9..fcf9e397ac1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20190527194900) do +ActiveRecord::Schema.define(version: 20190530154715) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -193,6 +193,7 @@ ActiveRecord::Schema.define(version: 20190527194900) do t.integer "elasticsearch_replicas", default: 1, null: false t.text "encrypted_lets_encrypt_private_key" t.text "encrypted_lets_encrypt_private_key_iv" + t.boolean "dns_rebinding_protection_enabled", default: true, null: false t.index ["usage_stats_set_by_user_id"], name: "index_application_settings_on_usage_stats_set_by_user_id", using: :btree end @@ -1361,6 +1362,7 @@ ActiveRecord::Schema.define(version: 20190527194900) do t.index ["source_branch"], name: "index_merge_requests_on_source_branch", using: :btree t.index ["source_project_id", "source_branch"], name: "index_merge_requests_on_source_project_and_branch_state_opened", where: "((state)::text = 'opened'::text)", using: :btree t.index ["source_project_id", "source_branch"], name: "index_merge_requests_on_source_project_id_and_source_branch", using: :btree + t.index ["state", "merge_status"], name: "index_merge_requests_on_state_and_merge_status", where: "(((state)::text = 'opened'::text) AND ((merge_status)::text = 'can_be_merged'::text))", using: :btree t.index ["target_branch"], name: "index_merge_requests_on_target_branch", using: :btree t.index ["target_project_id", "iid"], name: "index_merge_requests_on_target_project_id_and_iid", unique: true, using: :btree t.index ["target_project_id", "iid"], name: "index_merge_requests_on_target_project_id_and_iid_opened", where: "((state)::text = 'opened'::text)", using: :btree @@ -1633,7 +1635,6 @@ ActiveRecord::Schema.define(version: 20190527194900) do t.datetime_with_timezone "created_at", null: false t.datetime_with_timezone "updated_at", null: false t.boolean "enabled" - t.string "domain" t.integer "deploy_strategy", default: 0, null: false t.index ["project_id"], name: "index_project_auto_devops_on_project_id", unique: true, using: :btree end diff --git a/doc/administration/gitaly/index.md b/doc/administration/gitaly/index.md index f1cedb85455..dcf8d8715ca 100644 --- a/doc/administration/gitaly/index.md +++ b/doc/administration/gitaly/index.md @@ -53,6 +53,10 @@ But since 11.8 the indexer uses Gitaly for data access as well. NFS can still be leveraged for redudancy on block level of the Git data. But only has to be mounted on the Gitaly server. +NOTE: **Note:** While Gitaly can be used as a replacement for NFS, we do not recommend +using EFS as it may impact GitLab's performance. Please review the [relevant documentation](../high_availability/nfs.md#avoid-using-awss-elastic-file-system-efs) +for more details. + ### Network architecture - gitlab-rails shards repositories into "repository storages" @@ -73,18 +77,29 @@ be mounted on the Gitaly server. - Gitaly servers must not be exposed to the public internet Gitaly network traffic is unencrypted by default, but supports -[TLS](#tls-support). Authentication is done through a static token. For -security in depth, its recommended to use a firewall to restrict access -to your Gitaly server. +[TLS](#tls-support). Authentication is done through a static token. + +NOTE: **Note:** Gitaly network traffic is unencrypted so we recommend a firewall to +restrict access to your Gitaly server. Below we describe how to configure a Gitaly server at address `gitaly.internal:8075` with secret token `abc123secret`. We assume your GitLab installation has two repository storages, `default` and `storage1`. +### Installation + +First install Gitaly using either Omnibus or from source. + +Omnibus: [Download/install](https://about.gitlab.com/installation) the Omnibus GitLab +package you want using **steps 1 and 2** from the GitLab downloads page but +**_do not_** provide the `EXTERNAL_URL=` value. + +Source: [Install Gitaly](../../install/installation.md#install-gitaly) + ### Client side token configuration -Start by configuring a token on the client side. +Configure a token on the client side. Omnibus installations: @@ -110,7 +125,7 @@ changes to be picked up. Next, on the Gitaly server, we need to configure storage paths, enable the network listener and configure the token. -Note: if you want to reduce the risk of downtime when you enable +NOTE: **Note:** if you want to reduce the risk of downtime when you enable authentication you can temporarily disable enforcement, see [the documentation on configuring Gitaly authentication](https://gitlab.com/gitlab-org/gitaly/blob/master/doc/configuration/README.md#authentication) @@ -122,12 +137,17 @@ the Gitaly server. The easiest way to accomplish this is to copy `/etc/gitlab/gi from an existing GitLab server to the Gitaly server. Without this shared secret, Git operations in GitLab will result in an API error. -> **NOTE:** In most or all cases the storage paths below end in `/repositories` which is +NOTE: **Note:** In most or all cases the storage paths below end in `/repositories` which is different than `path` in `git_data_dirs` of Omnibus installations. Check the directory layout on your Gitaly server to be sure. Omnibus installations: +<!-- +updates to following example must also be made at +https://gitlab.com/charts/gitlab/blob/master/doc/advanced/external-gitaly/external-omnibus-gitaly.md#configure-omnibus-gitlab +--> + ```ruby # /etc/gitlab/gitlab.rb @@ -147,6 +167,7 @@ gitlab_rails['auto_migrate'] = false # Configure the gitlab-shell API callback URL. Without this, `git push` will # fail. This can be your 'front door' GitLab URL or an internal load # balancer. +# Don't forget to copy `/etc/gitlab/gitlab-secrets.json` from web server to Gitaly server. gitlab_rails['internal_api_url'] = 'https://gitlab.example.com' # Make Gitaly accept connections on all network interfaces. You must use diff --git a/doc/administration/high_availability/gitaly.md b/doc/administration/high_availability/gitaly.md index d44744f2af8..40f85f28cb8 100644 --- a/doc/administration/high_availability/gitaly.md +++ b/doc/administration/high_availability/gitaly.md @@ -12,77 +12,8 @@ environments and [High Availability Architecture](./README.md#high-availability- ## Running Gitaly on its own server -Starting with GitLab 11.4, Gitaly is a replacement for NFS except -when the [Elastic Search indexer](https://gitlab.com/gitlab-org/gitlab-elasticsearch-indexer) -is used. - -NOTE: **Note:** While Gitaly can be used as a replacement for NFS, we do not recommend using EFS as it may impact GitLab's performance. Please review the [relevant documentation](nfs.md#avoid-using-awss-elastic-file-system-efs) for more details. - -NOTE: **Note:** Gitaly network traffic is unencrypted so we recommend a firewall to -restrict access to your Gitaly server. - -The steps below are the minimum necessary to configure a Gitaly server with -Omnibus: - -1. SSH into the Gitaly server. -1. [Download/install](https://about.gitlab.com/installation) the Omnibus GitLab - package you want using **steps 1 and 2** from the GitLab downloads page. - - Do not complete any other steps on the download page. - -1. Edit `/etc/gitlab/gitlab.rb` and add the contents: - - Gitaly must trigger some callbacks to GitLab via GitLab Shell. As a result, - the GitLab Shell secret must be the same between the other GitLab servers and - the Gitaly server. The easiest way to accomplish this is to copy `/etc/gitlab/gitlab-secrets.json` - from an existing GitLab server to the Gitaly server. Without this shared secret, - Git operations in GitLab will result in an API error. - - > **NOTE:** In most or all cases the storage paths below end in `repositories` which is - different than `path` in `git_data_dirs` of Omnibus installations. Check the - directory layout on your Gitaly server to be sure. - - ```ruby - # Enable Gitaly - gitaly['enable'] = true - - ## Disable all other services - sidekiq['enable'] = false - gitlab_workhorse['enable'] = false - unicorn['enable'] = false - postgresql['enable'] = false - nginx['enable'] = false - prometheus['enable'] = false - alertmanager['enable'] = false - pgbouncer_exporter['enable'] = false - redis_exporter['enable'] = false - gitlab_monitor['enable'] = false - - # Prevent database connections during 'gitlab-ctl reconfigure' - gitlab_rails['rake_cache_clear'] = false - gitlab_rails['auto_migrate'] = false - - # Configure the gitlab-shell API callback URL. Without this, `git push` will - # fail. This can be your 'front door' GitLab URL or an internal load - # balancer. - gitlab_rails['internal_api_url'] = 'https://gitlab.example.com' - - # Make Gitaly accept connections on all network interfaces. You must use - # firewalls to restrict access to this address/port. - gitaly['listen_addr'] = "0.0.0.0:8075" - gitaly['auth_token'] = 'abc123secret' - - gitaly['storage'] = [ - { 'name' => 'default', 'path' => '/mnt/gitlab/default/repositories' }, - { 'name' => 'storage1', 'path' => '/mnt/gitlab/storage1/repositories' }, - ] - - # To use tls for gitaly you need to add - gitaly['tls_listen_addr'] = "0.0.0.0:9999" - gitaly['certificate_path'] = "path/to/cert.pem" - gitaly['key_path'] = "path/to/key.pem" - ``` - -Again, reconfigure (Omnibus) or restart (source). +See [Running Gitaly on its own server](../gitaly/index.md#running-gitaly-on-its-own-server) +in Gitaly documentation. Continue configuration of other components by going back to: diff --git a/doc/administration/high_availability/redis.md b/doc/administration/high_availability/redis.md index 46ad3ecd9bb..1f37224a184 100644 --- a/doc/administration/high_availability/redis.md +++ b/doc/administration/high_availability/redis.md @@ -863,7 +863,7 @@ You can check if everything is correct by connecting to each server using `redis-cli` application, and sending the `info replication` command as below. ``` -/opt/gitlab/embedded/bin/redis-cli -a <redis-password> info replication +/opt/gitlab/embedded/bin/redis-cli -h <redis-host-or-ip> -a '<redis-password>' info replication ``` When connected to a `master` redis, you will see the number of connected diff --git a/doc/administration/integration/terminal.md b/doc/administration/integration/terminal.md index 2596e3fe68b..c34858cd0db 100644 --- a/doc/administration/integration/terminal.md +++ b/doc/administration/integration/terminal.md @@ -43,6 +43,11 @@ detail below. ## Enabling and disabling terminal support +NOTE: **Note:** AWS Elastic Load Balancers (ELBs) do not support web sockets. +AWS Application Load Balancers (ALBs) must be used if you want web terminals +to work. See [AWS Elastic Load Balancing Product Comparison](https://aws.amazon.com/elasticloadbalancing/features/#compare) +for more information. + As web terminals use WebSockets, every HTTP/HTTPS reverse proxy in front of Workhorse needs to be configured to pass the `Connection` and `Upgrade` headers through to the next one in the chain. If you installed GitLab using Omnibus, or diff --git a/doc/administration/monitoring/prometheus/gitlab_metrics.md b/doc/administration/monitoring/prometheus/gitlab_metrics.md index 4529bd3bee3..3dcd1593099 100644 --- a/doc/administration/monitoring/prometheus/gitlab_metrics.md +++ b/doc/administration/monitoring/prometheus/gitlab_metrics.md @@ -43,10 +43,11 @@ The following metrics are available: | redis_ping_latency_seconds | Gauge | 9.4 | Round trip time of the redis ping | | user_session_logins_total | Counter | 9.4 | Counter of how many users have logged in | | upload_file_does_not_exist | Counter | 10.7 in EE, 11.5 in CE | Number of times an upload record could not find its file | -| failed_login_captcha_total | Gauge | 11.0 | Counter of failed CAPTCHA attempts during login | -| successful_login_captcha_total | Gauge | 11.0 | Counter of successful CAPTCHA attempts during login | -| unicorn_active_connections | Gauge | 11.0 | The number of active Unicorn connections (workers) | -| unicorn_queued_connections | Gauge | 11.0 | The number of queued Unicorn connections | +| failed_login_captcha_total | Gauge | 11.0 | Counter of failed CAPTCHA attempts during login | +| successful_login_captcha_total | Gauge | 11.0 | Counter of successful CAPTCHA attempts during login | +| unicorn_active_connections | Gauge | 11.0 | The number of active Unicorn connections (workers) | +| unicorn_queued_connections | Gauge | 11.0 | The number of queued Unicorn connections | +| unicorn_workers | Gauge | 12.0 | The number of Unicorn workers | ## Sidekiq Metrics available for Geo **[PREMIUM]** @@ -86,8 +87,8 @@ the `monitoring.sidekiq_exporter` configuration option in `gitlab.yml`. | geo_wikis_checksum_mismatch_count | Gauge | 10.7 | Number of wikis that checksum mismatch on secondary | url | geo_repositories_checked_count | Gauge | 11.1 | Number of repositories that have been checked via `git fsck` | url | geo_repositories_checked_failed_count | Gauge | 11.1 | Number of repositories that have a failure from `git fsck` | url -| geo_repositories_retrying_verification_count | Gauge | 11.2 | Number of repositories verification failures that Geo is actively trying to correct on secondary | url -| geo_wikis_retrying_verification_count | Gauge | 11.2 | Number of wikis verification failures that Geo is actively trying to correct on secondary | url +| geo_repositories_retrying_verification_count | Gauge | 11.2 | Number of repositories verification failures that Geo is actively trying to correct on secondary | url +| geo_wikis_retrying_verification_count | Gauge | 11.2 | Number of wikis verification failures that Geo is actively trying to correct on secondary | url ### Ruby metrics @@ -100,6 +101,10 @@ Some basic Ruby runtime metrics are available: | ruby_file_descriptors | Gauge | 11.1 | File descriptors per process | | ruby_memory_bytes | Gauge | 11.1 | Memory usage by process | | ruby_sampler_duration_seconds_total | Counter | 11.1 | Time spent collecting stats | +| ruby_process_cpu_seconds_total | Gauge | 12.0 | Total amount of CPU time per process | +| ruby_process_max_fds | Gauge | 12.0 | Maximum number of open file descriptors per process | +| ruby_process_resident_memory_bytes | Gauge | 12.0 | Memory usage by process, measured in bytes | +| ruby_process_start_time_seconds | Gauge | 12.0 | The elapsed time between system boot and the process started, measured in seconds | [GC.stat]: https://ruby-doc.org/core-2.3.0/GC.html#method-c-stat diff --git a/doc/administration/raketasks/maintenance.md b/doc/administration/raketasks/maintenance.md index b295b7d5dc4..0b4c1ae15d6 100644 --- a/doc/administration/raketasks/maintenance.md +++ b/doc/administration/raketasks/maintenance.md @@ -205,25 +205,6 @@ cd /home/git/gitlab sudo -u git -H bundle exec rake gitlab:track_deployment RAILS_ENV=production ``` -## Create or repair repository hooks symlink - -If the GitLab shell hooks directory location changes or another circumstance -leads to the hooks symlink becoming missing or invalid, run this Rake task -to create or repair the symlinks. - -**Omnibus Installation** - -``` -sudo gitlab-rake gitlab:shell:create_hooks -``` - -**Source Installation** - -``` -cd /home/git/gitlab -sudo -u git -H bundle exec rake gitlab:shell:create_hooks RAILS_ENV=production -``` - ## Check TCP connectivity to a remote site Sometimes you need to know if your GitLab installation can connect to a TCP diff --git a/doc/api/commits.md b/doc/api/commits.md index 92f53c7b5e6..25015fad9e3 100644 --- a/doc/api/commits.md +++ b/doc/api/commits.md @@ -75,6 +75,7 @@ POST /projects/:id/repository/commits | `branch` | string | yes | Name of the branch to commit into. To create a new branch, also provide `start_branch`. | | `commit_message` | string | yes | Commit message | | `start_branch` | string | no | Name of the branch to start the new commit from | +| `start_project` | integer/string | no | The project ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) to start the commit from. Defaults to the value of `id`. | | `actions[]` | array | yes | An array of action hashes to commit as a batch. See the next table for what attributes it can take. | | `author_email` | string | no | Specify the commit author's email address | | `author_name` | string | no | Specify the commit author's name | diff --git a/doc/api/graphql/index.md b/doc/api/graphql/index.md index 9195ba4cdf1..88e657a5d2f 100644 --- a/doc/api/graphql/index.md +++ b/doc/api/graphql/index.md @@ -47,6 +47,7 @@ A first iteration of a GraphQL API includes the following queries 1. `project` : Within a project it is also possible to fetch a `mergeRequest` by IID. 1. `group` : Only basic group information is currently supported. +1. `namespace` : Within a namespace it is also possible to fetch `projects`. ### Multiplex queries diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index 7992af15448..9529a9ec1f5 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -1159,33 +1159,29 @@ Parameters: } ``` -## Merge to default merge ref path +## Returns the up to date merge-ref HEAD commit Merge the changes between the merge request source and target branches into `refs/merge-requests/:iid/merge` -ref, of the target project repository. This ref will have the state the target branch would have if +ref, of the target project repository, if possible. This ref will have the state the target branch would have if a regular merge action was taken. -This is not a regular merge action given it doesn't change the merge request state in any manner. +This is not a regular merge action given it doesn't change the merge request target branch state in any manner. -This ref (`refs/merge-requests/:iid/merge`) is **always** overwritten when submitting -requests to this API, so none of its state is kept or used in the process. +This ref (`refs/merge-requests/:iid/merge`) isn't necessarily overwritten when submitting +requests to this API, though it'll make sure the ref has the latest possible state. -If the merge request has conflicts, is empty or already merged, -you'll get a `400` and a descriptive error message. If you don't have permissions to do so, -you'll get a `403`. +If the merge request has conflicts, is empty or already merged, you'll get a `400` and a descriptive error message. -It returns the HEAD commit of `refs/merge-requests/:iid/merge` in the response body in -case of `200`. +It returns the HEAD commit of `refs/merge-requests/:iid/merge` in the response body in case of `200`. ``` -PUT /projects/:id/merge_requests/:merge_request_iid/merge_to_ref +GET /projects/:id/merge_requests/:merge_request_iid/merge_ref ``` Parameters: - `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user - `merge_request_iid` (required) - Internal ID of MR -- `merge_commit_message` (optional) - Custom merge commit message ```json { diff --git a/doc/api/snippets.md b/doc/api/snippets.md index f90447e124e..1ce0b1e7a62 100644 --- a/doc/api/snippets.md +++ b/doc/api/snippets.md @@ -165,15 +165,15 @@ Parameters: |:--------------|:-------|:---------|:---------------------------------------------------| | `title` | string | yes | Title of a snippet. | | `file_name` | string | yes | Name of a snippet file. | -| `content` | string | yes | Content of a snippet. | +| `code` | string | yes | Content of a snippet. | | `description` | string | no | Description of a snippet. | -| `visibility` | string | no | Snippet's [visibility](#snippet-visibility-level). | +| `visibility` | string | yes | Snippet's [visibility](#snippet-visibility-level). | Example request: ```sh curl --request POST \ - --data '{"title": "This is a snippet", "content": "Hello world", "description": "Hello World snippet", "file_name": "test.txt", "visibility": "internal" }' \ + --data '{"title": "This is a snippet", "code": "Hello world", "description": "Hello World snippet", "file_name": "test.txt", "visibility": "internal" }' \ --header 'Content-Type: application/json' \ --header "PRIVATE-TOKEN: valid_api_token" \ https://gitlab.example.com/api/v4/snippets @@ -222,14 +222,14 @@ Parameters: | `title` | string | no | Title of a snippet. | | `file_name` | string | no | Name of a snippet file. | | `description` | string | no | Description of a snippet. | -| `content` | string | no | Content of a snippet. | +| `code` | string | no | Content of a snippet. | | `visibility` | string | no | Snippet's [visibility](#snippet-visibility-level). | Example request: ```sh curl --request PUT \ - --data '{"title": "foo", "content": "bar"}' \ + --data '{"title": "foo", "code": "bar"}' \ --header 'Content-Type: application/json' \ --header "PRIVATE-TOKEN: valid_api_token" \ https://gitlab.example.com/api/v4/snippets/1 diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md index 02370bead00..11bcfd5dc2c 100644 --- a/doc/ci/quick_start/README.md +++ b/doc/ci/quick_start/README.md @@ -4,10 +4,15 @@ type: reference # Getting started with GitLab CI/CD ->**Note:** Starting from version 8.0, GitLab [Continuous Integration][ci] (CI) +NOTE: **Note:** +Starting from version 8.0, GitLab [Continuous Integration][ci] (CI) is fully integrated into GitLab itself and is [enabled] by default on all projects. +NOTE: **Note:** +Please keep in mind that only project Maintainers and Admin users have +the permissions to access a project's settings. + GitLab offers a [continuous integration][ci] service. If you [add a `.gitlab-ci.yml` file][yaml] to the root directory of your repository, and configure your GitLab project to use a [Runner], then each commit or @@ -44,6 +49,7 @@ This guide assumes that you have: - A working GitLab instance of version 8.0+r or are using [GitLab.com](https://gitlab.com). - A project in GitLab that you would like to use CI for. +- Maintainer or owner access to the project Let's break it down to pieces and work on solving the GitLab CI puzzle. @@ -77,6 +83,8 @@ You need to create a file named `.gitlab-ci.yml` in the root directory of your repository. Below is an example for a Ruby on Rails project. ```yaml +image: "ruby:2.5" + before_script: - apt-get update -qq && apt-get install -y -qq sqlite3 libsqlite3-dev nodejs - ruby -v diff --git a/doc/development/architecture.md b/doc/development/architecture.md index 22a52a313b3..a0e4020da09 100644 --- a/doc/development/architecture.md +++ b/doc/development/architecture.md @@ -59,7 +59,7 @@ graph TB PgBouncerExporter[PgBouncer Exporter] --> PgBouncer Prometheus -- TCP 9187 --> PostgreSQLExporter Prometheus -- TCP 9100 --> NodeExporter[Node Exporter] - Prometheus -- TCP 9168 --> GitLabMonito[GitLab Monitor] + Prometheus -- TCP 9168 --> GitLabMonitor[GitLab Monitor] Prometheus -- TCP 9127 --> PgBouncerExporter GitLabMonitor --> PostgreSQL GitLabMonitor --> GitLabShell diff --git a/doc/development/fe_guide/style_guide_js.md b/doc/development/fe_guide/style_guide_js.md index 94dfdbdd073..b50159c2b75 100644 --- a/doc/development/fe_guide/style_guide_js.md +++ b/doc/development/fe_guide/style_guide_js.md @@ -95,6 +95,7 @@ See [our current .eslintrc](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/ #### Modules, Imports, and Exports 1. Use ES module syntax to import modules + ```javascript // bad const SomeClass = require('some_class'); @@ -168,6 +169,7 @@ See [our current .eslintrc](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/ Do not use them anymore and feel free to remove them when refactoring legacy code. 1. Avoid adding to the global namespace. + ```javascript // bad window.MyClass = class { /* ... */ }; @@ -176,7 +178,8 @@ See [our current .eslintrc](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/ export default class MyClass { /* ... */ } ``` -1. Side effects are forbidden in any script which contains exports +1. Side effects are forbidden in any script which contains export + ```javascript // bad export default class MyClass { /* ... */ } @@ -449,6 +452,7 @@ Please check this [rules][eslint-plugin-vue-rules] for more documentation. #### Props 1. Props should be declared as an object + ```javascript // bad props: ['foo'] diff --git a/doc/development/rolling_out_changes_using_feature_flags.md b/doc/development/rolling_out_changes_using_feature_flags.md index 1b4c89ba2a8..84028b1b342 100644 --- a/doc/development/rolling_out_changes_using_feature_flags.md +++ b/doc/development/rolling_out_changes_using_feature_flags.md @@ -200,10 +200,9 @@ isn't gated by a License or Plan. ### Undefined feature flags default to "on" -An important side-effect of the [implicit feature -flags][#implicit-feature-flags] mentioned above is that unless the feature is -explicitly disabled or limited to a percentage of users, the feature flag check -will default to `true`. +An important side-effect of the [implicit feature flags](#implicit-feature-flags) +mentioned above is that unless the feature is explicitly disabled or limited to a +percentage of users, the feature flag check will default to `true`. As an example, if you were to ship the backend half of a feature behind a flag, you'd want to explicitly disable that flag until the frontend half is also ready diff --git a/doc/development/understanding_explain_plans.md b/doc/development/understanding_explain_plans.md index 01a0044f096..2ef8b3148e4 100644 --- a/doc/development/understanding_explain_plans.md +++ b/doc/development/understanding_explain_plans.md @@ -80,8 +80,9 @@ Planning time: 2.861 ms Execution time: 3428.596 ms ``` -For more information, refer to the official [EXPLAIN -documentation](https://www.postgresql.org/docs/current/static/sql-explain.html). +For more information, refer to the official +[`EXPLAIN` documentation](https://www.postgresql.org/docs/current/sql-explain.html) +and [using `EXPLAIN` guide](https://www.postgresql.org/docs/current/using-explain.html). ## Nodes @@ -653,6 +654,35 @@ and related tools such as: - <https://explain.depesz.com/> - <http://tatiyants.com/postgres-query-plan-visualization/> +## Producing query plans + +There are a few ways to get the output of a query plan. Of course you +can directly run the `EXPLAIN` query in the `psql` console, or you can +follow one of the other options below. + +### Rails console + +Using the [`activerecord-explain-analyze`](https://github.com/6/activerecord-explain-analyze) +you can directly generate the query plan from the Rails console: + +```ruby +pry(main)> require 'activerecord-explain-analyze' +=> true +pry(main)> Project.where('build_timeout > ?', 3600).explain(analyze: true) + Project Load (1.9ms) SELECT "projects".* FROM "projects" WHERE (build_timeout > 3600) + ↳ (pry):12 +=> EXPLAIN for: SELECT "projects".* FROM "projects" WHERE (build_timeout > 3600) +Seq Scan on public.projects (cost=0.00..2.17 rows=1 width=742) (actual time=0.040..0.041 rows=0 loops=1) + Output: id, name, path, description, created_at, updated_at, creator_id, namespace_id, ... + Filter: (projects.build_timeout > 3600) + Rows Removed by Filter: 14 + Buffers: shared hit=2 +Planning time: 0.411 ms +Execution time: 0.113 ms +``` + +### Chatops + GitLab employees can also use our chatops solution, available in Slack using the `/chatops` slash command. You can use chatops to get a query plan by running the following: @@ -674,3 +704,9 @@ For more information about the available options, run: ``` /chatops run explain --help ``` + +## Further reading + +A more extensive guide on understanding query plans can be found in +the [presentation](https://www.dalibo.org/_media/understanding_explain.pdf) +from [Dalibo.org](https://www.dalibo.org/en/). diff --git a/doc/push_rules/push_rules.md b/doc/push_rules/push_rules.md index 7654023f266..e44eab2556e 100644 --- a/doc/push_rules/push_rules.md +++ b/doc/push_rules/push_rules.md @@ -61,7 +61,7 @@ The following options are available. | --------- | :------------: | ----------- | | Removal of tags with `git push` | **Starter** 7.10 | Forbid users to remove git tags with `git push`. Tags will still be able to be deleted through the web UI. | | Check whether author is a GitLab user | **Starter** 7.10 | Restrict commits by author (email) to existing GitLab users. | -| Check whether committer is the current authenticated user | **Premium** 10.2 | GitLab will reject any commit that was not committed by the current authenticated user | +| Committer restriction | **Premium** 10.2 | GitLab will reject any commit that was not committed by the current authenticated user | | Check whether commit is signed through GPG | **Premium** 10.1 | Reject commit when it is not signed through GPG. Read [signing commits with GPG][signing-commits]. | | Prevent committing secrets to Git | **Starter** 8.12 | GitLab will reject any files that are likely to contain secrets. Read [what files are forbidden](#prevent-pushing-secrets-to-the-repository). | | Restrict by commit message | **Starter** 7.10 | Only commit messages that match this regular expression are allowed to be pushed. Leave empty to allow any commit message. Uses multiline mode, which can be disabled using `(?-m)`. | diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md index 56db7b5eb3a..764916ca82d 100644 --- a/doc/raketasks/backup_restore.md +++ b/doc/raketasks/backup_restore.md @@ -917,9 +917,9 @@ backup beforehand. 1. Clear all the tokens for projects, groups, and the whole instance: -CAUTION: **Caution:** -The last UPDATE operation will stop the runners being able to pick up -new jobs. You must register new runners. + CAUTION: **Caution:** + The last UPDATE operation will stop the runners being able to pick up + new jobs. You must register new runners. ```sql -- Clear project tokens diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index 04938080539..b00a8afa386 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -582,16 +582,17 @@ Note that a post-install hook means that if any deploy succeeds, If present, `DB_MIGRATE` will be run as a shell command within an application pod as a helm pre-upgrade hook. -For example, in a Rails application: +For example, in a Rails application in an image built with +[Herokuish](https://github.com/gliderlabs/herokuish): -- `DB_INITIALIZE` can be set to `cd /app && RAILS_ENV=production - bin/setup` -- `DB_MIGRATE` can be set to `cd /app && RAILS_ENV=production bin/update` +- `DB_INITIALIZE` can be set to `RAILS_ENV=production /bin/herokuish procfile exec bin/rails db:setup` +- `DB_MIGRATE` can be set to `RAILS_ENV=production /bin/herokuish procfile exec bin/rails db:migrate` NOTE: **Note:** -The `/app` path is the directory of your project inside the docker image -as [configured by -Herokuish](https://github.com/gliderlabs/herokuish#paths) +Unless you have a `Dockerfile` in your repo, your image is built with +Herokuish. You must prefix commands run in these images with `/bin/herokuish +procfile exec` in order to replicate the the environment your application is +run in. ### Auto Monitoring diff --git a/doc/university/training/topics/unstage.md b/doc/university/training/topics/unstage.md index da36a3218e5..c926f0b4888 100644 --- a/doc/university/training/topics/unstage.md +++ b/doc/university/training/topics/unstage.md @@ -8,13 +8,13 @@ comments: false ## Unstage -- To remove files from stage use reset HEAD. Where HEAD is the last commit of the current branch. +- To remove files from stage use reset HEAD where HEAD is the last commit of the current branch. This will unstage the file but maintain the modifications. ```bash git reset HEAD <file> ``` -- This will unstage the file but maintain the modifications. To revert the file back to the state it was in before the changes we can use: +- To revert the file back to the state it was in before the changes we can use: ```bash git checkout -- <file> diff --git a/doc/user/admin_area/img/index_runners_search_or_filter.png b/doc/user/admin_area/img/index_runners_search_or_filter.png Binary files differnew file mode 100755 index 00000000000..5176a1a39bf --- /dev/null +++ b/doc/user/admin_area/img/index_runners_search_or_filter.png diff --git a/doc/user/admin_area/index.md b/doc/user/admin_area/index.md index 0fc6ed349ba..527110d53df 100644 --- a/doc/user/admin_area/index.md +++ b/doc/user/admin_area/index.md @@ -20,7 +20,7 @@ The Admin Area is made up of the following sections: | Section | Description | |:---------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------| -| Overview | View your GitLab [Dashboard](#admin-dashboard), and administer [projects](#administer-projects), [users](#administer-users), groups, jobs, runners, and Gitaly servers. | +| Overview | View your GitLab [Dashboard](#admin-dashboard), and administer [projects](#administer-projects), [users](#administer-users), groups, [jobs](#administer-jobs), [Runners](#administer-runners), and [Gitaly servers](#administer-gitaly-servers). | | Monitoring | View GitLab system information, and information on background jobs, logs, [health checks](monitoring/health_check.md), request profiles, and audit logs. | | Messages | Send and manage [broadcast messages](broadcast_messages.md) for your users. | | System Hooks | Configure [system hooks](../../system_hooks/system_hooks.md) for many events. | @@ -109,7 +109,12 @@ created and the date of last activity. To edit a user, click the **Edit** button row. To delete the user, or delete the user and their contributions, click the cog dropdown in that user's row, and select the desired option. -To change the sort order, click the sort dropdown and select the desired order. By default the sort dropdown shows **Name**. +To change the sort order: + +1. Click the sort dropdown. +1. Select the desired order. + +By default the sort dropdown shows **Name**. To search for users, enter your criteria in the search field. The user search is case insensitive, and applies partial matching to name and username. To search for an email address, @@ -138,3 +143,66 @@ For each job, the following details are listed: | Name | Name of the job specified in a `.gitlab-ci.yml` file. | | Timing | Duration of the job, and how long ago the job completed. | | Coverage | Percentage of tests coverage. | + +## Administer Runners + +You can adminster all Runners in the GitLab instance from the Admin Area's **Runners** page. See +[GitLab Runner](https://docs.gitlab.com/runner/) for more information on Runner itself. + +To access the **Runners** page, go to **Admin Area > Overview > Runners**. + +The **Runners** page features: + +- A description of Runners, and their possible states. +- Instructions on installing a Runner. +- A list of all registered Runners. + +Runners are listed in descending order by the date they were created, by default. You can change +the sort order to *Last Contacted* from the dropdown beside the search field. + +To search Runners' descriptions: + +1. In the **Search or filter results...** field, type the description of the Runner you want to +find. +1. Press Enter. + +You can also filter Runners by status, type, and tag. To filter: + +1. Click in the **Search or filter results...** field. +1. Select **status:**, **type:**, or **tag:** +1. Select or enter your search criteria. + +![Attributes of a Runner, with the **Search or filter results...** field active](img/index_runners_search_or_filter.png) + +For each Runner, the following attributes are listed: + +| Attribute | Description | +| ------------ | ----------- | +| Type | One or more of the following states: shared, group, specific, locked, or paused | +| Runner token | Token used to identify the Runner, and which the Runner uses to communicate with the GitLab instance | +| Description | Description given to the Runner when it was created | +| Version | GitLab Runner version | +| IP address | IP address of the host on which the Runner is registered | +| Projects | Projects to which the Runner is assigned | +| Jobs | Total of jobs run by the Runner | +| Tags | Tags associated with the Runner | +| Last contact | Timestamp indicating when the GitLab instance last contacted the Runner | + +You can also edit, pause, or remove each Runner. + +## Administer Gitaly servers + +You can list all Gitaly servers in the GitLab instance from the Admin Area's **Gitaly Servers** +page. For more details, see [Gitaly](../../administration/gitaly/index.md). + +To access the **Gitaly Servers** page, go to **Admin Area > Overview > Gitaly Servers**. + +For each Gitaly server, the following details are listed: + +| Field | Description | +| -------------- | ----------- | +| Storage | Repository storage | +| Address | Network address on which the Gitaly server is listening | +| Server version | Gitaly version | +| Git version | Version of Git installed on the Gitaly server | +| Up to date | Indicates if the Gitaly server version is the latest version available. A green dot indicates the server is up to date. | diff --git a/doc/user/admin_area/settings/instance_template_repository.md b/doc/user/admin_area/settings/instance_template_repository.md index 4010008f694..91286a67c31 100644 --- a/doc/user/admin_area/settings/instance_template_repository.md +++ b/doc/user/admin_area/settings/instance_template_repository.md @@ -1,3 +1,7 @@ +--- +type: reference +--- + # Instance template repository **[PREMIUM ONLY]** > [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/5986) in @@ -61,3 +65,15 @@ top of the list. If this feature is disabled or no templates are present, there will be no "Custom" section in the selection dropdown. + +<!-- ## Troubleshooting + +Include any troubleshooting steps that you can foresee. If you know beforehand what issues +one might have when setting this up, or when something is changed, or on upgrading, it's +important to describe those, too. Think of things that may go wrong and include them here. +This is important to minimize requests for support, and to avoid doc comments with +questions that you know someone might ask. + +Each scenario can be a third-level heading, e.g. `### Getting error message X`. +If you have none to add when creating a doc, leave this section in place +but commented out to help encourage others to add to it in the future. --> diff --git a/doc/user/admin_area/settings/sign_up_restrictions.md b/doc/user/admin_area/settings/sign_up_restrictions.md index d3ecfd42903..cebf36c7ec1 100644 --- a/doc/user/admin_area/settings/sign_up_restrictions.md +++ b/doc/user/admin_area/settings/sign_up_restrictions.md @@ -1,3 +1,7 @@ +--- +type: reference +--- + # Sign-up restrictions You can block email addresses of specific domains, or whitelist only some @@ -37,5 +41,17 @@ semicolon, comma, or a new line. ![Domain Blacklist](img/domain_blacklist.png) +<!-- ## Troubleshooting + +Include any troubleshooting steps that you can foresee. If you know beforehand what issues +one might have when setting this up, or when something is changed, or on upgrading, it's +important to describe those, too. Think of things that may go wrong and include them here. +This is important to minimize requests for support, and to avoid doc comments with +questions that you know someone might ask. + +Each scenario can be a third-level heading, e.g. `### Getting error message X`. +If you have none to add when creating a doc, leave this section in place +but commented out to help encourage others to add to it in the future. --> + [ce-5259]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5259 [ce-598]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/598 diff --git a/doc/user/admin_area/settings/third_party_offers.md b/doc/user/admin_area/settings/third_party_offers.md index 23311801790..d3c9cf7d8ff 100644 --- a/doc/user/admin_area/settings/third_party_offers.md +++ b/doc/user/admin_area/settings/third_party_offers.md @@ -1,9 +1,26 @@ +--- +type: reference +--- + # Third party offers > [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/20379) > in [GitLab Core](https://about.gitlab.com/pricing/) 11.1 -Within GitLab, we inform users of available third-party offers they might find valuable in order to enhance the development of their projects. -An example is the Google Cloud Platform free credit for using [Google Kubernetes Engine](https://cloud.google.com/kubernetes-engine/). +Within GitLab, we inform users of available third-party offers they might find valuable in order +to enhance the development of their projects. An example is the Google Cloud Platform free credit +for using [Google Kubernetes Engine](https://cloud.google.com/kubernetes-engine/). + +The display of third-party offers can be toggled in the **Admin Area > Settings** page. + +<!-- ## Troubleshooting + +Include any troubleshooting steps that you can foresee. If you know beforehand what issues +one might have when setting this up, or when something is changed, or on upgrading, it's +important to describe those, too. Think of things that may go wrong and include them here. +This is important to minimize requests for support, and to avoid doc comments with +questions that you know someone might ask. -The display of third-party offers can be toggled in the Admin area on the Settings page. +Each scenario can be a third-level heading, e.g. `### Getting error message X`. +If you have none to add when creating a doc, leave this section in place +but commented out to help encourage others to add to it in the future. --> diff --git a/doc/user/project/container_registry.md b/doc/user/project/container_registry.md index 83b268db967..58b7fe33906 100644 --- a/doc/user/project/container_registry.md +++ b/doc/user/project/container_registry.md @@ -53,7 +53,7 @@ If you visit the **Registry** link under your project's menu, you can see the explicit instructions to login to the Container Registry using your GitLab credentials. -For example if the Registry's URL is `registry.example.com`, the you should be +For example if the Registry's URL is `registry.example.com`, then you should be able to login with: ``` @@ -204,7 +204,7 @@ at the communication between the client and the Registry. The REST API between the Docker client and Registry is [described here](https://docs.docker.com/registry/spec/api/). Normally, one would just use Wireshark or tcpdump to capture the traffic and see where things went -wrong. However, since all communication between Docker clients and servers +wrong. However, since all communications between Docker clients and servers are done over HTTPS, it's a bit difficult to decrypt the traffic quickly even if you know the private key. What can we do instead? diff --git a/doc/user/project/import/gitlab_com.md b/doc/user/project/import/gitlab_com.md index 3b37da67a5b..f48a158e2d3 100644 --- a/doc/user/project/import/gitlab_com.md +++ b/doc/user/project/import/gitlab_com.md @@ -1,8 +1,9 @@ # Project importing from GitLab.com to your private GitLab instance You can import your existing GitLab.com projects to your GitLab instance. But keep in mind that it is possible only if -GitLab support is enabled on your GitLab instance. -You can read more about GitLab support [here](http://docs.gitlab.com/ce/integration/gitlab.html) +GitLab.com integration is enabled on your GitLab instance. +[Read more about GitLab.com integration for self-managed GitLab instances](../../../integration/gitlab.md). + To get to the importer page you need to go to "New project" page. >**Note:** diff --git a/doc/user/project/import/phabricator.md b/doc/user/project/import/phabricator.md index 4d1d99fd35b..5c624e3aff6 100644 --- a/doc/user/project/import/phabricator.md +++ b/doc/user/project/import/phabricator.md @@ -15,18 +15,15 @@ Currently, only the following basic fields are imported: - Created at - Closed at - ## Enabling this feature While this feature is incomplete, a feature flag is required to enable it so that we can gain early feedback before releasing it for everyone. To enable it: -1. Enable Phabricator as an [import source](../../admin_area/settings/visibility_and_access_controls.md#import-sources) in the Admin area. +1. Run the following command in a Rails console: - ``` {.ruby} - Feature.enable(:phabricator_import) - ``` + ```ruby + Feature.enable(:phabricator_import) + ``` -The [import -source](../../admin_area/settings/visibility_and_access_controls.md#import-sources) -also needs to be activated by an admin in the admin interface. +1. Enable Phabricator as an [import source](../../admin_area/settings/visibility_and_access_controls.md#import-sources) in the Admin area. diff --git a/doc/user/project/issues/multiple_assignees_for_issues.md b/doc/user/project/issues/multiple_assignees_for_issues.md index 8781ebdd5b0..d1db0790d69 100644 --- a/doc/user/project/issues/multiple_assignees_for_issues.md +++ b/doc/user/project/issues/multiple_assignees_for_issues.md @@ -22,7 +22,7 @@ Consider a team formed by frontend developers, backend developers, UX designers, QA testers, and a product manager working together to bring an idea to market. -Multiple Assignees for Issues makes collaboration smother, +Multiple Assignees for Issues makes collaboration smoother, and allows shared responsibilities to be clearly displayed. All assignees are shown across your team's workflows and receive notifications (as they would as single assignees), simplifying communication and ownership. diff --git a/doc/user/project/merge_requests/merge_request_approvals.md b/doc/user/project/merge_requests/merge_request_approvals.md index 52b6b56af84..2e9db949890 100644 --- a/doc/user/project/merge_requests/merge_request_approvals.md +++ b/doc/user/project/merge_requests/merge_request_approvals.md @@ -3,7 +3,7 @@ > Introduced in [GitLab Enterprise Edition 7.12](https://about.gitlab.com/2015/06/22/gitlab-7-12-released/#merge-request-approvers-ee-only). NOTE: **Note:** -If you are running a self-managed instance, the new interface shown on +Prior to 12.0, if you are running a self-managed instance, the new interface shown on this page will not be available unless the feature flag `approval_rules` is enabled, which can be done from the Rails console by instance administrators. diff --git a/lib/api/commits.rb b/lib/api/commits.rb index 65eb9bfb87e..80913f4ca07 100644 --- a/lib/api/commits.rb +++ b/lib/api/commits.rb @@ -96,17 +96,27 @@ module API end end optional :start_branch, type: String, desc: 'Name of the branch to start the new commit from' + optional :start_project, types: [Integer, String], desc: 'The ID or path of the project to start the commit from' optional :author_email, type: String, desc: 'Author email for commit' optional :author_name, type: String, desc: 'Author name for commit' optional :stats, type: Boolean, default: true, desc: 'Include commit stats' optional :force, type: Boolean, default: false, desc: 'When `true` overwrites the target branch with a new commit based on the `start_branch`' end post ':id/repository/commits' do + if params[:start_project] + start_project = find_project!(params[:start_project]) + + unless user_project.forked_from?(start_project) + forbidden!("Project is not included in the fork network for #{start_project.full_name}") + end + end + authorize_push_to_branch!(params[:branch]) attrs = declared_params attrs[:branch_name] = attrs.delete(:branch) attrs[:start_branch] ||= attrs[:branch_name] + attrs[:start_project] = start_project if start_project result = ::Files::MultiService.new(user_project, current_user, attrs).execute diff --git a/lib/api/discussions.rb b/lib/api/discussions.rb index 5928ee1657b..693172b7d08 100644 --- a/lib/api/discussions.rb +++ b/lib/api/discussions.rb @@ -206,7 +206,7 @@ module API delete_note(noteable, params[:note_id]) end - if Noteable::RESOLVABLE_TYPES.include?(noteable_type.to_s) + if Noteable.resolvable_types.include?(noteable_type.to_s) desc "Resolve/unresolve an existing #{noteable_type.to_s.downcase} discussion" do success Entities::Discussion end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 4e99fec02dd..b1b6e7bd7b9 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -699,7 +699,7 @@ module API # See https://gitlab.com/gitlab-org/gitlab-ce/issues/42344 for more # information. expose :merge_status do |merge_request| - merge_request.check_if_can_be_merged + merge_request.check_mergeability merge_request.merge_status end expose :diff_head_sha, as: :sha diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index ce85772e4ed..5bbf6df78b0 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -386,9 +386,8 @@ module API ) if merge_when_pipeline_succeeds && merge_request.head_pipeline && merge_request.head_pipeline.active? - ::MergeRequests::MergeWhenPipelineSucceedsService - .new(merge_request.target_project, current_user, merge_params) - .execute(merge_request) + AutoMergeService.new(merge_request.target_project, current_user, merge_params) + .execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) else ::MergeRequests::MergeService .new(merge_request.target_project, current_user, merge_params) @@ -398,28 +397,16 @@ module API present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project end - desc 'Merge a merge request to its default temporary merge ref path' - params do - optional :merge_commit_message, type: String, desc: 'Custom merge commit message' - end - put ':id/merge_requests/:merge_request_iid/merge_to_ref' do + desc 'Returns the up to date merge-ref HEAD commit' + get ':id/merge_requests/:merge_request_iid/merge_ref' do merge_request = find_project_merge_request(params[:merge_request_iid]) - authorize! :admin_merge_request, user_project - - merge_params = { - commit_message: params[:merge_commit_message] - } - - result = ::MergeRequests::MergeToRefService - .new(merge_request.target_project, current_user, merge_params) - .execute(merge_request) + result = ::MergeRequests::MergeabilityCheckService.new(merge_request).execute - if result[:status] == :success - present result.slice(:commit_id), 200 + if result.success? + present :commit_id, result.payload.dig(:merge_ref_head, :commit_id) else - http_status = result[:http_status] || 400 - render_api_error!(result[:message], http_status) + render_api_error!(result.message, 400) end end @@ -429,11 +416,9 @@ module API post ':id/merge_requests/:merge_request_iid/cancel_merge_when_pipeline_succeeds' do merge_request = find_project_merge_request(params[:merge_request_iid]) - unauthorized! unless merge_request.can_cancel_merge_when_pipeline_succeeds?(current_user) + unauthorized! unless merge_request.can_cancel_auto_merge?(current_user) - ::MergeRequests::MergeWhenPipelineSucceedsService - .new(merge_request.target_project, current_user) - .cancel(merge_request) + AutoMergeService.new(merge_request.target_project, current_user).cancel(merge_request) end desc 'Rebase the merge request against its target branch' do diff --git a/lib/banzai/filter/wiki_link_filter/rewriter.rb b/lib/banzai/filter/wiki_link_filter/rewriter.rb index f4cc8beeb52..77b5053f38c 100644 --- a/lib/banzai/filter/wiki_link_filter/rewriter.rb +++ b/lib/banzai/filter/wiki_link_filter/rewriter.rb @@ -4,6 +4,8 @@ module Banzai module Filter class WikiLinkFilter < HTML::Pipeline::Filter class Rewriter + UNSAFE_SLUG_REGEXES = [/\Ajavascript:/i].freeze + def initialize(link_string, wiki:, slug:) @uri = Addressable::URI.parse(link_string) @wiki_base_path = wiki && wiki.wiki_base_path @@ -35,6 +37,8 @@ module Banzai # Of the form `./link`, `../link`, or similar def apply_hierarchical_link_rules! + return if slug_considered_unsafe? + @uri = Addressable::URI.join(@slug, @uri) if @uri.to_s[0] == '.' end @@ -54,6 +58,10 @@ module Banzai def repository_upload? @uri.relative? && @uri.path.starts_with?(Wikis::CreateAttachmentService::ATTACHMENT_PATH) end + + def slug_considered_unsafe? + UNSAFE_SLUG_REGEXES.any? { |r| r.match?(@slug) } + end end end end diff --git a/lib/banzai/redactor.rb b/lib/banzai/redactor.rb index 7db5f5e1f7d..c2da7fec7cc 100644 --- a/lib/banzai/redactor.rb +++ b/lib/banzai/redactor.rb @@ -70,8 +70,11 @@ module Banzai # Build the raw <a> tag just with a link as href and content if # it's originally a link pattern. We shouldn't return a plain text href. original_link = - if link_reference == 'true' && href = original_content - %(<a href="#{href}">#{href}</a>) + if link_reference == 'true' + href = node.attr('href') + content = original_content + + %(<a href="#{href}">#{content}</a>) end # The reference should be replaced by the original link's content, diff --git a/lib/gitlab.rb b/lib/gitlab.rb index 3f107fbbf3b..ccaf06c5d6a 100644 --- a/lib/gitlab.rb +++ b/lib/gitlab.rb @@ -40,6 +40,7 @@ module Gitlab SUBDOMAIN_REGEX = %r{\Ahttps://[a-z0-9]+\.gitlab\.com\z}.freeze VERSION = File.read(root.join("VERSION")).strip.freeze INSTALLATION_TYPE = File.read(root.join("INSTALLATION_TYPE")).strip.freeze + HTTP_PROXY_ENV_VARS = %w(http_proxy https_proxy HTTP_PROXY HTTPS_PROXY).freeze def self.com? # Check `gl_subdomain?` as well to keep parity with gitlab.com @@ -66,6 +67,10 @@ module Gitlab end end + def self.http_proxy_env? + HTTP_PROXY_ENV_VARS.any? { |name| ENV[name] } + end + def self.process_name return 'sidekiq' if Sidekiq.server? return 'console' if defined?(Rails::Console) diff --git a/lib/gitlab/background_migration/reset_merge_status.rb b/lib/gitlab/background_migration/reset_merge_status.rb new file mode 100644 index 00000000000..447fec8903c --- /dev/null +++ b/lib/gitlab/background_migration/reset_merge_status.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # Updates the range of given MRs to merge_status "unchecked", if they're opened + # and mergeable. + class ResetMergeStatus + def perform(from_id, to_id) + relation = MergeRequest.where(id: from_id..to_id, + state: 'opened', + merge_status: 'can_be_merged') + + relation.update_all(merge_status: 'unchecked') + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/image.rb b/lib/gitlab/ci/config/entry/image.rb index 0beeb44c272..21c42857895 100644 --- a/lib/gitlab/ci/config/entry/image.rb +++ b/lib/gitlab/ci/config/entry/image.rb @@ -24,7 +24,7 @@ module Gitlab end entry :ports, Entry::Ports, - description: 'Ports used expose the image' + description: 'Ports used to expose the image' attributes :ports diff --git a/lib/gitlab/ci/config/entry/service.rb b/lib/gitlab/ci/config/entry/service.rb index 084fa4047a4..8d16371e857 100644 --- a/lib/gitlab/ci/config/entry/service.rb +++ b/lib/gitlab/ci/config/entry/service.rb @@ -24,6 +24,9 @@ module Gitlab validates :alias, type: String, presence: true, unless: ->(record) { record.ports.blank? } end + entry :ports, Entry::Ports, + description: 'Ports used to expose the service' + def alias value[:alias] end diff --git a/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml b/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml index 87cd62ef2d4..32ab216dd55 100644 --- a/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml @@ -51,7 +51,7 @@ variables: POSTGRES_VERSION: 9.6.2 KUBERNETES_VERSION: 1.11.10 - HELM_VERSION: 2.13.1 + HELM_VERSION: 2.14.0 DOCKER_DRIVER: overlay2 @@ -89,4 +89,4 @@ include: dast: except: refs: - - master
\ No newline at end of file + - master diff --git a/lib/gitlab/ci/templates/Maven.gitlab-ci.yml b/lib/gitlab/ci/templates/Maven.gitlab-ci.yml index c9838c7a7ff..08dc74e041a 100644 --- a/lib/gitlab/ci/templates/Maven.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Maven.gitlab-ci.yml @@ -1,18 +1,14 @@ ---- +# This file is a template, and might need editing before it works on your project. + # Build JAVA applications using Apache Maven (http://maven.apache.org) # For docker image tags see https://hub.docker.com/_/maven/ # # For general lifecycle information see https://maven.apache.org/guides/introduction/introduction-to-the-lifecycle.html -# -# This template will build and test your projects as well as create the documentation. -# + +# This template will build and test your projects # * Caches downloaded dependencies and plugins between invocation. # * Verify but don't deploy merge requests. # * Deploy built artifacts from master branch only. -# * Shows how to use multiple jobs in test stage for verifying functionality -# with multiple JDKs. -# * Uses site:stage to collect the documentation for multi-module projects. -# * Publishes the documentation for `master` branch. variables: # This will suppress any download for dependencies and plugins or upload messages which would clutter the console log. @@ -23,78 +19,38 @@ variables: # `installAtEnd` and `deployAtEnd` are only effective with recent version of the corresponding plugins. MAVEN_CLI_OPTS: "--batch-mode --errors --fail-at-end --show-version -DinstallAtEnd=true -DdeployAtEnd=true" +# This template uses jdk8 for verifying and deploying images +image: maven:3.3.9-jdk-8 + # Cache downloaded dependencies and plugins between builds. # To keep cache across branches add 'key: "$CI_JOB_NAME"' cache: paths: - .m2/repository -# This will only validate and compile stuff and run e.g. maven-enforcer-plugin. -# Because some enforcer rules might check dependency convergence and class duplications -# we use `test-compile` here instead of `validate`, so the correct classpath is picked up. -.validate: &validate - stage: build - script: - - 'mvn $MAVEN_CLI_OPTS test-compile' - # For merge requests do not `deploy` but only run `verify`. # See https://maven.apache.org/guides/introduction/introduction-to-the-lifecycle.html .verify: &verify stage: test script: - - 'mvn $MAVEN_CLI_OPTS verify site site:stage' + - 'mvn $MAVEN_CLI_OPTS verify' except: - master -# Validate merge requests using JDK7 -validate:jdk7: - <<: *validate - image: maven:3.3.9-jdk-7 - -# Validate merge requests using JDK8 -validate:jdk8: - <<: *validate - image: maven:3.3.9-jdk-8 - -# Verify merge requests using JDK7 -verify:jdk7: - <<: *verify - image: maven:3.3.9-jdk-7 - # Verify merge requests using JDK8 verify:jdk8: <<: *verify - image: maven:3.3.9-jdk-8 +# To deploy packages from CI, create a ci_settings.xml file +# For deploying packages to GitLab's Maven Repository: See https://gitlab.com/help/user/project/packages/maven_repository.md#creating-maven-packages-with-gitlab-cicd for more details. +# Please note: The GitLab Maven Repository is currently only available in GitLab Premium / Ultimate. # For `master` branch run `mvn deploy` automatically. -# Here you need to decide whether you want to use JDK7 or 8. -# To get this working you need to define a volume while configuring your gitlab-ci-multi-runner. -# Mount your `settings.xml` as `/root/.m2/settings.xml` which holds your secrets. -# See https://maven.apache.org/settings.html deploy:jdk8: - # Use stage test here, so the pages job may later pickup the created site. - stage: test - script: - - 'mvn $MAVEN_CLI_OPTS deploy site site:stage' - only: - - master - # Archive up the built documentation site. - artifacts: - paths: - - target/staging - image: maven:3.3.9-jdk-8 - -pages: - image: busybox:latest stage: deploy script: - # Because Maven appends the artifactId automatically to the staging path if you did define a parent pom, - # you might need to use `mv target/staging/YOUR_ARTIFACT_ID public` instead. - - mv target/staging public - dependencies: - - deploy:jdk8 - artifacts: - paths: - - public + - if [ ! -f ci_settings.xml ]; + then echo "CI settings missing\! If deploying to GitLab Maven Repository, please see https://gitlab.com/help/user/project/packages/maven_repository.md#creating-maven-packages-with-gitlab-cicd for instructions."; + fi + - 'mvn $MAVEN_CLI_OPTS deploy -s ci_settings.xml' only: - master diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 455588f3c66..dcdd3581d92 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -34,34 +34,6 @@ module Gitlab TagExistsError = Class.new(StandardError) ChecksumError = Class.new(StandardError) - class << self - def create_hooks(repo_path, global_hooks_path) - local_hooks_path = File.join(repo_path, 'hooks') - real_local_hooks_path = :not_found - - begin - real_local_hooks_path = File.realpath(local_hooks_path) - rescue Errno::ENOENT - # real_local_hooks_path == :not_found - end - - # Do nothing if hooks already exist - unless real_local_hooks_path == File.realpath(global_hooks_path) - if File.exist?(local_hooks_path) - # Move the existing hooks somewhere safe - FileUtils.mv( - local_hooks_path, - "#{local_hooks_path}.old.#{Time.now.to_i}") - end - - # Create the hooks symlink - FileUtils.ln_sf(global_hooks_path, local_hooks_path) - end - - true - end - end - # Directory name of repo attr_reader :name diff --git a/lib/gitlab/git_ref_validator.rb b/lib/gitlab/git_ref_validator.rb index 3f13ebeb9d0..dfff6823689 100644 --- a/lib/gitlab/git_ref_validator.rb +++ b/lib/gitlab/git_ref_validator.rb @@ -5,12 +5,15 @@ module Gitlab module GitRefValidator extend self + + EXPANDED_PREFIXES = %w[refs/heads/ refs/remotes/].freeze + DISALLOWED_PREFIXES = %w[-].freeze + # Validates a given name against the git reference specification # # Returns true for a valid reference name, false otherwise def validate(ref_name) - not_allowed_prefixes = %w(refs/heads/ refs/remotes/ -) - return false if ref_name.start_with?(*not_allowed_prefixes) + return false if ref_name.start_with?(*(EXPANDED_PREFIXES + DISALLOWED_PREFIXES)) return false if ref_name == 'HEAD' begin @@ -19,5 +22,21 @@ module Gitlab return false end end + + def validate_merge_request_branch(ref_name) + return false if ref_name.start_with?(*DISALLOWED_PREFIXES) + + expanded_name = if ref_name.start_with?(*EXPANDED_PREFIXES) + ref_name + else + "refs/heads/#{ref_name}" + end + + begin + Rugged::Reference.valid_name?(expanded_name) + rescue ArgumentError + return false + end + end end end diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb index 05e06eec012..e683d4e5bbe 100644 --- a/lib/gitlab/gitaly_client.rb +++ b/lib/gitlab/gitaly_client.rb @@ -33,7 +33,7 @@ module Gitlab SERVER_FEATURE_CATFILE_CACHE = 'catfile-cache'.freeze # Server feature flags should use '_' to separate words. - SERVER_FEATURE_FLAGS = [SERVER_FEATURE_CATFILE_CACHE, 'delta_islands'].freeze + SERVER_FEATURE_FLAGS = [SERVER_FEATURE_CATFILE_CACHE].freeze MUTEX = Mutex.new diff --git a/lib/gitlab/graphql/loaders/batch_project_statistics_loader.rb b/lib/gitlab/graphql/loaders/batch_project_statistics_loader.rb new file mode 100644 index 00000000000..5e151f4dbd7 --- /dev/null +++ b/lib/gitlab/graphql/loaders/batch_project_statistics_loader.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Loaders + class BatchProjectStatisticsLoader + attr_reader :project_id + + def initialize(project_id) + @project_id = project_id + end + + def find + BatchLoader.for(project_id).batch do |project_ids, loader| + ProjectStatistics.for_project_ids(project_ids).each do |statistics| + loader.call(statistics.project_id, statistics) + end + end + end + end + end + end +end diff --git a/lib/gitlab/http.rb b/lib/gitlab/http.rb index 313b5df51d4..db2b4dde244 100644 --- a/lib/gitlab/http.rb +++ b/lib/gitlab/http.rb @@ -18,7 +18,7 @@ module Gitlab include HTTParty # rubocop:disable Gitlab/HTTParty - connection_adapter ProxyHTTPConnectionAdapter + connection_adapter HTTPConnectionAdapter def self.perform_request(http_method, path, options, &block) super diff --git a/lib/gitlab/proxy_http_connection_adapter.rb b/lib/gitlab/http_connection_adapter.rb index a64cb47e77e..41eab3658bc 100644 --- a/lib/gitlab/proxy_http_connection_adapter.rb +++ b/lib/gitlab/http_connection_adapter.rb @@ -10,17 +10,19 @@ # # This option will take precedence over the global setting. module Gitlab - class ProxyHTTPConnectionAdapter < HTTParty::ConnectionAdapter + class HTTPConnectionAdapter < HTTParty::ConnectionAdapter def connection - unless allow_local_requests? - begin - Gitlab::UrlBlocker.validate!(uri, allow_local_network: false) - rescue Gitlab::UrlBlocker::BlockedUrlError => e - raise Gitlab::HTTP::BlockedUrlError, "URL '#{uri}' is blocked: #{e.message}" - end + begin + @uri, hostname = Gitlab::UrlBlocker.validate!(uri, allow_local_network: allow_local_requests?, + allow_localhost: allow_local_requests?, + dns_rebind_protection: dns_rebind_protection?) + rescue Gitlab::UrlBlocker::BlockedUrlError => e + raise Gitlab::HTTP::BlockedUrlError, "URL '#{uri}' is blocked: #{e.message}" end - super + super.tap do |http| + http.hostname_override = hostname if hostname + end end private @@ -29,6 +31,12 @@ module Gitlab options.fetch(:allow_local_requests, allow_settings_local_requests?) end + def dns_rebind_protection? + return false if Gitlab.http_proxy_env? + + Gitlab::CurrentSettings.dns_rebinding_protection_enabled? + end + def allow_settings_local_requests? Gitlab::CurrentSettings.allow_local_requests_from_hooks_and_services? end diff --git a/lib/gitlab/import_export/attribute_cleaner.rb b/lib/gitlab/import_export/attribute_cleaner.rb index 93b37b7bc5f..c28a1674018 100644 --- a/lib/gitlab/import_export/attribute_cleaner.rb +++ b/lib/gitlab/import_export/attribute_cleaner.rb @@ -4,6 +4,7 @@ module Gitlab module ImportExport class AttributeCleaner ALLOWED_REFERENCES = RelationFactory::PROJECT_REFERENCES + RelationFactory::USER_REFERENCES + ['group_id'] + PROHIBITED_REFERENCES = Regexp.union(/\Acached_markdown_version\Z/, /_id\Z/, /_html\Z/).freeze def self.clean(*args) new(*args).clean @@ -24,7 +25,11 @@ module Gitlab private def prohibited_key?(key) - key.end_with?('_id') && !ALLOWED_REFERENCES.include?(key) + key =~ PROHIBITED_REFERENCES && !permitted_key?(key) + end + + def permitted_key?(key) + ALLOWED_REFERENCES.include?(key) end def excluded_key?(key) diff --git a/lib/gitlab/metrics/samplers/puma_sampler.rb b/lib/gitlab/metrics/samplers/puma_sampler.rb index 87669b253bc..25e40c70230 100644 --- a/lib/gitlab/metrics/samplers/puma_sampler.rb +++ b/lib/gitlab/metrics/samplers/puma_sampler.rb @@ -51,10 +51,11 @@ module Gitlab set_master_metrics(stats) stats['worker_status'].each do |worker| + last_status = worker['last_status'] labels = { worker: "worker_#{worker['index']}" } metrics[:puma_phase].set(labels, worker['phase']) - set_worker_metrics(worker['last_status'], labels) + set_worker_metrics(last_status, labels) if last_status.present? end end diff --git a/lib/gitlab/metrics/samplers/ruby_sampler.rb b/lib/gitlab/metrics/samplers/ruby_sampler.rb index 18a69321905..4d9c43f37e7 100644 --- a/lib/gitlab/metrics/samplers/ruby_sampler.rb +++ b/lib/gitlab/metrics/samplers/ruby_sampler.rb @@ -23,25 +23,32 @@ module Gitlab end def init_metrics - metrics = {} - metrics[:sampler_duration] = ::Gitlab::Metrics.counter(with_prefix(:sampler, :duration_seconds_total), 'Sampler time', labels) - metrics[:total_time] = ::Gitlab::Metrics.counter(with_prefix(:gc, :duration_seconds_total), 'Total GC time', labels) + metrics = { + file_descriptors: ::Gitlab::Metrics.gauge(with_prefix(:file, :descriptors), 'File descriptors used', labels, :livesum), + memory_bytes: ::Gitlab::Metrics.gauge(with_prefix(:memory, :bytes), 'Memory used', labels, :livesum), + process_cpu_seconds_total: ::Gitlab::Metrics.gauge(with_prefix(:process, :cpu_seconds_total), 'Process CPU seconds total'), + process_max_fds: ::Gitlab::Metrics.gauge(with_prefix(:process, :max_fds), 'Process max fds'), + process_resident_memory_bytes: ::Gitlab::Metrics.gauge(with_prefix(:process, :resident_memory_bytes), 'Memory used', labels, :livesum), + process_start_time_seconds: ::Gitlab::Metrics.gauge(with_prefix(:process, :start_time_seconds), 'Process start time seconds'), + sampler_duration: ::Gitlab::Metrics.counter(with_prefix(:sampler, :duration_seconds_total), 'Sampler time', labels), + total_time: ::Gitlab::Metrics.counter(with_prefix(:gc, :duration_seconds_total), 'Total GC time', labels) + } + GC.stat.keys.each do |key| metrics[key] = ::Gitlab::Metrics.gauge(with_prefix(:gc_stat, key), to_doc_string(key), labels, :livesum) end - metrics[:memory_usage] = ::Gitlab::Metrics.gauge(with_prefix(:memory, :bytes), 'Memory used', labels, :livesum) - metrics[:file_descriptors] = ::Gitlab::Metrics.gauge(with_prefix(:file, :descriptors), 'File descriptors used', labels, :livesum) - metrics end def sample start_time = System.monotonic_time - metrics[:memory_usage].set(labels.merge(worker_label), System.memory_usage) metrics[:file_descriptors].set(labels.merge(worker_label), System.file_descriptor_count) - + metrics[:process_cpu_seconds_total].set(labels.merge(worker_label), ::Gitlab::Metrics::System.cpu_time) + metrics[:process_max_fds].set(labels.merge(worker_label), ::Gitlab::Metrics::System.max_open_file_descriptors) + metrics[:process_start_time_seconds].set(labels.merge(worker_label), ::Gitlab::Metrics::System.process_start_time) + set_memory_usage_metrics sample_gc metrics[:sampler_duration].increment(labels, System.monotonic_time - start_time) @@ -61,6 +68,14 @@ module Gitlab metrics[:total_time].increment(labels, GC::Profiler.total_time) end + def set_memory_usage_metrics + memory_usage = System.memory_usage + memory_labels = labels.merge(worker_label) + + metrics[:memory_bytes].set(memory_labels, memory_usage) + metrics[:process_resident_memory_bytes].set(memory_labels, memory_usage) + end + def worker_label return {} unless defined?(Unicorn::Worker) diff --git a/lib/gitlab/metrics/samplers/unicorn_sampler.rb b/lib/gitlab/metrics/samplers/unicorn_sampler.rb index bec64e864b3..9af7e0afed4 100644 --- a/lib/gitlab/metrics/samplers/unicorn_sampler.rb +++ b/lib/gitlab/metrics/samplers/unicorn_sampler.rb @@ -4,16 +4,16 @@ module Gitlab module Metrics module Samplers class UnicornSampler < BaseSampler - def initialize(interval) - super(interval) + def metrics + @metrics ||= init_metrics end - def unicorn_active_connections - @unicorn_active_connections ||= ::Gitlab::Metrics.gauge(:unicorn_active_connections, 'Unicorn active connections', {}, :max) - end - - def unicorn_queued_connections - @unicorn_queued_connections ||= ::Gitlab::Metrics.gauge(:unicorn_queued_connections, 'Unicorn queued connections', {}, :max) + def init_metrics + { + unicorn_active_connections: ::Gitlab::Metrics.gauge(:unicorn_active_connections, 'Unicorn active connections', {}, :max), + unicorn_queued_connections: ::Gitlab::Metrics.gauge(:unicorn_queued_connections, 'Unicorn queued connections', {}, :max), + unicorn_workers: ::Gitlab::Metrics.gauge(:unicorn_workers, 'Unicorn workers') + } end def enabled? @@ -23,14 +23,13 @@ module Gitlab def sample Raindrops::Linux.tcp_listener_stats(tcp_listeners).each do |addr, stats| - unicorn_active_connections.set({ socket_type: 'tcp', socket_address: addr }, stats.active) - unicorn_queued_connections.set({ socket_type: 'tcp', socket_address: addr }, stats.queued) + set_unicorn_connection_metrics('tcp', addr, stats) end - Raindrops::Linux.unix_listener_stats(unix_listeners).each do |addr, stats| - unicorn_active_connections.set({ socket_type: 'unix', socket_address: addr }, stats.active) - unicorn_queued_connections.set({ socket_type: 'unix', socket_address: addr }, stats.queued) + set_unicorn_connection_metrics('unix', addr, stats) end + + metrics[:unicorn_workers].set({}, unicorn_workers_count) end private @@ -39,6 +38,13 @@ module Gitlab @tcp_listeners ||= Unicorn.listener_names.grep(%r{\A[^/]+:\d+\z}) end + def set_unicorn_connection_metrics(type, addr, stats) + labels = { socket_type: type, socket_address: addr } + + metrics[:unicorn_active_connections].set(labels, stats.active) + metrics[:unicorn_queued_connections].set(labels, stats.queued) + end + def unix_listeners @unix_listeners ||= Unicorn.listener_names - tcp_listeners end @@ -46,6 +52,10 @@ module Gitlab def unicorn_with_listeners? defined?(Unicorn) && Unicorn.listener_names.any? end + + def unicorn_workers_count + `pgrep -f '[u]nicorn_rails worker.+ #{Rails.root.to_s}'`.split.count + end end end end diff --git a/lib/gitlab/metrics/system.rb b/lib/gitlab/metrics/system.rb index 426496855e3..33c0de91c11 100644 --- a/lib/gitlab/metrics/system.rb +++ b/lib/gitlab/metrics/system.rb @@ -23,6 +23,22 @@ module Gitlab def self.file_descriptor_count Dir.glob('/proc/self/fd/*').length end + + def self.max_open_file_descriptors + match = File.read('/proc/self/limits').match(/Max open files\s*(\d+)/) + + return unless match && match[1] + + match[1].to_i + end + + def self.process_start_time + fields = File.read('/proc/self/stat').split + + # fields[21] is linux proc stat field "(22) starttime". + # The value is expressed in clock ticks, divide by clock ticks for seconds. + ( fields[21].to_i || 0 ) / clk_tck + end else def self.memory_usage 0.0 @@ -31,6 +47,14 @@ module Gitlab def self.file_descriptor_count 0 end + + def self.max_open_file_descriptors + 0 + end + + def self.process_start_time + 0 + end end # THREAD_CPUTIME is not supported on OS X @@ -59,6 +83,10 @@ module Gitlab def self.monotonic_time Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second) end + + def self.clk_tck + @clk_tck ||= `getconf CLK_TCK`.to_i + end end end end diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb index 78337518988..0f3b97e2317 100644 --- a/lib/gitlab/project_search_results.rb +++ b/lib/gitlab/project_search_results.rb @@ -138,6 +138,12 @@ module Gitlab project end + def filter_milestones_by_project(milestones) + return Milestone.none unless Ability.allowed?(@current_user, :read_milestone, @project) + + milestones.where(project_id: project.id) # rubocop: disable CodeReuse/ActiveRecord + end + def repository_project_ref @repository_project_ref ||= repository_ref || project.default_branch end diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index 4a097a00101..7c1e6b1baff 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -103,9 +103,11 @@ module Gitlab # rubocop: disable CodeReuse/ActiveRecord def milestones - milestones = Milestone.where(project_id: project_ids_relation) - milestones = milestones.search(query) - milestones.reorder('milestones.updated_at DESC') + milestones = Milestone.search(query) + + milestones = filter_milestones_by_project(milestones) + + milestones.reorder('updated_at DESC') end # rubocop: enable CodeReuse/ActiveRecord @@ -123,6 +125,26 @@ module Gitlab 'projects' end + # Filter milestones by authorized projects. + # For performance reasons project_id is being plucked + # to be used on a smaller query. + # + # rubocop: disable CodeReuse/ActiveRecord + def filter_milestones_by_project(milestones) + project_ids = + milestones.where(project_id: project_ids_relation) + .select(:project_id).distinct + .pluck(:project_id) + + return Milestone.none if project_ids.nil? + + authorized_project_ids_relation = + Project.where(id: project_ids).ids_with_milestone_available_for(current_user) + + milestones.where(project_id: authorized_project_ids_relation) + end + # rubocop: enable CodeReuse/ActiveRecord + # rubocop: disable CodeReuse/ActiveRecord def project_ids_relation limit_projects.select(:id).reorder(nil) diff --git a/lib/gitlab/url_blocker.rb b/lib/gitlab/url_blocker.rb index 641ba70ef83..9a8df719827 100644 --- a/lib/gitlab/url_blocker.rb +++ b/lib/gitlab/url_blocker.rb @@ -8,38 +8,68 @@ module Gitlab BlockedUrlError = Class.new(StandardError) class << self - def validate!(url, ports: [], schemes: [], allow_localhost: false, allow_local_network: true, ascii_only: false, enforce_user: false, enforce_sanitization: false) - return true if url.nil? + # Validates the given url according to the constraints specified by arguments. + # + # ports - Raises error if the given URL port does is not between given ports. + # allow_localhost - Raises error if URL resolves to a localhost IP address and argument is true. + # allow_local_network - Raises error if URL resolves to a link-local address and argument is true. + # ascii_only - Raises error if URL has unicode characters and argument is true. + # enforce_user - Raises error if URL user doesn't start with alphanumeric characters and argument is true. + # enforce_sanitization - Raises error if URL includes any HTML/CSS/JS tags and argument is true. + # + # Returns an array with [<uri>, <original-hostname>]. + # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/ParameterLists + def validate!( + url, + ports: [], + schemes: [], + allow_localhost: false, + allow_local_network: true, + ascii_only: false, + enforce_user: false, + enforce_sanitization: false, + dns_rebind_protection: true) + # rubocop:enable Metrics/CyclomaticComplexity + # rubocop:enable Metrics/ParameterLists + + return [nil, nil] if url.nil? # Param url can be a string, URI or Addressable::URI uri = parse_url(url) validate_html_tags!(uri) if enforce_sanitization - # Allow imports from the GitLab instance itself but only from the configured ports - return true if internal?(uri) - + hostname = uri.hostname port = get_port(uri) - validate_scheme!(uri.scheme, schemes) - validate_port!(port, ports) if ports.any? - validate_user!(uri.user) if enforce_user - validate_hostname!(uri.hostname) - validate_unicode_restriction!(uri) if ascii_only + + unless internal?(uri) + validate_scheme!(uri.scheme, schemes) + validate_port!(port, ports) if ports.any? + validate_user!(uri.user) if enforce_user + validate_hostname!(hostname) + validate_unicode_restriction!(uri) if ascii_only + end begin - addrs_info = Addrinfo.getaddrinfo(uri.hostname, port, nil, :STREAM).map do |addr| + addrs_info = Addrinfo.getaddrinfo(hostname, port, nil, :STREAM).map do |addr| addr.ipv6_v4mapped? ? addr.ipv6_to_ipv4 : addr end rescue SocketError - return true + return [uri, nil] end + protected_uri_with_hostname = enforce_uri_hostname(addrs_info, uri, hostname, dns_rebind_protection) + + # Allow url from the GitLab instance itself but only for the configured hostname and ports + return protected_uri_with_hostname if internal?(uri) + validate_localhost!(addrs_info) unless allow_localhost validate_loopback!(addrs_info) unless allow_localhost validate_local_network!(addrs_info) unless allow_local_network validate_link_local!(addrs_info) unless allow_local_network - true + protected_uri_with_hostname end def blocked_url?(*args) @@ -52,6 +82,25 @@ module Gitlab private + # Returns the given URI with IP address as hostname and the original hostname respectively + # in an Array. + # + # It checks whether the resolved IP address matches with the hostname. If not, it changes + # the hostname to the resolved IP address. + # + # The original hostname is used to validate the SSL, given in that scenario + # we'll be making the request to the IP address, instead of using the hostname. + def enforce_uri_hostname(addrs_info, uri, hostname, dns_rebind_protection) + address = addrs_info.first + ip_address = address&.ip_address + + return [uri, nil] unless dns_rebind_protection && ip_address && ip_address != hostname + + uri = uri.dup + uri.hostname = ip_address + [uri, hostname] + end + def get_port(uri) uri.port || uri.default_port end diff --git a/lib/gitlab/url_sanitizer.rb b/lib/gitlab/url_sanitizer.rb index 880712de5fe..215454fe63c 100644 --- a/lib/gitlab/url_sanitizer.rb +++ b/lib/gitlab/url_sanitizer.rb @@ -47,6 +47,10 @@ module Gitlab @credentials ||= { user: @url.user.presence, password: @url.password.presence } end + def user + credentials[:user] + end + def full_url @full_url ||= generate_full_url.to_s end diff --git a/lib/tasks/gitlab/shell.rake b/lib/tasks/gitlab/shell.rake index ee3ef9dad6e..487808a7baa 100644 --- a/lib/tasks/gitlab/shell.rake +++ b/lib/tasks/gitlab/shell.rake @@ -51,9 +51,6 @@ namespace :gitlab do end end - # (Re)create hooks - Rake::Task['gitlab:shell:create_hooks'].invoke - Gitlab::Shell.ensure_secret_token! end @@ -78,15 +75,6 @@ namespace :gitlab do end end end - - desc 'Create or repair repository hooks symlink' - task create_hooks: :gitlab_environment do - warn_user_is_not_gitlab - - puts 'Creating/Repairing hooks symlinks for all repositories' - system(*%W(#{Gitlab.config.gitlab_shell.path}/bin/create-hooks) + repository_storage_paths_args) - puts 'done'.color(:green) - end end def setup diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f737d75ca95..294c938d87d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2337,9 +2337,6 @@ msgstr "" msgid "ClusterIntegration|Installing Ingress may incur additional costs. Learn more about %{pricingLink}." msgstr "" -msgid "ClusterIntegration|Installing Knative may incur additional costs. Learn more about %{pricingLink}." -msgstr "" - msgid "ClusterIntegration|Instance cluster" msgstr "" @@ -2373,6 +2370,9 @@ msgstr "" msgid "ClusterIntegration|Knative Endpoint:" msgstr "" +msgid "ClusterIntegration|Knative domain name was updated successfully." +msgstr "" + msgid "ClusterIntegration|Knative extends Kubernetes to provide a set of middleware components that are essential to build modern, source-centric, and container-based applications that can run anywhere: on premises, in the cloud, or even in a third-party data center." msgstr "" @@ -2547,6 +2547,9 @@ msgstr "" msgid "ClusterIntegration|Something went wrong while uninstalling %{title}" msgstr "" +msgid "ClusterIntegration|Something went wrong while updating Knative domain name." +msgstr "" + msgid "ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{auto_devops_start}Auto DevOps%{auto_devops_end}. The domain should have a wildcard DNS configured matching the domain." msgstr "" @@ -3850,6 +3853,9 @@ msgstr "" msgid "Ends at (UTC)" msgstr "" +msgid "Enforce DNS rebinding attack protection" +msgstr "" + msgid "Enter at least three characters to search" msgstr "" @@ -5147,7 +5153,7 @@ msgstr "" msgid "If you lose your recovery codes you can generate new ones, invalidating all previous codes." msgstr "" -msgid "If your HTTP repository is not publicly accessible, add authentication information to the URL: <code>https://username:password@gitlab.company.com/group/project.git</code>." +msgid "If your HTTP repository is not publicly accessible, add your credentials." msgstr "" msgid "ImageDiffViewer|2-up" @@ -5438,6 +5444,9 @@ msgstr "" msgid "Issue update failed" msgstr "" +msgid "Issue was closed by %{name} %{reason}" +msgstr "" + msgid "IssueBoards|Board" msgstr "" @@ -5853,6 +5862,9 @@ msgstr "" msgid "Live preview" msgstr "" +msgid "Loading functions timed out. Please reload the page to try again." +msgstr "" + msgid "Loading the GitLab IDE..." msgstr "" @@ -6638,9 +6650,6 @@ msgstr "" msgid "Not found." msgstr "" -msgid "Not implemented!" -msgstr "" - msgid "Not now" msgstr "" @@ -6928,6 +6937,9 @@ msgstr "" msgid "Password" msgstr "" +msgid "Password (optional)" +msgstr "" + msgid "Password authentication is unavailable." msgstr "" @@ -8475,6 +8487,9 @@ msgstr "" msgid "Resolved by %{resolvedByName}" msgstr "" +msgid "Resolves IP addresses once and uses them to submit requests" +msgstr "" + msgid "Response metrics (AWS ELB)" msgstr "" @@ -11155,6 +11170,9 @@ msgstr "" msgid "UserProfile|Your projects can be available publicly, internally, or privately, at your choice." msgstr "" +msgid "Username (optional)" +msgstr "" + msgid "Username is already taken." msgstr "" @@ -11976,6 +11994,9 @@ msgstr "" msgid "commented on %{link_to_project}" msgstr "" +msgid "commit %{commit_id}" +msgstr "" + msgid "confidentiality|You are going to turn off the confidentiality. This means <strong>everyone</strong> will be able to see and leave a comment on this issue." msgstr "" @@ -12237,6 +12258,9 @@ msgstr "" msgid "mrWidget|Merge failed." msgstr "" +msgid "mrWidget|Merge failed: %{mergeError}. Please try again." +msgstr "" + msgid "mrWidget|Merge locally" msgstr "" @@ -12501,6 +12525,12 @@ msgstr "" msgid "verify ownership" msgstr "" +msgid "via %{closed_via}" +msgstr "" + +msgid "via merge request %{link}" +msgstr "" + msgid "view it on GitLab" msgstr "" diff --git a/package.json b/package.json index b4e83475802..3599ce2c279 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@babel/preset-env": "^7.4.4", "@gitlab/csslab": "^1.9.0", "@gitlab/svgs": "^1.63.0", - "@gitlab/ui": "^3.10.1", + "@gitlab/ui": "^3.10.3", "apollo-cache-inmemory": "^1.5.1", "apollo-client": "^2.5.1", "apollo-link": "^1.2.11", @@ -146,7 +146,7 @@ }, "devDependencies": { "@babel/plugin-transform-modules-commonjs": "^7.2.0", - "@gitlab/eslint-config": "^1.5.0", + "@gitlab/eslint-config": "^1.6.0", "@vue/test-utils": "^1.0.0-beta.25", "axios-mock-adapter": "^1.15.0", "babel-jest": "^24.1.0", diff --git a/qa/Gemfile b/qa/Gemfile index 64215b24cf1..12994b85322 100644 --- a/qa/Gemfile +++ b/qa/Gemfile @@ -10,3 +10,4 @@ gem 'airborne', '~> 0.2.13' gem 'nokogiri', '~> 1.10.3' gem 'rspec-retry', '~> 0.6.1' gem 'faker', '~> 1.6', '>= 1.6.6' +gem 'knapsack', '~> 1.17' diff --git a/qa/Gemfile.lock b/qa/Gemfile.lock index a06c88b6f0a..6b0635ed0e2 100644 --- a/qa/Gemfile.lock +++ b/qa/Gemfile.lock @@ -39,6 +39,8 @@ GEM domain_name (~> 0.5) i18n (0.9.1) concurrent-ruby (~> 1.0) + knapsack (1.17.1) + rake launchy (2.4.3) addressable (~> 2.3) method_source (0.9.0) @@ -102,6 +104,7 @@ DEPENDENCIES capybara (~> 2.16.1) capybara-screenshot (~> 1.0.18) faker (~> 1.6, >= 1.6.6) + knapsack (~> 1.17) nokogiri (~> 1.10.3) pry-byebug (~> 3.5.1) rake (~> 12.3.0) diff --git a/qa/knapsack/gitlab-ce/review-qa-all_master_report.json b/qa/knapsack/gitlab-ce/review-qa-all_master_report.json new file mode 100644 index 00000000000..f147346ba0f --- /dev/null +++ b/qa/knapsack/gitlab-ce/review-qa-all_master_report.json @@ -0,0 +1,42 @@ +{ + "qa/specs/features/browser_ui/1_manage/project/add_project_member_spec.rb": 9.697327613830566, + "qa/specs/features/browser_ui/3_create/merge_request/rebase_merge_request_spec.rb": 46.54227638244629, + "qa/specs/features/browser_ui/1_manage/project/create_project_spec.rb": 10.214765310287476, + "qa/specs/features/browser_ui/1_manage/project/view_project_activity_spec.rb": 7.882027864456177, + "qa/specs/features/api/3_create/repository/files_spec.rb": 5.015859127044678, + "qa/specs/features/browser_ui/2_plan/issue/filter_issue_comments_spec.rb": 12.772682905197144, + "qa/specs/features/browser_ui/3_create/repository/push_protected_branch_spec.rb": 29.76174831390381, + "qa/specs/features/browser_ui/3_create/repository/use_ssh_key_spec.rb": 22.800872802734375, + "qa/specs/features/browser_ui/1_manage/login/register_spec.rb": 22.320587396621704, + "qa/specs/features/api/1_manage/users_spec.rb": 0.6089541912078857, + "qa/specs/features/browser_ui/3_create/repository/clone_spec.rb": 0.9618203639984131, + "qa/specs/features/browser_ui/2_plan/issue/collapse_comments_in_discussions_spec.rb": 13.403101205825806, + "qa/specs/features/browser_ui/non_devops/performance_bar_spec.rb": 8.810423135757446, + "qa/specs/features/browser_ui/3_create/repository/push_over_http_spec.rb": 7.730542182922363, + "qa/specs/features/browser_ui/3_create/repository/add_ssh_key_spec.rb": 16.18057894706726, + "qa/specs/features/browser_ui/4_verify/ci_variable/add_ci_variable_spec.rb": 8.31815505027771, + "qa/specs/features/browser_ui/3_create/repository/push_http_private_token_spec.rb": 9.48607873916626, + "qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb": 19.552733182907104, + "qa/specs/features/browser_ui/3_create/wiki/create_edit_clone_push_wiki_spec.rb": 17.273863554000854, + "qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb": 8.281434059143066, + "qa/specs/features/browser_ui/3_create/repository/create_edit_delete_file_via_web_spec.rb": 18.047621726989746, + "qa/specs/features/browser_ui/6_release/deploy_token/add_deploy_token_spec.rb": 7.422840595245361, + "qa/specs/features/browser_ui/1_manage/login/log_in_spec.rb": 3.438166856765747, + "qa/specs/features/browser_ui/4_verify/runner/register_runner_spec.rb": 18.679633855819702, + "qa/specs/features/browser_ui/3_create/merge_request/squash_merge_request_spec.rb": 27.943300485610962, + "qa/specs/features/browser_ui/3_create/repository/add_list_delete_branches_spec.rb": 39.17585229873657, + "qa/specs/features/browser_ui/3_create/repository/add_file_template_spec.rb": 40.09336972236633, + "qa/specs/features/browser_ui/3_create/merge_request/view_merge_request_diff_patch_spec.rb": 3.705310821533203, + "qa/specs/features/browser_ui/3_create/snippet/create_snippet_spec.rb": 5.812374591827393, + "qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb": 92.46774697303772, + "qa/specs/features/browser_ui/1_manage/project/import_github_repo_spec.rb": 100.28881478309631, + "qa/specs/features/browser_ui/3_create/repository/push_mirroring_over_http_spec.rb": 23.710937023162842, + "qa/specs/features/browser_ui/1_manage/login/login_via_oauth_spec.rb": 20.58603596687317, + "qa/specs/features/browser_ui/3_create/web_ide/add_file_template_spec.rb": 25.460349321365356, + "qa/specs/features/browser_ui/3_create/repository/push_over_http_file_size_spec.rb": 19.459370374679565, + "qa/specs/features/browser_ui/3_create/repository/user_views_commit_diff_patch_spec.rb": 6.731764793395996, + "qa/specs/features/browser_ui/6_release/deploy_key/add_deploy_key_spec.rb": 15.342933893203735, + "qa/specs/features/browser_ui/2_plan/issue/issue_suggestions_spec.rb": 11.280649185180664, + "qa/specs/features/browser_ui/3_create/merge_request/create_merge_request_spec.rb": 57.48992609977722, + "qa/specs/features/browser_ui/3_create/merge_request/merge_merge_request_from_fork_spec.rb": 32.5517954826355 +}
\ No newline at end of file diff --git a/qa/qa/runtime/env.rb b/qa/qa/runtime/env.rb index 03cae3c1fe6..82510dfa03c 100644 --- a/qa/qa/runtime/env.rb +++ b/qa/qa/runtime/env.rb @@ -136,6 +136,10 @@ module QA ENV['GITLAB_QA_PASSWORD_2'] end + def knapsack? + !!(ENV['KNAPSACK_GENERATE_REPORT'] || ENV['KNAPSACK_REPORT_PATH'] || ENV['KNAPSACK_TEST_FILE_PATTERN']) + end + def ldap_username @ldap_username ||= ENV['GITLAB_LDAP_USERNAME'] end diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/issue_suggestions_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/issue_suggestions_spec.rb index f97b0e56ca2..530fc684437 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/issue_suggestions_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/issue_suggestions_spec.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module QA - # Failure issue: https://gitlab.com/gitlab-org/quality/staging/issues/53 - context 'Plan', :quarantine do + context 'Plan' do describe 'issue suggestions' do let(:issue_title) { 'Issue Lists are awesome' } @@ -10,12 +9,12 @@ module QA Runtime::Browser.visit(:gitlab, Page::Main::Login) Page::Main::Login.perform(&:sign_in_using_credentials) - project = Resource::Project.fabricate! do |resource| + project = Resource::Project.fabricate_via_api! do |resource| resource.name = 'project-for-issue-suggestions' resource.description = 'project for issue suggestions' end - Resource::Issue.fabricate! do |issue| + Resource::Issue.fabricate_via_browser_ui! do |issue| issue.title = issue_title issue.project = project end diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/push_over_http_file_size_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/push_over_http_file_size_spec.rb index a544efb35ee..5bfafdfa041 100644 --- a/qa/qa/specs/features/browser_ui/3_create/repository/push_over_http_file_size_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/repository/push_over_http_file_size_spec.rb @@ -26,6 +26,8 @@ module QA Page::Main::Login.perform(&:sign_in_using_credentials) set_file_size_limit('') + + Page::Main::Menu.perform(&:sign_out) end it 'push successful when the file size is under the limit' do diff --git a/qa/qa/specs/runner.rb b/qa/qa/specs/runner.rb index 306913dafa6..f1cb9378de8 100644 --- a/qa/qa/specs/runner.rb +++ b/qa/qa/specs/runner.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require 'rspec/core' +require 'rspec/expectations' +require 'knapsack' module QA module Specs @@ -32,10 +34,25 @@ module QA end args.push(options) - args.push(DEFAULT_TEST_PATH_ARGS) unless options.any? { |opt| opt =~ %r{/features/} } Runtime::Browser.configure! + if Runtime::Env.knapsack? + allocator = Knapsack::AllocatorBuilder.new(Knapsack::Adapters::RSpecAdapter).allocator + + QA::Runtime::Logger.info '' + QA::Runtime::Logger.info 'Report specs:' + QA::Runtime::Logger.info allocator.report_node_tests.join(', ') + QA::Runtime::Logger.info '' + QA::Runtime::Logger.info 'Leftover specs:' + QA::Runtime::Logger.info allocator.leftover_node_tests.join(', ') + QA::Runtime::Logger.info '' + + args.push(['--', allocator.node_tests]) + else + args.push(DEFAULT_TEST_PATH_ARGS) unless options.any? { |opt| opt =~ %r{/features/} } + end + RSpec::Core::Runner.run(args.flatten, $stderr, $stdout).tap do |status| abort if status.nonzero? end diff --git a/qa/spec/runtime/env_spec.rb b/qa/spec/runtime/env_spec.rb index 04085efe2ce..2560695ef2e 100644 --- a/qa/spec/runtime/env_spec.rb +++ b/qa/spec/runtime/env_spec.rb @@ -168,6 +168,30 @@ describe QA::Runtime::Env do end end + describe '.knapsack?' do + it 'returns true if KNAPSACK_GENERATE_REPORT is defined' do + stub_env('KNAPSACK_GENERATE_REPORT', 'true') + + expect(described_class.knapsack?).to be_truthy + end + + it 'returns true if KNAPSACK_REPORT_PATH is defined' do + stub_env('KNAPSACK_REPORT_PATH', '/a/path') + + expect(described_class.knapsack?).to be_truthy + end + + it 'returns true if KNAPSACK_TEST_FILE_PATTERN is defined' do + stub_env('KNAPSACK_TEST_FILE_PATTERN', '/a/**/pattern') + + expect(described_class.knapsack?).to be_truthy + end + + it 'returns false if neither KNAPSACK_GENERATE_REPORT nor KNAPSACK_REPORT_PATH nor KNAPSACK_TEST_FILE_PATTERN are defined' do + expect(described_class.knapsack?).to be_falsey + end + end + describe '.require_github_access_token!' do it 'raises ArgumentError if GITHUB_ACCESS_TOKEN is not defined' do stub_env('GITHUB_ACCESS_TOKEN', nil) diff --git a/qa/spec/spec_helper.rb b/qa/spec/spec_helper.rb index a368ffba711..f25dbf3a8ab 100644 --- a/qa/spec/spec_helper.rb +++ b/qa/spec/spec_helper.rb @@ -3,6 +3,11 @@ require_relative '../qa' require 'rspec/retry' +if ENV['CI'] && QA::Runtime::Env.knapsack? && !ENV['NO_KNAPSACK'] + require 'knapsack' + Knapsack::Adapters::RSpecAdapter.bind +end + %w[helpers shared_examples].each do |d| Dir[::File.join(__dir__, d, '**', '*.rb')].each { |f| require f } end diff --git a/spec/controllers/concerns/import_url_params_spec.rb b/spec/controllers/concerns/import_url_params_spec.rb new file mode 100644 index 00000000000..adbe6e5d3bf --- /dev/null +++ b/spec/controllers/concerns/import_url_params_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ImportUrlParams do + let(:import_url_params) do + controller = OpenStruct.new(params: params).extend(described_class) + controller.import_url_params + end + + context 'empty URL' do + let(:params) do + ActionController::Parameters.new(project: { + title: 'Test' + }) + end + + it 'returns empty hash' do + expect(import_url_params).to eq({}) + end + end + + context 'url and password separately provided' do + let(:params) do + ActionController::Parameters.new(project: { + import_url: 'https://url.com', + import_url_user: 'user', import_url_password: 'password' + }) + end + + describe '#import_url_params' do + it 'returns hash with import_url' do + expect(import_url_params).to eq( + import_url: "https://user:password@url.com" + ) + end + end + end + + context 'url with provided empty credentials' do + let(:params) do + ActionController::Parameters.new(project: { + import_url: 'https://user:password@url.com', + import_url_user: '', import_url_password: '' + }) + end + + describe '#import_url_params' do + it 'does not change the url' do + expect(import_url_params).to eq( + import_url: "https://user:password@url.com" + ) + end + end + end +end diff --git a/spec/controllers/projects/ci/lints_controller_spec.rb b/spec/controllers/projects/ci/lints_controller_spec.rb index 323a32575af..cc6ac83ca38 100644 --- a/spec/controllers/projects/ci/lints_controller_spec.rb +++ b/spec/controllers/projects/ci/lints_controller_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe Projects::Ci::LintsController do + include StubRequests + let(:project) { create(:project, :repository) } let(:user) { create(:user) } @@ -70,7 +72,7 @@ describe Projects::Ci::LintsController do context 'with a valid gitlab-ci.yml' do before do - WebMock.stub_request(:get, remote_file_path).to_return(body: remote_file_content) + stub_full_request(remote_file_path).to_return(body: remote_file_content) project.add_developer(user) post :create, params: { namespace_id: project.namespace, project_id: project, content: content } diff --git a/spec/controllers/projects/imports_controller_spec.rb b/spec/controllers/projects/imports_controller_spec.rb index 8d88ee7dfd6..bdc81efe3bc 100644 --- a/spec/controllers/projects/imports_controller_spec.rb +++ b/spec/controllers/projects/imports_controller_spec.rb @@ -122,4 +122,19 @@ describe Projects::ImportsController do end end end + + describe 'POST #create' do + let(:params) { { import_url: 'https://github.com/vim/vim.git', import_url_user: 'user', import_url_password: 'password' } } + let(:project) { create(:project) } + + before do + allow(RepositoryImportWorker).to receive(:perform_async) + + post :create, params: { project: params, namespace_id: project.namespace.to_param, project_id: project } + end + + it 'sets import_url to the project' do + expect(project.reload.import_url).to eq('https://user:password@github.com/vim/vim.git') + end + end end diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index f4a18dcba51..f8c0ab55eb4 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -429,8 +429,9 @@ describe Projects::MergeRequestsController do it 'sets the MR to merge when the pipeline succeeds' do service = double(:merge_when_pipeline_succeeds_service) + allow(service).to receive(:available_for?) { true } - expect(MergeRequests::MergeWhenPipelineSucceedsService) + expect(AutoMerge::MergeWhenPipelineSucceedsService) .to receive(:new).with(project, anything, anything) .and_return(service) expect(service).to receive(:execute).with(merge_request) @@ -713,9 +714,9 @@ describe Projects::MergeRequestsController do end end - describe 'POST cancel_merge_when_pipeline_succeeds' do + describe 'POST cancel_auto_merge' do subject do - post :cancel_merge_when_pipeline_succeeds, + post :cancel_auto_merge, params: { format: :json, namespace_id: merge_request.project.namespace.to_param, @@ -725,14 +726,15 @@ describe Projects::MergeRequestsController do xhr: true end - it 'calls MergeRequests::MergeWhenPipelineSucceedsService' do - mwps_service = double + it 'calls AutoMergeService' do + auto_merge_service = double - allow(MergeRequests::MergeWhenPipelineSucceedsService) + allow(AutoMergeService) .to receive(:new) - .and_return(mwps_service) + .and_return(auto_merge_service) - expect(mwps_service).to receive(:cancel).with(merge_request) + allow(auto_merge_service).to receive(:available_strategies).with(merge_request) + expect(auto_merge_service).to receive(:cancel).with(merge_request) subject end diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb index f8470a94f98..767cee7d54a 100644 --- a/spec/controllers/projects/milestones_controller_spec.rb +++ b/spec/controllers/projects/milestones_controller_spec.rb @@ -175,6 +175,40 @@ describe Projects::MilestonesController do end end + describe '#labels' do + render_views + + context 'as json' do + let!(:guest) { create(:user, username: 'guest1') } + let!(:group) { create(:group, :public) } + let!(:project) { create(:project, :public, group: group) } + let!(:label) { create(:label, title: 'test_label_on_private_issue', project: project) } + let!(:confidential_issue) { create(:labeled_issue, confidential: true, project: project, milestone: milestone, labels: [label]) } + + it 'does not render labels of private issues if user has no access' do + sign_in(guest) + + get :labels, params: { namespace_id: group.id, project_id: project.id, id: milestone.iid }, format: :json + + expect(response).to have_gitlab_http_status(200) + expect(response.content_type).to eq 'application/json' + + expect(json_response['html']).not_to include(label.title) + end + + it 'does render labels of private issues if user has access' do + sign_in(user) + + get :labels, params: { namespace_id: group.id, project_id: project.id, id: milestone.iid }, format: :json + + expect(response).to have_gitlab_http_status(200) + expect(response.content_type).to eq 'application/json' + + expect(json_response['html']).to include(label.title) + end + end + end + context 'promotion succeeds' do before do group.add_developer(user) diff --git a/spec/controllers/projects/serverless/functions_controller_spec.rb b/spec/controllers/projects/serverless/functions_controller_spec.rb index 782f5f272d9..18c594acae0 100644 --- a/spec/controllers/projects/serverless/functions_controller_spec.rb +++ b/spec/controllers/projects/serverless/functions_controller_spec.rb @@ -8,9 +8,8 @@ describe Projects::Serverless::FunctionsController do let(:user) { create(:user) } let(:cluster) { create(:cluster, :project, :provided_by_gcp) } - let(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } let(:service) { cluster.platform_kubernetes } - let(:project) { cluster.project} + let(:project) { cluster.project } let(:namespace) do create(:cluster_kubernetes_namespace, @@ -30,17 +29,69 @@ describe Projects::Serverless::FunctionsController do end describe 'GET #index' do - context 'empty cache' do - it 'has no data' do + let(:expected_json) { { 'knative_installed' => knative_state, 'functions' => functions } } + + context 'when cache is being read' do + let(:knative_state) { 'checking' } + let(:functions) { [] } + + before do get :index, params: params({ format: :json }) + end - expect(response).to have_gitlab_http_status(204) + it 'returns checking' do + expect(json_response).to eq expected_json end - it 'renders an html page' do - get :index, params: params + it { expect(response).to have_gitlab_http_status(200) } + end + + context 'when cache is ready' do + let(:knative_services_finder) { project.clusters.first.knative_services_finder(project) } + let(:knative_state) { true } - expect(response).to have_gitlab_http_status(200) + before do + allow_any_instance_of(Clusters::Cluster) + .to receive(:knative_services_finder) + .and_return(knative_services_finder) + synchronous_reactive_cache(knative_services_finder) + stub_kubeclient_service_pods( + kube_response({ "kind" => "PodList", "items" => [] }), + namespace: namespace.namespace + ) + end + + context 'when no functions were found' do + let(:functions) { [] } + + before do + stub_kubeclient_knative_services( + namespace: namespace.namespace, + response: kube_response({ "kind" => "ServiceList", "items" => [] }) + ) + get :index, params: params({ format: :json }) + end + + it 'returns checking' do + expect(json_response).to eq expected_json + end + + it { expect(response).to have_gitlab_http_status(200) } + end + + context 'when functions were found' do + let(:functions) { ["asdf"] } + + before do + stub_kubeclient_knative_services(namespace: namespace.namespace) + get :index, params: params({ format: :json }) + end + + it 'returns functions' do + expect(json_response["functions"]).not_to be_empty + end + + it { expect(response).to have_gitlab_http_status(200) } end end end @@ -56,11 +107,12 @@ describe Projects::Serverless::FunctionsController do context 'valid data', :use_clean_rails_memory_store_caching do before do stub_kubeclient_service_pods - stub_reactive_cache(knative, + stub_reactive_cache(cluster.knative_services_finder(project), { services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"], pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"] - }) + }, + *cluster.knative_services_finder(project).cache_args) end it 'has a valid function name' do @@ -88,11 +140,12 @@ describe Projects::Serverless::FunctionsController do describe 'GET #index with data', :use_clean_rails_memory_store_caching do before do stub_kubeclient_service_pods - stub_reactive_cache(knative, + stub_reactive_cache(cluster.knative_services_finder(project), { services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"], pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"] - }) + }, + *cluster.knative_services_finder(project).cache_args) end it 'has data' do @@ -100,11 +153,16 @@ describe Projects::Serverless::FunctionsController do expect(response).to have_gitlab_http_status(200) - expect(json_response).to contain_exactly( - a_hash_including( - "name" => project.name, - "url" => "http://#{project.name}.#{namespace.namespace}.example.com" - ) + expect(json_response).to match( + { + "knative_installed" => "checking", + "functions" => [ + a_hash_including( + "name" => project.name, + "url" => "http://#{project.name}.#{namespace.namespace}.example.com" + ) + ] + } ) end diff --git a/spec/controllers/projects/settings/ci_cd_controller_spec.rb b/spec/controllers/projects/settings/ci_cd_controller_spec.rb index db53e5bc8a4..b91a4df40a5 100644 --- a/spec/controllers/projects/settings/ci_cd_controller_spec.rb +++ b/spec/controllers/projects/settings/ci_cd_controller_spec.rb @@ -109,7 +109,7 @@ describe Projects::Settings::CiCdController do end context 'when updating the auto_devops settings' do - let(:params) { { auto_devops_attributes: { enabled: '', domain: 'mepmep.md' } } } + let(:params) { { auto_devops_attributes: { enabled: '' } } } context 'following the instance default' do let(:params) { { auto_devops_attributes: { enabled: '' } } } diff --git a/spec/controllers/sent_notifications_controller_spec.rb b/spec/controllers/sent_notifications_controller_spec.rb index 2b9df71aa3a..89857a9d21b 100644 --- a/spec/controllers/sent_notifications_controller_spec.rb +++ b/spec/controllers/sent_notifications_controller_spec.rb @@ -4,15 +4,31 @@ require 'rails_helper' describe SentNotificationsController do let(:user) { create(:user) } - let(:project) { create(:project) } - let(:sent_notification) { create(:sent_notification, project: project, noteable: issue, recipient: user) } + let(:project) { create(:project, :public) } + let(:private_project) { create(:project, :private) } + let(:sent_notification) { create(:sent_notification, project: target_project, noteable: noteable, recipient: user) } let(:issue) do - create(:issue, project: project, author: user) do |issue| - issue.subscriptions.create(user: user, project: project, subscribed: true) + create(:issue, project: target_project) do |issue| + issue.subscriptions.create(user: user, project: target_project, subscribed: true) end end + let(:confidential_issue) do + create(:issue, project: target_project, confidential: true) do |issue| + issue.subscriptions.create(user: user, project: target_project, subscribed: true) + end + end + + let(:merge_request) do + create(:merge_request, source_project: target_project, target_project: target_project) do |mr| + mr.subscriptions.create(user: user, project: target_project, subscribed: true) + end + end + + let(:noteable) { issue } + let(:target_project) { project } + describe 'GET unsubscribe' do context 'when the user is not logged in' do context 'when the force param is passed' do @@ -34,20 +50,93 @@ describe SentNotificationsController do end context 'when the force param is not passed' do + render_views + before do get(:unsubscribe, params: { id: sent_notification.reply_key }) end - it 'does not unsubscribe the user' do - expect(issue.subscribed?(user, project)).to be_truthy + shared_examples 'unsubscribing as anonymous' do + it 'does not unsubscribe the user' do + expect(noteable.subscribed?(user, target_project)).to be_truthy + end + + it 'does not set the flash message' do + expect(controller).not_to set_flash[:notice] + end + + it 'renders unsubscribe page' do + expect(response.status).to eq(200) + expect(response).to render_template :unsubscribe + end end - it 'does not set the flash message' do - expect(controller).not_to set_flash[:notice] + context 'when project is public' do + context 'when unsubscribing from issue' do + let(:noteable) { issue } + + it 'shows issue title' do + expect(response.body).to include(issue.title) + end + + it_behaves_like 'unsubscribing as anonymous' + end + + context 'when unsubscribing from confidential issue' do + let(:noteable) { confidential_issue } + + it 'does not show issue title' do + expect(response.body).not_to include(confidential_issue.title) + expect(response.body).to include(confidential_issue.to_reference) + end + + it_behaves_like 'unsubscribing as anonymous' + end + + context 'when unsubscribing from merge request' do + let(:noteable) { merge_request } + + it 'shows merge request title' do + expect(response.body).to include(merge_request.title) + end + + it_behaves_like 'unsubscribing as anonymous' + end end - it 'redirects to the login page' do - expect(response).to render_template :unsubscribe + context 'when project is not public' do + let(:target_project) { private_project } + + context 'when unsubscribing from issue' do + let(:noteable) { issue } + + it 'shows issue title' do + expect(response.body).not_to include(issue.title) + end + + it_behaves_like 'unsubscribing as anonymous' + end + + context 'when unsubscribing from confidential issue' do + let(:noteable) { confidential_issue } + + it 'does not show issue title' do + expect(response.body).not_to include(confidential_issue.title) + expect(response.body).to include(confidential_issue.to_reference) + end + + it_behaves_like 'unsubscribing as anonymous' + end + + context 'when unsubscribing from merge request' do + let(:noteable) { merge_request } + + it 'shows merge request title' do + expect(response.body).not_to include(merge_request.title) + end + + it_behaves_like 'unsubscribing as anonymous' + end end end end diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb index 6bcff7f975c..9c4ddce5409 100644 --- a/spec/controllers/sessions_controller_spec.rb +++ b/spec/controllers/sessions_controller_spec.rb @@ -58,7 +58,26 @@ describe SessionsController do it 'authenticates user correctly' do post(:create, params: { user: user_params }) - expect(subject.current_user). to eq user + expect(subject.current_user).to eq user + end + + context 'with password authentication disabled' do + before do + stub_application_setting(password_authentication_enabled_for_web: false) + end + + it 'does not sign in the user' do + post(:create, params: { user: user_params }) + + expect(@request.env['warden']).not_to be_authenticated + expect(subject.current_user).to be_nil + end + + it 'returns status 403' do + post(:create, params: { user: user_params }) + + expect(response.status).to eq 403 + end end it 'creates an audit log record' do @@ -153,6 +172,19 @@ describe SessionsController do end end + context 'with password authentication disabled' do + before do + stub_application_setting(password_authentication_enabled_for_web: false) + end + + it 'allows 2FA stage of non-password login' do + authenticate_2fa(otp_attempt: user.current_otp) + + expect(@request.env['warden']).to be_authenticated + expect(subject.current_user).to eq user + end + end + ## # See #14900 issue # diff --git a/spec/factories/ci/pipeline_schedule.rb b/spec/factories/ci/pipeline_schedule.rb index b2b79807429..4b83ba2ac1b 100644 --- a/spec/factories/ci/pipeline_schedule.rb +++ b/spec/factories/ci/pipeline_schedule.rb @@ -7,6 +7,16 @@ FactoryBot.define do description "pipeline schedule" project + trait :every_minute do + cron '*/1 * * * *' + cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE + end + + trait :hourly do + cron '* */1 * * *' + cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE + end + trait :nightly do cron '0 1 * * *' cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb index e8df5094b83..0b6a43b13a9 100644 --- a/spec/factories/merge_requests.rb +++ b/spec/factories/merge_requests.rb @@ -95,7 +95,8 @@ FactoryBot.define do end trait :merge_when_pipeline_succeeds do - merge_when_pipeline_succeeds true + auto_merge_enabled true + auto_merge_strategy AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS merge_user { author } end diff --git a/spec/factories/project_auto_devops.rb b/spec/factories/project_auto_devops.rb index 75ac7cc7687..1de42512402 100644 --- a/spec/factories/project_auto_devops.rb +++ b/spec/factories/project_auto_devops.rb @@ -2,7 +2,6 @@ FactoryBot.define do factory :project_auto_devops do project enabled true - domain "example.com" deploy_strategy :continuous trait :continuous_deployment do diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index f9950b5b03f..c4dbe23f6b4 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -332,16 +332,19 @@ describe 'Admin updates settings' do end context 'Network page' do - it 'Enable outbound requests' do + it 'Changes Outbound requests settings' do visit network_admin_application_settings_path page.within('.as-outbound') do check 'Allow requests to the local network from hooks and services' + # Enabled by default + uncheck 'Enforce DNS rebinding attack protection' click_button 'Save changes' end expect(page).to have_content "Application settings saved successfully" expect(Gitlab::CurrentSettings.allow_local_requests_from_hooks_and_services).to be true + expect(Gitlab::CurrentSettings.dns_rebinding_protection_enabled).to be false end end diff --git a/spec/features/global_search_spec.rb b/spec/features/global_search_spec.rb index d7692181453..f2ab5373d3d 100644 --- a/spec/features/global_search_spec.rb +++ b/spec/features/global_search_spec.rb @@ -25,4 +25,18 @@ describe 'Global search' do expect(page).to have_selector('.gl-pagination .next') end end + + it 'closes the dropdown on blur', :js do + visit dashboard_projects_path + + fill_in 'search', with: "a" + dropdown = find('.js-dashboard-search-options') + + expect(dropdown[:class]).to include 'show' + + find('#search').send_keys(:backspace) + find('body').click + + expect(dropdown[:class]).not_to include 'show' + end end diff --git a/spec/features/issuables/issuable_list_spec.rb b/spec/features/issuables/issuable_list_spec.rb index 7b6e9cd66b2..225b858742d 100644 --- a/spec/features/issuables/issuable_list_spec.rb +++ b/spec/features/issuables/issuable_list_spec.rb @@ -76,7 +76,7 @@ describe 'issuable list' do create(:issue, project: project, author: user) else create(:merge_request, source_project: project, source_branch: generate(:branch)) - source_branch = FFaker::Name.name + source_branch = FFaker::Lorem.characters(8) pipeline = create(:ci_empty_pipeline, project: project, ref: source_branch, status: %w(running failed success).sample, sha: 'any') create(:merge_request, title: FFaker::Lorem.sentence, source_project: project, source_branch: source_branch, head_pipeline: pipeline) end diff --git a/spec/features/merge_request/user_creates_merge_request_spec.rb b/spec/features/merge_request/user_creates_merge_request_spec.rb index bcc11217389..d05ef2a8f12 100644 --- a/spec/features/merge_request/user_creates_merge_request_spec.rb +++ b/spec/features/merge_request/user_creates_merge_request_spec.rb @@ -8,8 +8,6 @@ describe "User creates a merge request", :js do let(:user) { create(:user) } before do - stub_feature_flags(approval_rules: false) - project.add_maintainer(user) sign_in(user) end diff --git a/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb b/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb index d4ad11b3585..586b3ba170d 100644 --- a/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb +++ b/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb @@ -74,11 +74,12 @@ describe 'Merge request > User merges when pipeline succeeds', :js do source_project: project, title: 'Bug NS-04', author: user, - merge_user: user, - merge_params: { force_remove_source_branch: '1' }) + merge_user: user) end before do + merge_request.merge_params['force_remove_source_branch'] = '0' + merge_request.save! click_link "Cancel automatic merge" end @@ -102,11 +103,11 @@ describe 'Merge request > User merges when pipeline succeeds', :js do context 'when merge when pipeline succeeds is enabled' do let(:merge_request) do - create(:merge_request_with_diffs, :simple, source_project: project, - author: user, - merge_user: user, - title: 'MepMep', - merge_when_pipeline_succeeds: true) + create(:merge_request_with_diffs, :simple, :merge_when_pipeline_succeeds, + source_project: project, + author: user, + merge_user: user, + title: 'MepMep') end let!(:build) do create(:ci_build, pipeline: pipeline) @@ -158,8 +159,8 @@ describe 'Merge request > User merges when pipeline succeeds', :js do # Wait for the `ci_status` and `merge_check` requests wait_for_requests - page.within('.mr-widget-body') do - expect(page).to have_content('Something went wrong') + page.within('.mr-section-container') do + expect(page).to have_content('Merge failed: Something went wrong') end end end @@ -177,8 +178,8 @@ describe 'Merge request > User merges when pipeline succeeds', :js do # Wait for the `ci_status` and `merge_check` requests wait_for_requests - page.within('.mr-widget-body') do - expect(page).to have_content('Something went wrong') + page.within('.mr-section-container') do + expect(page).to have_content('Merge failed: Something went wrong') end end end diff --git a/spec/features/merge_request/user_sees_merge_widget_spec.rb b/spec/features/merge_request/user_sees_merge_widget_spec.rb index 93ddde623fe..0066e985fbb 100644 --- a/spec/features/merge_request/user_sees_merge_widget_spec.rb +++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb @@ -314,7 +314,8 @@ describe 'Merge request > User sees merge widget', :js do context 'view merge request with MWPS enabled but automatically merge fails' do before do merge_request.update( - merge_when_pipeline_succeeds: true, + auto_merge_enabled: true, + auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS, merge_user: merge_request.author, merge_error: 'Something went wrong' ) @@ -326,8 +327,8 @@ describe 'Merge request > User sees merge widget', :js do # Wait for the `ci_status` and `merge_check` requests wait_for_requests - page.within('.mr-widget-body') do - expect(page).to have_content('Something went wrong') + page.within('.mr-section-container') do + expect(page).to have_content('Merge failed: Something went wrong') end end end @@ -347,8 +348,8 @@ describe 'Merge request > User sees merge widget', :js do # Wait for the `ci_status` and `merge_check` requests wait_for_requests - page.within('.mr-widget-body') do - expect(page).to have_content('Something went wrong') + page.within('.mr-section-container') do + expect(page).to have_content('Merge failed: Something went wrong') end end end diff --git a/spec/features/projects/import_export/export_file_spec.rb b/spec/features/projects/import_export/export_file_spec.rb index f76f9ba7577..9d74a96ab3d 100644 --- a/spec/features/projects/import_export/export_file_spec.rb +++ b/spec/features/projects/import_export/export_file_spec.rb @@ -12,7 +12,7 @@ describe 'Import/Export - project export integration test', :js do let(:export_path) { "#{Dir.tmpdir}/import_file_spec" } let(:config_hash) { YAML.load_file(Gitlab::ImportExport.config_file).deep_stringify_keys } - let(:sensitive_words) { %w[pass secret token key encrypted] } + let(:sensitive_words) { %w[pass secret token key encrypted html] } let(:safe_list) do { token: [ProjectHook, Ci::Trigger, CommitStatus], diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb index 28ae90bc0de..8d2b1fc7e30 100644 --- a/spec/features/projects/import_export/import_file_spec.rb +++ b/spec/features/projects/import_export/import_file_spec.rb @@ -47,7 +47,6 @@ describe 'Import/Export - project import integration test', :js do expect(project.description).to eq("Foo Bar") expect(project.issues).not_to be_empty expect(project.merge_requests).not_to be_empty - expect(project_hook_exists?(project)).to be true expect(wiki_exists?(project)).to be true expect(project.import_state.status).to eq('finished') end diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb index b1a705f09ce..24041a51383 100644 --- a/spec/features/projects/pipeline_schedules_spec.rb +++ b/spec/features/projects/pipeline_schedules_spec.rb @@ -225,7 +225,7 @@ describe 'Pipeline Schedules', :js do context 'when active is true and next_run_at is NULL' do before do create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule| - pipeline_schedule.update_attribute(:cron, nil) # Consequently next_run_at will be nil + pipeline_schedule.update_attribute(:next_run_at, nil) # Consequently next_run_at will be nil end end diff --git a/spec/features/projects/serverless/functions_spec.rb b/spec/features/projects/serverless/functions_spec.rb index e14934b1672..9865dbbfb3c 100644 --- a/spec/features/projects/serverless/functions_spec.rb +++ b/spec/features/projects/serverless/functions_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' describe 'Functions', :js do include KubernetesHelpers + include ReactiveCachingHelpers let(:project) { create(:project) } let(:user) { create(:user) } @@ -13,44 +14,70 @@ describe 'Functions', :js do gitlab_sign_in(user) end - context 'when user does not have a cluster and visits the serverless page' do + shared_examples "it's missing knative installation" do before do visit project_serverless_functions_path(project) end - it 'sees an empty state' do + it 'sees an empty state require Knative installation' do expect(page).to have_link('Install Knative') expect(page).to have_selector('.empty-state') end end + context 'when user does not have a cluster and visits the serverless page' do + it_behaves_like "it's missing knative installation" + end + context 'when the user does have a cluster and visits the serverless page' do let(:cluster) { create(:cluster, :project, :provided_by_gcp) } - before do - visit project_serverless_functions_path(project) - end - - it 'sees an empty state' do - expect(page).to have_link('Install Knative') - expect(page).to have_selector('.empty-state') - end + it_behaves_like "it's missing knative installation" end context 'when the user has a cluster and knative installed and visits the serverless page' do let(:cluster) { create(:cluster, :project, :provided_by_gcp) } let(:service) { cluster.platform_kubernetes } - let(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } - let(:project) { knative.cluster.project } + let(:project) { cluster.project } + let(:knative_services_finder) { project.clusters.first.knative_services_finder(project) } + let(:namespace) do + create(:cluster_kubernetes_namespace, + cluster: cluster, + cluster_project: cluster.cluster_project, + project: cluster.cluster_project.project) + end before do - stub_kubeclient_knative_services - stub_kubeclient_service_pods + allow_any_instance_of(Clusters::Cluster) + .to receive(:knative_services_finder) + .and_return(knative_services_finder) + synchronous_reactive_cache(knative_services_finder) + stub_kubeclient_knative_services(stub_get_services_options) + stub_kubeclient_service_pods(nil, namespace: namespace.namespace) visit project_serverless_functions_path(project) end - it 'sees an empty listing of serverless functions' do - expect(page).to have_selector('.empty-state') + context 'when there are no functions' do + let(:stub_get_services_options) do + { + namespace: namespace.namespace, + response: kube_response({ "kind" => "ServiceList", "items" => [] }) + } + end + + it 'sees an empty listing of serverless functions' do + expect(page).to have_selector('.empty-state') + expect(page).not_to have_selector('.content-list') + end + end + + context 'when there are functions' do + let(:stub_get_services_options) { { namespace: namespace.namespace } } + + it 'does not see an empty listing of serverless functions' do + expect(page).not_to have_selector('.empty-state') + expect(page).to have_selector('.content-list') + end end end end diff --git a/spec/features/projects/settings/forked_project_settings_spec.rb b/spec/features/projects/settings/forked_project_settings_spec.rb index dc0278370aa..df33d215602 100644 --- a/spec/features/projects/settings/forked_project_settings_spec.rb +++ b/spec/features/projects/settings/forked_project_settings_spec.rb @@ -7,7 +7,6 @@ describe 'Projects > Settings > For a forked project', :js do let(:forked_project) { fork_project(original_project, user) } before do - stub_feature_flags(approval_rules: false) original_project.add_maintainer(user) forked_project.add_maintainer(user) sign_in(user) diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index 4fe45311b2d..27f6ed56283 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -6,7 +6,6 @@ describe 'Project' do before do stub_feature_flags(vue_file_list: false) - stub_feature_flags(approval_rules: false) end describe 'creating from template' do diff --git a/spec/finders/clusters/knative_services_finder_spec.rb b/spec/finders/clusters/knative_services_finder_spec.rb new file mode 100644 index 00000000000..b731c2bd6bf --- /dev/null +++ b/spec/finders/clusters/knative_services_finder_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::KnativeServicesFinder do + include KubernetesHelpers + include ReactiveCachingHelpers + + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:service) { cluster.platform_kubernetes } + let(:project) { cluster.cluster_project.project } + let(:namespace) do + create(:cluster_kubernetes_namespace, + cluster: cluster, + cluster_project: cluster.cluster_project, + project: project) + end + + before do + stub_kubeclient_knative_services(namespace: namespace.namespace) + stub_kubeclient_service_pods( + kube_response( + kube_knative_pods_body( + project.name, namespace.namespace + ) + ), + namespace: namespace.namespace + ) + end + + shared_examples 'a cached data' do + it 'has an unintialized cache' do + is_expected.to be_blank + end + + context 'when using synchronous reactive cache' do + before do + synchronous_reactive_cache(cluster.knative_services_finder(project)) + end + + context 'when there are functions for cluster namespace' do + it { is_expected.not_to be_blank } + end + + context 'when there are no functions for cluster namespace' do + before do + stub_kubeclient_knative_services( + namespace: namespace.namespace, + response: kube_response({ "kind" => "ServiceList", "items" => [] }) + ) + stub_kubeclient_service_pods( + kube_response({ "kind" => "PodList", "items" => [] }), + namespace: namespace.namespace + ) + end + + it { is_expected.to be_blank } + end + end + end + + describe '#service_pod_details' do + subject { cluster.knative_services_finder(project).service_pod_details(project.name) } + + it_behaves_like 'a cached data' + end + + describe '#services' do + subject { cluster.knative_services_finder(project).services } + + it_behaves_like 'a cached data' + end + + describe '#knative_detected' do + subject { cluster.knative_services_finder(project).knative_detected } + before do + synchronous_reactive_cache(cluster.knative_services_finder(project)) + end + + context 'when knative is installed' do + before do + stub_kubeclient_discover(service.api_url) + end + + it { is_expected.to be_truthy } + it "discovers knative installation" do + expect { subject } + .to change { cluster.kubeclient.knative_client.discovered } + .from(false) + .to(true) + end + end + + context 'when knative is not installed' do + before do + stub_kubeclient_discover_knative_not_found(service.api_url) + end + + it { is_expected.to be_falsy } + it "does not discover knative installation" do + expect { subject }.not_to change { cluster.kubeclient.knative_client.discovered } + end + end + end +end diff --git a/spec/finders/projects/serverless/functions_finder_spec.rb b/spec/finders/projects/serverless/functions_finder_spec.rb index 3ad38207da4..8aea45b457c 100644 --- a/spec/finders/projects/serverless/functions_finder_spec.rb +++ b/spec/finders/projects/serverless/functions_finder_spec.rb @@ -10,7 +10,7 @@ describe Projects::Serverless::FunctionsFinder do let(:user) { create(:user) } let(:cluster) { create(:cluster, :project, :provided_by_gcp) } let(:service) { cluster.platform_kubernetes } - let(:project) { cluster.project} + let(:project) { cluster.project } let(:namespace) do create(:cluster_kubernetes_namespace, @@ -23,9 +23,45 @@ describe Projects::Serverless::FunctionsFinder do project.add_maintainer(user) end + describe '#installed' do + it 'when reactive_caching is still fetching data' do + expect(described_class.new(project).knative_installed).to eq 'checking' + end + + context 'when reactive_caching has finished' do + let(:knative_services_finder) { project.clusters.first.knative_services_finder(project) } + + before do + allow_any_instance_of(Clusters::Cluster) + .to receive(:knative_services_finder) + .and_return(knative_services_finder) + synchronous_reactive_cache(knative_services_finder) + end + + context 'when knative is not installed' do + it 'returns false' do + stub_kubeclient_discover_knative_not_found(service.api_url) + + expect(described_class.new(project).knative_installed).to eq false + end + end + + context 'reactive_caching is finished and knative is installed' do + let(:knative_services_finder) { project.clusters.first.knative_services_finder(project) } + + it 'returns true' do + stub_kubeclient_knative_services(namespace: namespace.namespace) + stub_kubeclient_service_pods(nil, namespace: namespace.namespace) + + expect(described_class.new(project).knative_installed).to be true + end + end + end + end + describe 'retrieve data from knative' do - it 'does not have knative installed' do - expect(described_class.new(project).execute).to be_empty + context 'does not have knative installed' do + it { expect(described_class.new(project).execute).to be_empty } end context 'has knative installed' do @@ -38,22 +74,24 @@ describe Projects::Serverless::FunctionsFinder do it 'there are functions', :use_clean_rails_memory_store_caching do stub_kubeclient_service_pods - stub_reactive_cache(knative, + stub_reactive_cache(cluster.knative_services_finder(project), { services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"], pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"] - }) + }, + *cluster.knative_services_finder(project).cache_args) expect(finder.execute).not_to be_empty end it 'has a function', :use_clean_rails_memory_store_caching do stub_kubeclient_service_pods - stub_reactive_cache(knative, + stub_reactive_cache(cluster.knative_services_finder(project), { services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"], pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"] - }) + }, + *cluster.knative_services_finder(project).cache_args) result = finder.service(cluster.environment_scope, cluster.project.name) expect(result).not_to be_empty @@ -84,20 +122,4 @@ describe Projects::Serverless::FunctionsFinder do end end end - - describe 'verify if knative is installed' do - context 'knative is not installed' do - it 'does not have knative installed' do - expect(described_class.new(project).installed?).to be false - end - end - - context 'knative is installed' do - let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } - - it 'does have knative installed' do - expect(described_class.new(project).installed?).to be true - end - end - end end diff --git a/spec/fixtures/security-reports/dependency_list/gl-dependency-scanning-report.json b/spec/fixtures/security-reports/dependency_list/gl-dependency-scanning-report.json new file mode 100644 index 00000000000..1e62d020026 --- /dev/null +++ b/spec/fixtures/security-reports/dependency_list/gl-dependency-scanning-report.json @@ -0,0 +1,422 @@ +{ + "version": "2.1", + "vulnerabilities": [ + { + "category": "dependency_scanning", + "name": "Vulnerabilities in libxml2", + "message": "Vulnerabilities in libxml2 in nokogiri", + "description": " The version of libxml2 packaged with Nokogiri contains several vulnerabilities.\r\n Nokogiri has mitigated these issues by upgrading to libxml 2.9.5.\r\n\r\n It was discovered that a type confusion error existed in libxml2. An\r\n attacker could use this to specially construct XML data that\r\n could cause a denial of service or possibly execute arbitrary\r\n code. (CVE-2017-0663)\r\n\r\n It was discovered that libxml2 did not properly validate parsed entity\r\n references. An attacker could use this to specially construct XML\r\n data that could expose sensitive information. (CVE-2017-7375)\r\n\r\n It was discovered that a buffer overflow existed in libxml2 when\r\n handling HTTP redirects. An attacker could use this to specially\r\n construct XML data that could cause a denial of service or possibly\r\n execute arbitrary code. (CVE-2017-7376)\r\n\r\n Marcel Böhme and Van-Thuan Pham discovered a buffer overflow in\r\n libxml2 when handling elements. An attacker could use this to specially\r\n construct XML data that could cause a denial of service or possibly\r\n execute arbitrary code. (CVE-2017-9047)\r\n\r\n Marcel Böhme and Van-Thuan Pham discovered a buffer overread\r\n in libxml2 when handling elements. An attacker could use this\r\n to specially construct XML data that could cause a denial of\r\n service. (CVE-2017-9048)\r\n\r\n Marcel Böhme and Van-Thuan Pham discovered multiple buffer overreads\r\n in libxml2 when handling parameter-entity references. An attacker\r\n could use these to specially construct XML data that could cause a\r\n denial of service. (CVE-2017-9049, CVE-2017-9050)", + "cve": "rails/Gemfile.lock:nokogiri:gemnasium:06565b64-486d-4326-b906-890d9915804d", + "severity": "Unknown", + "solution": "Upgrade to latest version.", + "scanner": { + "id": "gemnasium", + "name": "Gemnasium" + }, + "location": { + "file": "rails/Gemfile.lock", + "dependency": { + "package": { + "name": "nokogiri" + }, + "version": "1.8.0" + } + }, + "identifiers": [ + { + "type": "gemnasium", + "name": "Gemnasium-06565b64-486d-4326-b906-890d9915804d", + "value": "06565b64-486d-4326-b906-890d9915804d", + "url": "https://deps.sec.gitlab.com/packages/gem/nokogiri/versions/1.8.0/advisories" + }, + { + "type": "usn", + "name": "USN-3424-1", + "value": "USN-3424-1", + "url": "https://usn.ubuntu.com/3424-1/" + } + ], + "links": [ + { + "url": "https://github.com/sparklemotion/nokogiri/issues/1673" + } + ] + }, + { + "category": "dependency_scanning", + "name": "Infinite recursion in parameter entities", + "message": "Infinite recursion in parameter entities in nokogiri", + "description": "libxml2 incorrectly handles certain parameter entities. An attacker can leverage this with specially constructed XML data to cause libxml2 to consume resources, leading to a denial of service.", + "cve": "rails/Gemfile.lock:nokogiri:gemnasium:6a0d56f6-2441-492a-9b14-edb95ac31919", + "severity": "Unknown", + "solution": "Upgrade to latest version.", + "scanner": { + "id": "gemnasium", + "name": "Gemnasium" + }, + "location": { + "file": "rails/Gemfile.lock", + "dependency": { + "package": { + "name": "nokogiri" + }, + "version": "1.8.0" + } + }, + "identifiers": [ + { + "type": "gemnasium", + "name": "Gemnasium-6a0d56f6-2441-492a-9b14-edb95ac31919", + "value": "6a0d56f6-2441-492a-9b14-edb95ac31919", + "url": "https://deps.sec.gitlab.com/packages/gem/nokogiri/versions/1.8.0/advisories" + }, + { + "type": "cve", + "name": "CVE-2017-16932", + "value": "CVE-2017-16932", + "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-16932" + } + ], + "links": [ + { + "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-16932" + }, + { + "url": "https://github.com/sparklemotion/nokogiri/issues/1714" + }, + { + "url": "https://people.canonical.com/~ubuntu-security/cve/2017/CVE-2017-16932.html" + }, + { + "url": "https://usn.ubuntu.com/usn/usn-3504-1/" + } + ] + }, + { + "category": "dependency_scanning", + "name": "Denial of Service", + "message": "Denial of Service in nokogiri", + "description": "libxml2 incorrectly handles certain files. An attacker can use this issue with specially constructed XML data to cause libxml2 to consume resources, leading to a denial of service.\r\n\r\n", + "cve": "rails/Gemfile.lock:nokogiri:gemnasium:78658378-bd8f-4d79-81c8-07c419302426", + "severity": "Unknown", + "solution": "Upgrade to latest version.", + "scanner": { + "id": "gemnasium", + "name": "Gemnasium" + }, + "location": { + "file": "rails/Gemfile.lock", + "dependency": { + "package": { + "name": "nokogiri" + }, + "version": "1.8.0" + } + }, + "identifiers": [ + { + "type": "gemnasium", + "name": "Gemnasium-78658378-bd8f-4d79-81c8-07c419302426", + "value": "78658378-bd8f-4d79-81c8-07c419302426", + "url": "https://deps.sec.gitlab.com/packages/gem/nokogiri/versions/1.8.0/advisories" + }, + { + "type": "cve", + "name": "CVE-2017-15412", + "value": "CVE-2017-15412", + "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-15412" + } + ], + "links": [ + { + "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-15412" + }, + { + "url": "https://github.com/sparklemotion/nokogiri/issues/1714" + }, + { + "url": "https://people.canonical.com/~ubuntu-security/cve/2017/CVE-2017-15412.html" + } + ] + }, + { + "category": "dependency_scanning", + "name": "Bypass of a protection mechanism in libxslt", + "message": "Bypass of a protection mechanism in libxslt in nokogiri", + "description": "libxslt through 1.1.33 allows bypass of a protection mechanism because callers of xsltCheckRead and xsltCheckWrite permit access even upon receiving a -1 error code. xsltCheckRead can return -1 for a crafted URL that is not actually invalid and is subsequently loaded. Vendored version of libxslt has been patched to remediate this vulnerability. Note that this patch is not yet (as of 2019-04-22) in an upstream release of libxslt.", + "cve": "rails/Gemfile.lock:nokogiri:gemnasium:1a2e2e6e-67ba-4142-bfa1-3391f5416e4c", + "severity": "Unknown", + "solution": "Upgrade to latest version if using vendored version of libxslt OR update the system library libxslt to a fixed version", + "scanner": { + "id": "gemnasium", + "name": "Gemnasium" + }, + "location": { + "file": "rails/Gemfile.lock", + "dependency": { + "package": { + "name": "nokogiri" + }, + "version": "1.8.0" + } + }, + "identifiers": [ + { + "type": "gemnasium", + "name": "Gemnasium-1a2e2e6e-67ba-4142-bfa1-3391f5416e4c", + "value": "1a2e2e6e-67ba-4142-bfa1-3391f5416e4c", + "url": "https://deps.sec.gitlab.com/packages/gem/nokogiri/versions/1.8.0/advisories" + }, + { + "type": "cve", + "name": "CVE-2019-11068", + "value": "CVE-2019-11068", + "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-11068" + } + ], + "links": [ + { + "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-11068" + }, + { + "url": "https://github.com/sparklemotion/nokogiri/issues/1892" + }, + { + "url": "https://people.canonical.com/~ubuntu-security/cve/CVE-2019-11068" + }, + { + "url": "https://security-tracker.debian.org/tracker/CVE-2019-11068" + } + ] + }, + { + "category": "dependency_scanning", + "name": "Regular Expression Denial of Service", + "message": "Regular Expression Denial of Service in debug", + "description": "The debug module is vulnerable to regular expression denial of service when untrusted user input is passed into the `o` formatter. It takes around 50k characters to block for 2 seconds making this a low severity issue.", + "cve": "yarn/yarn.lock:debug:gemnasium:37283ed4-0380-40d7-ada7-2d994afcc62a", + "severity": "Unknown", + "solution": "Upgrade to latest versions.", + "scanner": { + "id": "gemnasium", + "name": "Gemnasium" + }, + "location": { + "file": "yarn/yarn.lock", + "dependency": { + "package": { + "name": "debug" + }, + "version": "1.0.5" + } + }, + "identifiers": [ + { + "type": "gemnasium", + "name": "Gemnasium-37283ed4-0380-40d7-ada7-2d994afcc62a", + "value": "37283ed4-0380-40d7-ada7-2d994afcc62a", + "url": "https://deps.sec.gitlab.com/packages/npm/debug/versions/1.0.5/advisories" + } + ], + "links": [ + { + "url": "https://github.com/visionmedia/debug/issues/501" + }, + { + "url": "https://github.com/visionmedia/debug/pull/504" + }, + { + "url": "https://nodesecurity.io/advisories/534" + } + ] + }, + { + "category": "dependency_scanning", + "name": "Authentication bypass via incorrect DOM traversal and canonicalization", + "message": "Authentication bypass via incorrect DOM traversal and canonicalization in saml2-js", + "description": "Some XML DOM traversal and canonicalization APIs may be inconsistent in handling of comments within XML nodes. Incorrect use of these APIs by some SAML libraries results in incorrect parsing of the inner text of XML nodes such that any inner text after the comment is lost prior to cryptographically signing the SAML message. Text after the comment therefore has no impact on the signature on the SAML message.\r\n\r\nA remote attacker can modify SAML content for a SAML service provider without invalidating the cryptographic signature, which may allow attackers to bypass primary authentication for the affected SAML service provider.", + "cve": "yarn/yarn.lock:saml2-js:gemnasium:9952e574-7b5b-46fa-a270-aeb694198a98", + "severity": "Unknown", + "solution": "Upgrade to fixed version.\r\n", + "scanner": { + "id": "gemnasium", + "name": "Gemnasium" + }, + "location": { + "file": "yarn/yarn.lock", + "dependency": { + "package": { + "name": "saml2-js" + }, + "version": "1.5.0" + } + }, + "identifiers": [ + { + "type": "gemnasium", + "name": "Gemnasium-9952e574-7b5b-46fa-a270-aeb694198a98", + "value": "9952e574-7b5b-46fa-a270-aeb694198a98", + "url": "https://deps.sec.gitlab.com/packages/npm/saml2-js/versions/1.5.0/advisories" + }, + { + "type": "cve", + "name": "CVE-2017-11429", + "value": "CVE-2017-11429", + "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-11429" + } + ], + "links": [ + { + "url": "https://github.com/Clever/saml2/commit/3546cb61fd541f219abda364c5b919633609ef3d#diff-af730f9f738de1c9ad87596df3f6de84R279" + }, + { + "url": "https://github.com/Clever/saml2/issues/127" + }, + { + "url": "https://www.kb.cert.org/vuls/id/475445" + } + ] + } + ], + "remediations": [], + "dependency_files": [ + { + "path": "rails/Gemfile.lock", + "package_manager": "bundler", + "dependencies": [ + { + "package": { + "name": "mini_portile2" + }, + "version": "2.2.0" + }, + { + "package": { + "name": "nokogiri" + }, + "version": "1.8.0" + } + ] + }, + { + "path": "yarn/yarn.lock", + "package_manager": "yarn", + "dependencies": [ + { + "package": { + "name": "async" + }, + "version": "0.2.10" + }, + { + "package": { + "name": "async" + }, + "version": "1.5.2" + }, + { + "package": { + "name": "debug" + }, + "version": "1.0.5" + }, + { + "package": { + "name": "ejs" + }, + "version": "0.8.8" + }, + { + "package": { + "name": "ms" + }, + "version": "2.0.0" + }, + { + "package": { + "name": "node-forge" + }, + "version": "0.2.24" + }, + { + "package": { + "name": "saml2-js" + }, + "version": "1.5.0" + }, + { + "package": { + "name": "sax" + }, + "version": "1.2.4" + }, + { + "package": { + "name": "underscore" + }, + "version": "1.9.1" + }, + { + "package": { + "name": "underscore" + }, + "version": "1.6.0" + }, + { + "package": { + "name": "xml-crypto" + }, + "version": "0.8.5" + }, + { + "package": { + "name": "xml-encryption" + }, + "version": "0.7.4" + }, + { + "package": { + "name": "xml2js" + }, + "version": "0.4.19" + }, + { + "package": { + "name": "xmlbuilder" + }, + "version": "2.1.0" + }, + { + "package": { + "name": "xmlbuilder" + }, + "version": "9.0.7" + }, + { + "package": { + "name": "xmldom" + }, + "version": "0.1.19" + }, + { + "package": { + "name": "xmldom" + }, + "version": "0.1.27" + }, + { + "package": { + "name": "xpath.js" + }, + "version": "1.1.0" + }, + { + "package": { + "name": "xpath" + }, + "version": "0.0.5" + } + ] + } + ] +} diff --git a/spec/fixtures/security-reports/master/gl-dast-report.json b/spec/fixtures/security-reports/master/gl-dast-report.json index 3a308bf047e..df459d9419d 100644 --- a/spec/fixtures/security-reports/master/gl-dast-report.json +++ b/spec/fixtures/security-reports/master/gl-dast-report.json @@ -1,40 +1,42 @@ { - "site": { - "alerts": [ - { - "sourceid": "3", - "wascid": "15", - "cweid": "16", - "reference": "<p>http://msdn.microsoft.com/en-us/library/ie/gg622941%28v=vs.85%29.aspx</p><p>https://www.owasp.org/index.php/List_of_useful_HTTP_headers</p>", - "otherinfo": "<p>This issue still applies to error type pages (401, 403, 500, etc) as those pages are often still affected by injection issues, in which case there is still concern for browsers sniffing pages away from their actual content type.</p><p>At \"High\" threshold this scanner will not alert on client or server error responses.</p>", - "solution": "<p>Ensure that the application/web server sets the Content-Type header appropriately, and that it sets the X-Content-Type-Options header to 'nosniff' for all web pages.</p><p>If possible, ensure that the end user uses a standards-compliant and modern web browser that does not perform MIME-sniffing at all, or that can be directed by the web application/web server to not perform MIME-sniffing.</p>", - "count": "2", - "pluginid": "10021", - "alert": "X-Content-Type-Options Header Missing", - "name": "X-Content-Type-Options Header Missing", - "riskcode": "1", - "confidence": "2", - "riskdesc": "Low (Medium)", - "desc": "<p>The Anti-MIME-Sniffing header X-Content-Type-Options was not set to 'nosniff'. This allows older versions of Internet Explorer and Chrome to perform MIME-sniffing on the response body, potentially causing the response body to be interpreted and displayed as a content type other than the declared content type. Current (early 2014) and legacy versions of Firefox will use the declared content type (if one is set), rather than performing MIME-sniffing.</p>", - "instances": [ - { - "param": "X-Content-Type-Options", - "method": "GET", - "uri": "http://bikebilly-spring-auto-devops-review-feature-br-3y2gpb.35.192.176.43.xip.io" - }, - { - "param": "X-Content-Type-Options", - "method": "GET", - "uri": "http://bikebilly-spring-auto-devops-review-feature-br-3y2gpb.35.192.176.43.xip.io/" - } - ] - } - ], - "@ssl": "false", - "@port": "80", - "@host": "bikebilly-spring-auto-devops-review-feature-br-3y2gpb.35.192.176.43.xip.io", - "@name": "http://bikebilly-spring-auto-devops-review-feature-br-3y2gpb.35.192.176.43.xip.io" - }, + "site": [ + { + "alerts": [ + { + "sourceid": "3", + "wascid": "15", + "cweid": "16", + "reference": "<p>http://msdn.microsoft.com/en-us/library/ie/gg622941%28v=vs.85%29.aspx</p><p>https://www.owasp.org/index.php/List_of_useful_HTTP_headers</p>", + "otherinfo": "<p>This issue still applies to error type pages (401, 403, 500, etc) as those pages are often still affected by injection issues, in which case there is still concern for browsers sniffing pages away from their actual content type.</p><p>At \"High\" threshold this scanner will not alert on client or server error responses.</p>", + "solution": "<p>Ensure that the application/web server sets the Content-Type header appropriately, and that it sets the X-Content-Type-Options header to 'nosniff' for all web pages.</p><p>If possible, ensure that the end user uses a standards-compliant and modern web browser that does not perform MIME-sniffing at all, or that can be directed by the web application/web server to not perform MIME-sniffing.</p>", + "count": "2", + "pluginid": "10021", + "alert": "X-Content-Type-Options Header Missing", + "name": "X-Content-Type-Options Header Missing", + "riskcode": "1", + "confidence": "2", + "riskdesc": "Low (Medium)", + "desc": "<p>The Anti-MIME-Sniffing header X-Content-Type-Options was not set to 'nosniff'. This allows older versions of Internet Explorer and Chrome to perform MIME-sniffing on the response body, potentially causing the response body to be interpreted and displayed as a content type other than the declared content type. Current (early 2014) and legacy versions of Firefox will use the declared content type (if one is set), rather than performing MIME-sniffing.</p>", + "instances": [ + { + "param": "X-Content-Type-Options", + "method": "GET", + "uri": "http://bikebilly-spring-auto-devops-review-feature-br-3y2gpb.35.192.176.43.xip.io" + }, + { + "param": "X-Content-Type-Options", + "method": "GET", + "uri": "http://bikebilly-spring-auto-devops-review-feature-br-3y2gpb.35.192.176.43.xip.io/" + } + ] + } + ], + "@ssl": "false", + "@port": "80", + "@host": "bikebilly-spring-auto-devops-review-feature-br-3y2gpb.35.192.176.43.xip.io", + "@name": "http://bikebilly-spring-auto-devops-review-feature-br-3y2gpb.35.192.176.43.xip.io" + } + ], "@generated": "Fri, 13 Apr 2018 09:22:01", "@version": "2.7.0" } diff --git a/spec/javascripts/activities_spec.js b/spec/frontend/activities_spec.js index 23b6de7e4e0..d14be3a1f26 100644 --- a/spec/javascripts/activities_spec.js +++ b/spec/frontend/activities_spec.js @@ -1,7 +1,6 @@ /* eslint-disable no-unused-expressions, no-prototype-builtins, no-new, no-shadow */ import $ from 'jquery'; -import 'vendor/jquery.endless-scroll'; import Activities from '~/activities'; import Pager from '~/pager'; @@ -40,7 +39,7 @@ describe('Activities', () => { beforeEach(() => { loadFixtures(fixtureTemplate); - spyOn(Pager, 'init').and.stub(); + jest.spyOn(Pager, 'init').mockImplementation(() => {}); new Activities(); }); diff --git a/spec/javascripts/api_spec.js b/spec/frontend/api_spec.js index 805bb10bda6..6010488d9e0 100644 --- a/spec/javascripts/api_spec.js +++ b/spec/frontend/api_spec.js @@ -264,7 +264,7 @@ describe('Api', () => { const namespace = 'some namespace'; const project = 'some project'; const labelData = { some: 'data' }; - const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/labels`; + const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/-/labels`; const expectedData = { label: labelData, }; @@ -459,7 +459,7 @@ describe('Api', () => { dummyProjectPath, )}/repository/branches`; - spyOn(axios, 'post').and.callThrough(); + jest.spyOn(axios, 'post'); mock.onPost(expectedUrl).replyOnce(200, { name: branch, diff --git a/spec/javascripts/autosave_spec.js b/spec/frontend/autosave_spec.js index dcb1c781591..4d9c8f96d62 100644 --- a/spec/javascripts/autosave_spec.js +++ b/spec/frontend/autosave_spec.js @@ -1,16 +1,19 @@ import $ from 'jquery'; import Autosave from '~/autosave'; import AccessorUtilities from '~/lib/utils/accessor'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; describe('Autosave', () => { + useLocalStorageSpy(); + let autosave; const field = $('<textarea></textarea>'); const key = 'key'; describe('class constructor', () => { beforeEach(() => { - spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true); - spyOn(Autosave.prototype, 'restore'); + jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(true); + jest.spyOn(Autosave.prototype, 'restore').mockImplementation(() => {}); }); it('should set .isLocalStorageAvailable', () => { @@ -27,8 +30,6 @@ describe('Autosave', () => { field, key, }; - - spyOn(window.localStorage, 'getItem'); }); describe('if .isLocalStorageAvailable is `false`', () => { @@ -55,7 +56,7 @@ describe('Autosave', () => { }); it('triggers jquery event', () => { - spyOn(autosave.field, 'trigger').and.callThrough(); + jest.spyOn(autosave.field, 'trigger').mockImplementation(() => {}); Autosave.prototype.restore.call(autosave); @@ -77,7 +78,7 @@ describe('Autosave', () => { }); it('does not trigger event', () => { - spyOn(field, 'trigger').and.callThrough(); + jest.spyOn(field, 'trigger'); expect(field.trigger).not.toHaveBeenCalled(); }); @@ -86,11 +87,9 @@ describe('Autosave', () => { describe('save', () => { beforeEach(() => { - autosave = jasmine.createSpyObj('autosave', ['reset']); + autosave = { reset: jest.fn() }; autosave.field = field; field.val('value'); - - spyOn(window.localStorage, 'setItem'); }); describe('if .isLocalStorageAvailable is `false`', () => { @@ -123,8 +122,6 @@ describe('Autosave', () => { autosave = { key, }; - - spyOn(window.localStorage, 'removeItem'); }); describe('if .isLocalStorageAvailable is `false`', () => { diff --git a/spec/frontend/clusters/components/applications_spec.js b/spec/frontend/clusters/components/applications_spec.js index 8bcf02f0a34..221ebb143be 100644 --- a/spec/frontend/clusters/components/applications_spec.js +++ b/spec/frontend/clusters/components/applications_spec.js @@ -1,9 +1,11 @@ import Vue from 'vue'; import applications from '~/clusters/components/applications.vue'; import { CLUSTER_TYPE } from '~/clusters/constants'; -import eventHub from '~/clusters/event_hub'; import mountComponent from 'helpers/vue_mount_component_helper'; import { APPLICATIONS_MOCK_STATE } from '../services/mock_data'; +import eventHub from '~/clusters/event_hub'; +import { shallowMount } from '@vue/test-utils'; +import KnativeDomainEditor from '~/clusters/components/knative_domain_editor.vue'; describe('Applications', () => { let vm; @@ -277,73 +279,48 @@ describe('Applications', () => { }); describe('Knative application', () => { - describe('when installed', () => { - describe('with ip address', () => { - const props = { - applications: { - ...APPLICATIONS_MOCK_STATE, - knative: { - title: 'Knative', - hostname: 'example.com', - status: 'installed', - externalIp: '1.1.1.1', - }, - }, - }; - it('renders ip address with a clipboard button', () => { - vm = mountComponent(Applications, props); + const propsData = { + applications: { + ...APPLICATIONS_MOCK_STATE, + knative: { + title: 'Knative', + hostname: 'example.com', + status: 'installed', + externalIp: '1.1.1.1', + installed: true, + }, + }, + }; + const newHostname = 'newhostname.com'; + let wrapper; + let knativeDomainEditor; - expect(vm.$el.querySelector('.js-knative-endpoint').value).toEqual('1.1.1.1'); - - expect( - vm.$el - .querySelector('.js-knative-endpoint-clipboard-btn') - .getAttribute('data-clipboard-text'), - ).toEqual('1.1.1.1'); - }); - - it('renders domain & allows editing', () => { - expect(vm.$el.querySelector('.js-knative-domainname').value).toEqual('example.com'); - expect(vm.$el.querySelector('.js-knative-domainname').getAttribute('readonly')).toBe( - null, - ); - }); - - it('renders an update/save Knative domain button', () => { - expect(vm.$el.querySelector('.js-knative-save-domain-button')).not.toBe(null); - }); + beforeEach(() => { + wrapper = shallowMount(Applications, { propsData }); + jest.spyOn(eventHub, '$emit'); - it('emits event when clicking Save changes button', () => { - jest.spyOn(eventHub, '$emit'); - vm = mountComponent(Applications, props); + knativeDomainEditor = wrapper.find(KnativeDomainEditor); + }); - const saveButton = vm.$el.querySelector('.js-knative-save-domain-button'); + afterEach(() => { + wrapper.destroy(); + }); - saveButton.click(); + it('emits saveKnativeDomain event when knative domain editor emits save event', () => { + knativeDomainEditor.vm.$emit('save', newHostname); - expect(eventHub.$emit).toHaveBeenCalledWith('saveKnativeDomain', { - id: 'knative', - params: { hostname: 'example.com' }, - }); - }); + expect(eventHub.$emit).toHaveBeenCalledWith('saveKnativeDomain', { + id: 'knative', + params: { hostname: newHostname }, }); + }); - describe('without ip address', () => { - it('renders an input text with a loading icon and an alert text', () => { - vm = mountComponent(Applications, { - applications: { - ...APPLICATIONS_MOCK_STATE, - knative: { - title: 'Knative', - hostname: 'example.com', - status: 'installed', - }, - }, - }); + it('emits setKnativeHostname event when knative domain editor emits change event', () => { + wrapper.find(KnativeDomainEditor).vm.$emit('set', newHostname); - expect(vm.$el.querySelector('.js-knative-ip-loading-icon')).not.toBe(null); - expect(vm.$el.querySelector('.js-no-knative-endpoint-message')).not.toBe(null); - }); + expect(eventHub.$emit).toHaveBeenCalledWith('setKnativeHostname', { + id: 'knative', + hostname: newHostname, }); }); }); diff --git a/spec/frontend/clusters/components/knative_domain_editor_spec.js b/spec/frontend/clusters/components/knative_domain_editor_spec.js new file mode 100644 index 00000000000..242b5701f8b --- /dev/null +++ b/spec/frontend/clusters/components/knative_domain_editor_spec.js @@ -0,0 +1,141 @@ +import { shallowMount } from '@vue/test-utils'; +import KnativeDomainEditor from '~/clusters/components/knative_domain_editor.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import { APPLICATION_STATUS } from '~/clusters/constants'; + +const { UPDATING } = APPLICATION_STATUS; + +describe('KnativeDomainEditor', () => { + let wrapper; + let knative; + + const createComponent = (props = {}) => { + wrapper = shallowMount(KnativeDomainEditor, { + propsData: { ...props }, + }); + }; + + beforeEach(() => { + knative = { + title: 'Knative', + hostname: 'example.com', + installed: true, + }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('knative has an assigned IP address', () => { + beforeEach(() => { + knative.externalIp = '1.1.1.1'; + createComponent({ knative }); + }); + + it('renders ip address with a clipboard button', () => { + expect(wrapper.find('.js-knative-endpoint').exists()).toBe(true); + expect(wrapper.find('.js-knative-endpoint').element.value).toEqual(knative.externalIp); + }); + + it('displays ip address clipboard button', () => { + expect(wrapper.find('.js-knative-endpoint-clipboard-btn').attributes('text')).toEqual( + knative.externalIp, + ); + }); + + it('renders domain & allows editing', () => { + const domainNameInput = wrapper.find('.js-knative-domainname'); + + expect(domainNameInput.element.value).toEqual(knative.hostname); + expect(domainNameInput.attributes('readonly')).toBeFalsy(); + }); + + it('renders an update/save Knative domain button', () => { + expect(wrapper.find('.js-knative-save-domain-button').exists()).toBe(true); + }); + }); + + describe('knative without ip address', () => { + beforeEach(() => { + knative.externalIp = null; + createComponent({ knative }); + }); + + it('renders an input text with a loading icon', () => { + expect(wrapper.find('.js-knative-ip-loading-icon').exists()).toBe(true); + }); + + it('renders message indicating there is not IP address assigned', () => { + expect(wrapper.find('.js-no-knative-endpoint-message').exists()).toBe(true); + }); + }); + + describe('clicking save changes button', () => { + beforeEach(() => { + createComponent({ knative }); + }); + + it('triggers save event and pass current knative hostname', () => { + wrapper.find(LoadingButton).vm.$emit('click'); + expect(wrapper.emitted('save')[0]).toEqual([knative.hostname]); + }); + }); + + describe('when knative domain name was saved successfully', () => { + beforeEach(() => { + createComponent({ knative }); + }); + + it('displays toast indicating a successful update', () => { + wrapper.vm.$toast = { show: jest.fn() }; + wrapper.setProps({ knative: Object.assign({ updateSuccessful: true }, knative) }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith( + 'Knative domain name was updated successfully.', + ); + }); + }); + }); + + describe('when knative domain name input changes', () => { + it('emits "set" event with updated domain name', () => { + const newHostname = 'newhostname.com'; + + wrapper.setData({ knativeHostname: newHostname }); + + expect(wrapper.emitted('set')[0]).toEqual([newHostname]); + }); + }); + + describe('when updating knative domain name failed', () => { + beforeEach(() => { + createComponent({ knative }); + }); + + it('displays an error banner indicating the operation failure', () => { + wrapper.setProps({ knative: { updateFailed: true, ...knative } }); + + expect(wrapper.find('.js-cluster-knative-domain-name-failure-message').exists()).toBe(true); + }); + }); + + describe(`when knative status is ${UPDATING}`, () => { + beforeEach(() => { + createComponent({ knative: { status: UPDATING, ...knative } }); + }); + + it('renders loading spinner in save button', () => { + expect(wrapper.find(LoadingButton).props('loading')).toBe(true); + }); + + it('renders disabled save button', () => { + expect(wrapper.find(LoadingButton).props('disabled')).toBe(true); + }); + + it('renders save button with "Saving" label', () => { + expect(wrapper.find(LoadingButton).props('label')).toBe('Saving'); + }); + }); +}); diff --git a/spec/frontend/clusters/stores/clusters_store_spec.js b/spec/frontend/clusters/stores/clusters_store_spec.js index aa926bb36d7..0d129349799 100644 --- a/spec/frontend/clusters/stores/clusters_store_spec.js +++ b/spec/frontend/clusters/stores/clusters_store_spec.js @@ -133,6 +133,8 @@ describe('Clusters Store', () => { uninstallable: false, uninstallSuccessful: false, uninstallFailed: false, + updateSuccessful: false, + updateFailed: false, }, cert_manager: { title: 'Cert-Manager', diff --git a/spec/frontend/helpers/jquery.js b/spec/frontend/helpers/jquery.js new file mode 100644 index 00000000000..6421a592c0c --- /dev/null +++ b/spec/frontend/helpers/jquery.js @@ -0,0 +1,6 @@ +import $ from 'jquery'; + +global.$ = $; +global.jQuery = $; + +export default $; diff --git a/spec/frontend/helpers/local_storage_helper.js b/spec/frontend/helpers/local_storage_helper.js new file mode 100644 index 00000000000..48e66b11767 --- /dev/null +++ b/spec/frontend/helpers/local_storage_helper.js @@ -0,0 +1,41 @@ +/** + * Manage the instance of a custom `window.localStorage` + * + * This only encapsulates the setup / teardown logic so that it can easily be + * reused with different implementations (i.e. a spy or a [fake][1]) + * + * [1]: https://stackoverflow.com/a/41434763/1708147 + * + * @param {() => any} fn Function that returns the object to use for localStorage + */ +const useLocalStorage = fn => { + const origLocalStorage = window.localStorage; + let currentLocalStorage; + + Object.defineProperty(window, 'localStorage', { + get: () => currentLocalStorage, + }); + + beforeEach(() => { + currentLocalStorage = fn(); + }); + + afterEach(() => { + currentLocalStorage = origLocalStorage; + }); +}; + +/** + * Create an object with the localStorage interface but `jest.fn()` implementations. + */ +export const createLocalStorageSpy = () => ({ + clear: jest.fn(), + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), +}); + +/** + * Before each test, overwrite `window.localStorage` with a spy implementation. + */ +export const useLocalStorageSpy = () => useLocalStorage(createLocalStorageSpy); diff --git a/spec/frontend/helpers/vue_test_utils_helper.js b/spec/frontend/helpers/vue_test_utils_helper.js index 19e27388eeb..121e99c9783 100644 --- a/spec/frontend/helpers/vue_test_utils_helper.js +++ b/spec/frontend/helpers/vue_test_utils_helper.js @@ -16,4 +16,6 @@ const vNodeContainsText = (vnode, text) => * @param {String} text */ export const shallowWrapperContainsSlotText = (shallowWrapper, slotName, text) => - !!shallowWrapper.vm.$slots[slotName].filter(vnode => vNodeContainsText(vnode, text)).length; + Boolean( + shallowWrapper.vm.$slots[slotName].filter(vnode => vNodeContainsText(vnode, text)).length, + ); diff --git a/spec/javascripts/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index 381c7b2d0a6..eca240c9c18 100644 --- a/spec/javascripts/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -107,4 +107,88 @@ describe('URL utility', () => { expect(url).toBe('/home/feature#install'); }); }); + + describe('getBaseURL', () => { + beforeEach(() => { + global.window = Object.create(window); + Object.defineProperty(window, 'location', { + value: { + host: 'gitlab.com', + protocol: 'https:', + }, + }); + }); + + it('returns correct base URL', () => { + expect(urlUtils.getBaseURL()).toBe('https://gitlab.com'); + }); + }); + + describe('isAbsoluteOrRootRelative', () => { + const validUrls = ['https://gitlab.com/', 'http://gitlab.com/', '/users/sign_in']; + + const invalidUrls = [' https://gitlab.com/', './file/path', 'notanurl', '<a></a>']; + + it.each(validUrls)(`returns true for %s`, url => { + expect(urlUtils.isAbsoluteOrRootRelative(url)).toBe(true); + }); + + it.each(invalidUrls)(`returns false for %s`, url => { + expect(urlUtils.isAbsoluteOrRootRelative(url)).toBe(false); + }); + }); + + describe('isSafeUrl', () => { + const absoluteUrls = [ + 'http://example.org', + 'http://example.org:8080', + 'https://example.org', + 'https://example.org:8080', + 'https://192.168.1.1', + ]; + + const rootRelativeUrls = ['/relative/link']; + + const relativeUrls = ['./relative/link', '../relative/link']; + + const urlsWithoutHost = ['http://', 'https://', 'https:https:https:']; + + /* eslint-disable no-script-url */ + const nonHttpUrls = [ + 'javascript:', + 'javascript:alert("XSS")', + 'jav\tascript:alert("XSS");', + '  javascript:alert("XSS");', + 'ftp://192.168.1.1', + 'file:///', + 'file:///etc/hosts', + ]; + /* eslint-enable no-script-url */ + + // javascript:alert('XSS') + const encodedJavaScriptUrls = [ + 'javascript:alert('XSS')', + 'javascript:alert('XSS')', + 'javascript:alert('XSS')', + '\\u006A\\u0061\\u0076\\u0061\\u0073\\u0063\\u0072\\u0069\\u0070\\u0074\\u003A\\u0061\\u006C\\u0065\\u0072\\u0074\\u0028\\u0027\\u0058\\u0053\\u0053\\u0027\\u0029', + ]; + + const safeUrls = [...absoluteUrls, ...rootRelativeUrls]; + const unsafeUrls = [ + ...relativeUrls, + ...urlsWithoutHost, + ...nonHttpUrls, + ...encodedJavaScriptUrls, + ]; + + describe('with URL constructor support', () => { + it.each(safeUrls)('returns true for %s', url => { + expect(urlUtils.isSafeURL(url)).toBe(true); + }); + + it.each(unsafeUrls)('returns false for %s', url => { + expect(urlUtils.isSafeURL(url)).toBe(false); + }); + }); + }); }); diff --git a/spec/javascripts/notes/components/note_app_spec.js b/spec/frontend/notes/components/note_app_spec.js index ef876dc2941..ff833d2c899 100644 --- a/spec/javascripts/notes/components/note_app_spec.js +++ b/spec/frontend/notes/components/note_app_spec.js @@ -1,18 +1,47 @@ -import $ from 'jquery'; -import _ from 'underscore'; +import $ from 'helpers/jquery'; import Vue from 'vue'; import { mount, createLocalVue } from '@vue/test-utils'; import NotesApp from '~/notes/components/notes_app.vue'; import service from '~/notes/services/notes_service'; import createStore from '~/notes/stores'; import '~/behaviors/markdown/render_gfm'; -import * as mockData from '../mock_data'; +import { setTestTimeout } from 'helpers/timeout'; +// TODO: use generated fixture (https://gitlab.com/gitlab-org/gitlab-ce/issues/62491) +import * as mockData from '../../../javascripts/notes/mock_data'; + +const originalInterceptors = [...Vue.http.interceptors]; + +const emptyResponseInterceptor = (request, next) => { + next( + request.respondWith(JSON.stringify([]), { + status: 200, + }), + ); +}; + +setTestTimeout(1000); describe('note_app', () => { let mountComponent; let wrapper; let store; + /** + * waits for fetchNotes() to complete + */ + const waitForDiscussionsRequest = () => + new Promise(resolve => { + const { vm } = wrapper.find(NotesApp); + const unwatch = vm.$watch('isFetching', isFetching => { + if (isFetching) { + return; + } + + unwatch(); + resolve(); + }); + }); + beforeEach(() => { $('body').attr('data-page', 'projects:merge_requests:show'); @@ -33,6 +62,7 @@ describe('note_app', () => { template: '<div class="js-vue-notes-event"><notes-app v-bind="$attrs" /></div>', }, { + attachToDocument: true, propsData, store, localVue, @@ -44,24 +74,14 @@ describe('note_app', () => { afterEach(() => { wrapper.destroy(); + Vue.http.interceptors = [...originalInterceptors]; }); describe('set data', () => { - const responseInterceptor = (request, next) => { - next( - request.respondWith(JSON.stringify([]), { - status: 200, - }), - ); - }; - beforeEach(() => { - Vue.http.interceptors.push(responseInterceptor); + Vue.http.interceptors.push(emptyResponseInterceptor); wrapper = mountComponent(); - }); - - afterEach(() => { - Vue.http.interceptors = _.without(Vue.http.interceptors, responseInterceptor); + return waitForDiscussionsRequest(); }); it('should set notes data', () => { @@ -87,29 +107,23 @@ describe('note_app', () => { Vue.http.interceptors.push(mockData.individualNoteInterceptor); wrapper = mountComponent(); + return waitForDiscussionsRequest(); }); - afterEach(() => { - Vue.http.interceptors = _.without(Vue.http.interceptors, mockData.individualNoteInterceptor); - }); - - it('should render list of notes', done => { + it('should render list of notes', () => { const note = mockData.INDIVIDUAL_NOTE_RESPONSE_MAP.GET[ '/gitlab-org/gitlab-ce/issues/26/discussions.json' ][0].notes[0]; - setTimeout(() => { - expect( - wrapper - .find('.main-notes-list .note-header-author-name') - .text() - .trim(), - ).toEqual(note.author.name); + expect( + wrapper + .find('.main-notes-list .note-header-author-name') + .text() + .trim(), + ).toEqual(note.author.name); - expect(wrapper.find('.main-notes-list .note-text').html()).toContain(note.note_html); - done(); - }, 0); + expect(wrapper.find('.main-notes-list .note-text').html()).toContain(note.note_html); }); it('should render form', () => { @@ -120,37 +134,42 @@ describe('note_app', () => { }); it('should not render form when commenting is disabled', () => { + wrapper.destroy(); + store.state.commentsDisabled = true; wrapper = mountComponent(); - - expect(wrapper.find('.js-main-target-form').exists()).toBe(false); + return waitForDiscussionsRequest().then(() => { + expect(wrapper.find('.js-main-target-form').exists()).toBe(false); + }); }); it('should render discussion filter note `commentsDisabled` is true', () => { + wrapper.destroy(); + store.state.commentsDisabled = true; wrapper = mountComponent(); - - expect(wrapper.find('.js-discussion-filter-note').exists()).toBe(true); + return waitForDiscussionsRequest().then(() => { + expect(wrapper.find('.js-discussion-filter-note').exists()).toBe(true); + }); }); it('should render form comment button as disabled', () => { expect(wrapper.find('.js-note-new-discussion').attributes('disabled')).toEqual('disabled'); }); - it('updates discussions badge', done => { - setTimeout(() => { - expect(document.querySelector('.js-discussions-count').textContent).toEqual('2'); - - done(); - }); + it('updates discussions badge', () => { + expect(document.querySelector('.js-discussions-count').textContent).toEqual('2'); }); }); describe('while fetching data', () => { beforeEach(() => { + Vue.http.interceptors.push(emptyResponseInterceptor); wrapper = mountComponent(); }); + afterEach(() => waitForDiscussionsRequest()); + it('renders skeleton notes', () => { expect(wrapper.find('.animation-container').exists()).toBe(true); }); @@ -165,78 +184,55 @@ describe('note_app', () => { describe('update note', () => { describe('individual note', () => { - beforeEach(done => { + beforeEach(() => { Vue.http.interceptors.push(mockData.individualNoteInterceptor); - spyOn(service, 'updateNote').and.callThrough(); + jest.spyOn(service, 'updateNote'); wrapper = mountComponent(); - setTimeout(() => { + return waitForDiscussionsRequest().then(() => { wrapper.find('.js-note-edit').trigger('click'); - Vue.nextTick(done); - }, 0); - }); - - afterEach(() => { - Vue.http.interceptors = _.without( - Vue.http.interceptors, - mockData.individualNoteInterceptor, - ); + }); }); it('renders edit form', () => { expect(wrapper.find('.js-vue-issue-note-form').exists()).toBe(true); }); - it('calls the service to update the note', done => { + it('calls the service to update the note', () => { wrapper.find('.js-vue-issue-note-form').value = 'this is a note'; wrapper.find('.js-vue-issue-save').trigger('click'); expect(service.updateNote).toHaveBeenCalled(); - // Wait for the requests to finish before destroying - setTimeout(() => { - done(); - }); }); }); describe('discussion note', () => { - beforeEach(done => { + beforeEach(() => { Vue.http.interceptors.push(mockData.discussionNoteInterceptor); - spyOn(service, 'updateNote').and.callThrough(); + jest.spyOn(service, 'updateNote'); wrapper = mountComponent(); - - setTimeout(() => { + return waitForDiscussionsRequest().then(() => { wrapper.find('.js-note-edit').trigger('click'); - Vue.nextTick(done); - }, 0); - }); - - afterEach(() => { - Vue.http.interceptors = _.without( - Vue.http.interceptors, - mockData.discussionNoteInterceptor, - ); + }); }); it('renders edit form', () => { expect(wrapper.find('.js-vue-issue-note-form').exists()).toBe(true); }); - it('updates the note and resets the edit form', done => { + it('updates the note and resets the edit form', () => { wrapper.find('.js-vue-issue-note-form').value = 'this is a note'; wrapper.find('.js-vue-issue-save').trigger('click'); expect(service.updateNote).toHaveBeenCalled(); - // Wait for the requests to finish before destroying - setTimeout(() => { - done(); - }); }); }); }); describe('new note form', () => { beforeEach(() => { + Vue.http.interceptors.push(mockData.individualNoteInterceptor); wrapper = mountComponent(); + return waitForDiscussionsRequest(); }); it('should render markdown docs url', () => { @@ -266,43 +262,37 @@ describe('note_app', () => { beforeEach(() => { Vue.http.interceptors.push(mockData.individualNoteInterceptor); wrapper = mountComponent(); + return waitForDiscussionsRequest(); }); - afterEach(() => { - Vue.http.interceptors = _.without(Vue.http.interceptors, mockData.individualNoteInterceptor); - }); + it('should render markdown docs url', () => { + wrapper.find('.js-note-edit').trigger('click'); + const { markdownDocsPath } = mockData.notesDataMock; - it('should render markdown docs url', done => { - setTimeout(() => { - wrapper.find('.js-note-edit').trigger('click'); - const { markdownDocsPath } = mockData.notesDataMock; - - Vue.nextTick(() => { - expect( - wrapper - .find(`.edit-note a[href="${markdownDocsPath}"]`) - .text() - .trim(), - ).toEqual('Markdown is supported'); - done(); - }); - }, 0); + return Vue.nextTick().then(() => { + expect( + wrapper + .find(`.edit-note a[href="${markdownDocsPath}"]`) + .text() + .trim(), + ).toEqual('Markdown is supported'); + }); }); - it('should not render quick actions docs url', done => { - setTimeout(() => { - wrapper.find('.js-note-edit').trigger('click'); - const { quickActionsDocsPath } = mockData.notesDataMock; - - Vue.nextTick(() => { - expect(wrapper.find(`.edit-note a[href="${quickActionsDocsPath}"]`).exists()).toBe(false); - done(); - }); - }, 0); + it('should not render quick actions docs url', () => { + wrapper.find('.js-note-edit').trigger('click'); + const { quickActionsDocsPath } = mockData.notesDataMock; + expect(wrapper.find(`.edit-note a[href="${quickActionsDocsPath}"]`).exists()).toBe(false); }); }); describe('emoji awards', () => { + beforeEach(() => { + Vue.http.interceptors.push(emptyResponseInterceptor); + wrapper = mountComponent(); + return waitForDiscussionsRequest(); + }); + it('dispatches toggleAward after toggleAward event', () => { const toggleAwardEvent = new CustomEvent('toggleAward', { detail: { @@ -310,17 +300,18 @@ describe('note_app', () => { noteId: 1, }, }); - const toggleAwardAction = jasmine.createSpy('toggleAward'); + const toggleAwardAction = jest.fn().mockName('toggleAward'); wrapper.vm.$store.hotUpdate({ actions: { toggleAward: toggleAwardAction, + stopPolling() {}, }, }); wrapper.vm.$parent.$el.dispatchEvent(toggleAwardEvent); expect(toggleAwardAction).toHaveBeenCalledTimes(1); - const [, payload] = toggleAwardAction.calls.argsFor(0); + const [, payload] = toggleAwardAction.mock.calls[0]; expect(payload).toEqual({ awardName: 'test', diff --git a/spec/frontend/operation_settings/components/external_dashboard_spec.js b/spec/frontend/operation_settings/components/external_dashboard_spec.js index de1dd219fe0..986aada0b03 100644 --- a/spec/frontend/operation_settings/components/external_dashboard_spec.js +++ b/spec/frontend/operation_settings/components/external_dashboard_spec.js @@ -1,30 +1,64 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount, shallowMount, createLocalVue } from '@vue/test-utils'; import { GlButton, GlLink, GlFormGroup, GlFormInput } from '@gitlab/ui'; import ExternalDashboard from '~/operation_settings/components/external_dashboard.vue'; +import store from '~/operation_settings/store'; +import axios from '~/lib/utils/axios_utils'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import createFlash from '~/flash'; import { TEST_HOST } from 'helpers/test_constants'; +jest.mock('~/lib/utils/axios_utils'); +jest.mock('~/lib/utils/url_utility'); +jest.mock('~/flash'); + describe('operation settings external dashboard component', () => { let wrapper; - const externalDashboardPath = `http://mock-external-domain.com/external/dashboard/path`; + const operationsSettingsEndpoint = `${TEST_HOST}/mock/ops/settings/endpoint`; + const externalDashboardUrl = `http://mock-external-domain.com/external/dashboard/url`; const externalDashboardHelpPagePath = `${TEST_HOST}/help/page/path`; - - beforeEach(() => { - wrapper = shallowMount(ExternalDashboard, { - propsData: { - externalDashboardPath, - externalDashboardHelpPagePath, + const localVue = createLocalVue(); + const mountComponent = (shallow = true) => { + const config = [ + ExternalDashboard, + { + localVue, + store: store({ + operationsSettingsEndpoint, + externalDashboardUrl, + externalDashboardHelpPagePath, + }), }, - }); + ]; + wrapper = shallow ? shallowMount(...config) : mount(...config); + }; + + afterEach(() => { + if (wrapper.destroy) { + wrapper.destroy(); + } + axios.patch.mockReset(); + refreshCurrentPage.mockReset(); + createFlash.mockReset(); }); it('renders header text', () => { + mountComponent(); expect(wrapper.find('.js-section-header').text()).toBe('External Dashboard'); }); + describe('expand/collapse button', () => { + it('renders as an expand button by default', () => { + const button = wrapper.find(GlButton); + + expect(button.text()).toBe('Expand'); + }); + }); + describe('sub-header', () => { let subHeader; beforeEach(() => { + mountComponent(); subHeader = wrapper.find('.js-section-sub-header'); }); @@ -43,57 +77,87 @@ describe('operation settings external dashboard component', () => { }); describe('form', () => { - let form; + describe('input label', () => { + let formGroup; - beforeEach(() => { - form = wrapper.find('form'); - }); + beforeEach(() => { + mountComponent(); + formGroup = wrapper.find(GlFormGroup); + }); - describe('external dashboard url', () => { - describe('input label', () => { - let formGroup; + it('uses label text', () => { + expect(formGroup.attributes().label).toBe('Full dashboard URL'); + }); - beforeEach(() => { - formGroup = form.find(GlFormGroup); - }); + it('uses description text', () => { + expect(formGroup.attributes().description).toBe( + 'Enter the URL of the dashboard you want to link to', + ); + }); + }); - it('uses label text', () => { - expect(formGroup.attributes().label).toBe('Full dashboard URL'); - }); + describe('input field', () => { + let input; - it('uses description text', () => { - expect(formGroup.attributes().description).toBe( - 'Enter the URL of the dashboard you want to link to', - ); - }); + beforeEach(() => { + mountComponent(); + input = wrapper.find(GlFormInput); }); - describe('input field', () => { - let input; - - beforeEach(() => { - input = form.find(GlFormInput); - }); + it('defaults to externalDashboardUrl', () => { + expect(input.attributes().value).toBe(externalDashboardUrl); + }); - it('defaults to externalDashboardPath prop', () => { - expect(input.attributes().value).toBe(externalDashboardPath); - }); + it('uses a placeholder', () => { + expect(input.attributes().placeholder).toBe('https://my-org.gitlab.io/my-dashboards'); + }); + }); - it('uses a placeholder', () => { - expect(input.attributes().placeholder).toBe('https://my-org.gitlab.io/my-dashboards'); - }); + describe('submit button', () => { + const endpointRequest = [ + operationsSettingsEndpoint, + { + project: { + metrics_setting_attributes: { + external_dashboard_url: externalDashboardUrl, + }, + }, + }, + ]; + + it('renders button label', () => { + mountComponent(); + const submit = wrapper.find(GlButton); + expect(submit.text()).toBe('Save Changes'); }); - describe('submit button', () => { - let submit; + it('submits form on click', () => { + mountComponent(false); + axios.patch.mockResolvedValue(); + wrapper.find(GlButton).trigger('click'); - beforeEach(() => { - submit = form.find(GlButton); - }); + expect(axios.patch).toHaveBeenCalledWith(...endpointRequest); - it('renders button label', () => { - expect(submit.text()).toBe('Save Changes'); - }); + return wrapper.vm.$nextTick().then(() => expect(refreshCurrentPage).toHaveBeenCalled()); + }); + + it('creates flash banner on error', () => { + mountComponent(false); + const message = 'mockErrorMessage'; + axios.patch.mockRejectedValue({ response: { data: { message } } }); + wrapper.find(GlButton).trigger('click'); + + expect(axios.patch).toHaveBeenCalledWith(...endpointRequest); + + return wrapper.vm + .$nextTick() + .then(jest.runAllTicks) + .then(() => + expect(createFlash).toHaveBeenCalledWith( + `There was an error saving your changes. ${message}`, + 'alert', + ), + ); }); }); }); diff --git a/spec/frontend/operation_settings/store/mutations_spec.js b/spec/frontend/operation_settings/store/mutations_spec.js new file mode 100644 index 00000000000..1854142c89a --- /dev/null +++ b/spec/frontend/operation_settings/store/mutations_spec.js @@ -0,0 +1,19 @@ +import mutations from '~/operation_settings/store/mutations'; +import createState from '~/operation_settings/store/state'; + +describe('operation settings mutations', () => { + let localState; + + beforeEach(() => { + localState = createState(); + }); + + describe('SET_EXTERNAL_DASHBOARD_URL', () => { + it('sets externalDashboardUrl', () => { + const mockUrl = 'mockUrl'; + mutations.SET_EXTERNAL_DASHBOARD_URL(localState, mockUrl); + + expect(localState.externalDashboardUrl).toBe(mockUrl); + }); + }); +}); diff --git a/spec/frontend/serverless/components/environment_row_spec.js b/spec/frontend/serverless/components/environment_row_spec.js index 161a637dd75..0ad85e218dc 100644 --- a/spec/frontend/serverless/components/environment_row_spec.js +++ b/spec/frontend/serverless/components/environment_row_spec.js @@ -14,7 +14,7 @@ describe('environment row component', () => { beforeEach(() => { localVue = createLocalVue(); - vm = createComponent(localVue, translate(mockServerlessFunctions)['*'], '*'); + vm = createComponent(localVue, translate(mockServerlessFunctions.functions)['*'], '*'); }); afterEach(() => vm.$destroy()); @@ -48,7 +48,11 @@ describe('environment row component', () => { beforeEach(() => { localVue = createLocalVue(); - vm = createComponent(localVue, translate(mockServerlessFunctionsDiffEnv).test, 'test'); + vm = createComponent( + localVue, + translate(mockServerlessFunctionsDiffEnv.functions).test, + 'test', + ); }); afterEach(() => vm.$destroy()); diff --git a/spec/frontend/serverless/components/functions_spec.js b/spec/frontend/serverless/components/functions_spec.js index 6924fb9e91f..d8a80f8031e 100644 --- a/spec/frontend/serverless/components/functions_spec.js +++ b/spec/frontend/serverless/components/functions_spec.js @@ -34,11 +34,11 @@ describe('functionsComponent', () => { }); it('should render empty state when Knative is not installed', () => { + store.dispatch('receiveFunctionsSuccess', { knative_installed: false }); component = shallowMount(functionsComponent, { localVue, store, propsData: { - installed: false, clustersPath: '', helpPath: '', statusPath: '', @@ -55,7 +55,6 @@ describe('functionsComponent', () => { localVue, store, propsData: { - installed: true, clustersPath: '', helpPath: '', statusPath: '', @@ -67,12 +66,11 @@ describe('functionsComponent', () => { }); it('should render empty state when there is no function data', () => { - store.dispatch('receiveFunctionsNoDataSuccess'); + store.dispatch('receiveFunctionsNoDataSuccess', { knative_installed: true }); component = shallowMount(functionsComponent, { localVue, store, propsData: { - installed: true, clustersPath: '', helpPath: '', statusPath: '', @@ -91,12 +89,31 @@ describe('functionsComponent', () => { ); }); + it('should render functions and a loader when functions are partially fetched', () => { + store.dispatch('receiveFunctionsPartial', { + ...mockServerlessFunctions, + knative_installed: 'checking', + }); + component = shallowMount(functionsComponent, { + localVue, + store, + propsData: { + clustersPath: '', + helpPath: '', + statusPath: '', + }, + sync: false, + }); + + expect(component.find('.js-functions-wrapper').exists()).toBe(true); + expect(component.find('.js-functions-loader').exists()).toBe(true); + }); + it('should render the functions list', () => { component = shallowMount(functionsComponent, { localVue, store, propsData: { - installed: true, clustersPath: 'clustersPath', helpPath: 'helpPath', statusPath, diff --git a/spec/frontend/serverless/mock_data.js b/spec/frontend/serverless/mock_data.js index a2c18616324..ef616ceb37f 100644 --- a/spec/frontend/serverless/mock_data.js +++ b/spec/frontend/serverless/mock_data.js @@ -1,56 +1,62 @@ -export const mockServerlessFunctions = [ - { - name: 'testfunc1', - namespace: 'tm-example', - environment_scope: '*', - cluster_id: 46, - detail_url: '/testuser/testproj/serverless/functions/*/testfunc1', - podcount: null, - created_at: '2019-02-05T01:01:23Z', - url: 'http://testfunc1.tm-example.apps.example.com', - description: 'A test service', - image: 'knative-test-container-buildtemplate', - }, - { - name: 'testfunc2', - namespace: 'tm-example', - environment_scope: '*', - cluster_id: 46, - detail_url: '/testuser/testproj/serverless/functions/*/testfunc2', - podcount: null, - created_at: '2019-02-05T01:01:23Z', - url: 'http://testfunc2.tm-example.apps.example.com', - description: 'A second test service\nThis one with additional descriptions', - image: 'knative-test-echo-buildtemplate', - }, -]; +export const mockServerlessFunctions = { + knative_installed: true, + functions: [ + { + name: 'testfunc1', + namespace: 'tm-example', + environment_scope: '*', + cluster_id: 46, + detail_url: '/testuser/testproj/serverless/functions/*/testfunc1', + podcount: null, + created_at: '2019-02-05T01:01:23Z', + url: 'http://testfunc1.tm-example.apps.example.com', + description: 'A test service', + image: 'knative-test-container-buildtemplate', + }, + { + name: 'testfunc2', + namespace: 'tm-example', + environment_scope: '*', + cluster_id: 46, + detail_url: '/testuser/testproj/serverless/functions/*/testfunc2', + podcount: null, + created_at: '2019-02-05T01:01:23Z', + url: 'http://testfunc2.tm-example.apps.example.com', + description: 'A second test service\nThis one with additional descriptions', + image: 'knative-test-echo-buildtemplate', + }, + ], +}; -export const mockServerlessFunctionsDiffEnv = [ - { - name: 'testfunc1', - namespace: 'tm-example', - environment_scope: '*', - cluster_id: 46, - detail_url: '/testuser/testproj/serverless/functions/*/testfunc1', - podcount: null, - created_at: '2019-02-05T01:01:23Z', - url: 'http://testfunc1.tm-example.apps.example.com', - description: 'A test service', - image: 'knative-test-container-buildtemplate', - }, - { - name: 'testfunc2', - namespace: 'tm-example', - environment_scope: 'test', - cluster_id: 46, - detail_url: '/testuser/testproj/serverless/functions/*/testfunc2', - podcount: null, - created_at: '2019-02-05T01:01:23Z', - url: 'http://testfunc2.tm-example.apps.example.com', - description: 'A second test service\nThis one with additional descriptions', - image: 'knative-test-echo-buildtemplate', - }, -]; +export const mockServerlessFunctionsDiffEnv = { + knative_installed: true, + functions: [ + { + name: 'testfunc1', + namespace: 'tm-example', + environment_scope: '*', + cluster_id: 46, + detail_url: '/testuser/testproj/serverless/functions/*/testfunc1', + podcount: null, + created_at: '2019-02-05T01:01:23Z', + url: 'http://testfunc1.tm-example.apps.example.com', + description: 'A test service', + image: 'knative-test-container-buildtemplate', + }, + { + name: 'testfunc2', + namespace: 'tm-example', + environment_scope: 'test', + cluster_id: 46, + detail_url: '/testuser/testproj/serverless/functions/*/testfunc2', + podcount: null, + created_at: '2019-02-05T01:01:23Z', + url: 'http://testfunc2.tm-example.apps.example.com', + description: 'A second test service\nThis one with additional descriptions', + image: 'knative-test-echo-buildtemplate', + }, + ], +}; export const mockServerlessFunction = { name: 'testfunc1', diff --git a/spec/frontend/serverless/store/getters_spec.js b/spec/frontend/serverless/store/getters_spec.js index fb549c8f153..92853fda37c 100644 --- a/spec/frontend/serverless/store/getters_spec.js +++ b/spec/frontend/serverless/store/getters_spec.js @@ -32,7 +32,7 @@ describe('Serverless Store Getters', () => { describe('getFunctions', () => { it('should translate the raw function array to group the functions per environment scope', () => { - state.functions = mockServerlessFunctions; + state.functions = mockServerlessFunctions.functions; const funcs = getters.getFunctions(state); diff --git a/spec/frontend/serverless/store/mutations_spec.js b/spec/frontend/serverless/store/mutations_spec.js index ca3053e5c38..e2771c7e5fd 100644 --- a/spec/frontend/serverless/store/mutations_spec.js +++ b/spec/frontend/serverless/store/mutations_spec.js @@ -19,13 +19,13 @@ describe('ServerlessMutations', () => { expect(state.isLoading).toEqual(false); expect(state.hasFunctionData).toEqual(true); - expect(state.functions).toEqual(mockServerlessFunctions); + expect(state.functions).toEqual(mockServerlessFunctions.functions); }); it('should ensure loading has stopped and hasFunctionData is false when there are no functions available', () => { const state = {}; - mutations[types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state); + mutations[types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state, { knative_installed: true }); expect(state.isLoading).toEqual(false); expect(state.hasFunctionData).toEqual(false); diff --git a/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js b/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js index b356ea85cad..0f5d47b3bfe 100644 --- a/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js +++ b/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js @@ -4,7 +4,7 @@ describe('getStateKey', () => { it('should return proper state name', () => { const context = { mergeStatus: 'checked', - mergeWhenPipelineSucceeds: false, + autoMergeEnabled: false, canMerge: true, onlyAllowMergeIfPipelineSucceeds: false, isPipelineFailed: false, @@ -31,9 +31,9 @@ describe('getStateKey', () => { expect(bound()).toEqual('notAllowedToMerge'); - context.mergeWhenPipelineSucceeds = true; + context.autoMergeEnabled = true; - expect(bound()).toEqual('mergeWhenPipelineSucceeds'); + expect(bound()).toEqual('autoMergeEnabled'); context.isSHAMismatch = true; @@ -80,7 +80,7 @@ describe('getStateKey', () => { it('returns rebased state key', () => { const context = { mergeStatus: 'checked', - mergeWhenPipelineSucceeds: false, + autoMergeEnabled: false, canMerge: true, onlyAllowMergeIfPipelineSucceeds: true, isPipelineFailed: true, diff --git a/spec/graphql/resolvers/namespace_projects_resolver_spec.rb b/spec/graphql/resolvers/namespace_projects_resolver_spec.rb new file mode 100644 index 00000000000..395e08081d3 --- /dev/null +++ b/spec/graphql/resolvers/namespace_projects_resolver_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Resolvers::NamespaceProjectsResolver, :nested_groups do + include GraphqlHelpers + + let(:current_user) { create(:user) } + + context "with a group" do + let(:group) { create(:group) } + let(:namespace) { group } + let(:project1) { create(:project, namespace: namespace) } + let(:project2) { create(:project, namespace: namespace) } + let(:nested_group) { create(:group, parent: group) } + let(:nested_project) { create(:project, group: nested_group) } + + before do + project1.add_developer(current_user) + project2.add_developer(current_user) + nested_project.add_developer(current_user) + end + + describe '#resolve' do + it 'finds all projects' do + expect(resolve_projects).to contain_exactly(project1, project2) + end + + it 'finds all projects including the subgroups' do + expect(resolve_projects(include_subgroups: true)).to contain_exactly(project1, project2, nested_project) + end + + context 'with an user namespace' do + let(:namespace) { current_user.namespace } + + it 'finds all projects' do + expect(resolve_projects).to contain_exactly(project1, project2) + end + + it 'finds all projects including the subgroups' do + expect(resolve_projects(include_subgroups: true)).to contain_exactly(project1, project2) + end + end + end + end + + context "when passing a non existent, batch loaded namespace" do + let(:namespace) do + BatchLoader.for("non-existent-path").batch do |_fake_paths, loader, _| + loader.call("non-existent-path", nil) + end + end + + it "returns nil without breaking" do + expect(resolve_projects).to be_empty + end + end + + it 'has an high complexity regardless of arguments' do + field = Types::BaseField.new(name: 'test', type: GraphQL::STRING_TYPE, resolver_class: described_class, null: false, max_page_size: 100) + + expect(field.to_graphql.complexity.call({}, {}, 1)).to eq 24 + expect(field.to_graphql.complexity.call({}, { include_subgroups: true }, 1)).to eq 24 + end + + def resolve_projects(args = { include_subgroups: false }, context = { current_user: current_user }) + resolve(described_class, obj: namespace, args: args, ctx: context) + end +end diff --git a/spec/graphql/types/base_field_spec.rb b/spec/graphql/types/base_field_spec.rb index 4fe426e2447..a7fb156d9a8 100644 --- a/spec/graphql/types/base_field_spec.rb +++ b/spec/graphql/types/base_field_spec.rb @@ -6,7 +6,7 @@ describe Types::BaseField do context 'when considering complexity' do let(:resolver) do Class.new(described_class) do - def self.resolver_complexity(args) + def self.resolver_complexity(args, child_complexity:) 2 if args[:foo] end diff --git a/spec/graphql/types/issue_type_spec.rb b/spec/graphql/types/issue_type_spec.rb index dc37b15001f..bae560829cc 100644 --- a/spec/graphql/types/issue_type_spec.rb +++ b/spec/graphql/types/issue_type_spec.rb @@ -6,4 +6,10 @@ describe GitlabSchema.types['Issue'] do it { expect(described_class.graphql_name).to eq('Issue') } it { expect(described_class).to require_graphql_authorizations(:read_issue) } + + it 'has specific fields' do + %i[relative_position web_path web_url reference].each do |field_name| + expect(described_class).to have_graphql_field(field_name) + end + end end diff --git a/spec/graphql/types/namespace_type.rb b/spec/graphql/types/namespace_type_spec.rb index 7cd6a79ae5d..b4144cc4121 100644 --- a/spec/graphql/types/namespace_type.rb +++ b/spec/graphql/types/namespace_type_spec.rb @@ -4,4 +4,6 @@ require 'spec_helper' describe GitlabSchema.types['Namespace'] do it { expect(described_class.graphql_name).to eq('Namespace') } + + it { expect(described_class).to have_graphql_field(:projects) } end diff --git a/spec/graphql/types/project_statistics_type_spec.rb b/spec/graphql/types/project_statistics_type_spec.rb new file mode 100644 index 00000000000..e9feac57a36 --- /dev/null +++ b/spec/graphql/types/project_statistics_type_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GitlabSchema.types['ProjectStatistics'] do + it "has all the required fields" do + is_expected.to have_graphql_fields(:storage_size, :repository_size, :lfs_objects_size, + :build_artifacts_size, :packages_size, :commit_count, + :wiki_size) + end +end diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb index 075fa7c7e43..cb5ac2e3cb1 100644 --- a/spec/graphql/types/project_type_spec.rb +++ b/spec/graphql/types/project_type_spec.rb @@ -19,4 +19,6 @@ describe GitlabSchema.types['Project'] do it { is_expected.to have_graphql_field(:pipelines) } it { is_expected.to have_graphql_field(:repository) } + + it { is_expected.to have_graphql_field(:statistics) } end diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb index b4626955816..af1972a2513 100644 --- a/spec/graphql/types/query_type_spec.rb +++ b/spec/graphql/types/query_type_spec.rb @@ -5,7 +5,17 @@ describe GitlabSchema.types['Query'] do expect(described_class.graphql_name).to eq('Query') end - it { is_expected.to have_graphql_fields(:project, :group, :echo, :metadata) } + it { is_expected.to have_graphql_fields(:project, :namespace, :group, :echo, :metadata) } + + describe 'namespace field' do + subject { described_class.fields['namespace'] } + + it 'finds namespaces by full path' do + is_expected.to have_graphql_arguments(:full_path) + is_expected.to have_graphql_type(Types::NamespaceType) + is_expected.to have_graphql_resolver(Resolvers::NamespaceResolver) + end + end describe 'project field' do subject { described_class.fields['project'] } diff --git a/spec/helpers/emails_helper_spec.rb b/spec/helpers/emails_helper_spec.rb index 0434af25866..e6aacb5b92b 100644 --- a/spec/helpers/emails_helper_spec.rb +++ b/spec/helpers/emails_helper_spec.rb @@ -8,19 +8,19 @@ describe EmailsHelper do context "and format is text" do it "returns plain text" do - expect(closure_reason_text(merge_request, format: :text)).to eq(" via merge request #{merge_request.to_reference} (#{merge_request_presenter.web_url})") + expect(closure_reason_text(merge_request, format: :text)).to eq("via merge request #{merge_request.to_reference} (#{merge_request_presenter.web_url})") end end context "and format is HTML" do it "returns HTML" do - expect(closure_reason_text(merge_request, format: :html)).to eq(" via merge request #{link_to(merge_request.to_reference, merge_request_presenter.web_url)}") + expect(closure_reason_text(merge_request, format: :html)).to eq("via merge request #{link_to(merge_request.to_reference, merge_request_presenter.web_url)}") end end context "and format is unknown" do it "returns plain text" do - expect(closure_reason_text(merge_request, format: :text)).to eq(" via merge request #{merge_request.to_reference} (#{merge_request_presenter.web_url})") + expect(closure_reason_text(merge_request, format: :text)).to eq("via merge request #{merge_request.to_reference} (#{merge_request_presenter.web_url})") end end end @@ -29,7 +29,7 @@ describe EmailsHelper do let(:closed_via) { "5a0eb6fd7e0f133044378c662fcbbc0d0c16dbfa" } it "returns plain text" do - expect(closure_reason_text(closed_via)).to eq(" via #{closed_via}") + expect(closure_reason_text(closed_via)).to eq("via #{closed_via}") end end diff --git a/spec/helpers/gitlab_routing_helper_spec.rb b/spec/helpers/gitlab_routing_helper_spec.rb index 143b28728a3..027480143bd 100644 --- a/spec/helpers/gitlab_routing_helper_spec.rb +++ b/spec/helpers/gitlab_routing_helper_spec.rb @@ -101,7 +101,7 @@ describe GitlabRoutingHelper do it 'returns project milestone edit path when given entity parent is not a Group' do milestone = create(:milestone, group: nil) - expect(edit_milestone_path(milestone)).to eq("/#{milestone.project.full_path}/milestones/#{milestone.iid}/edit") + expect(edit_milestone_path(milestone)).to eq("/#{milestone.project.full_path}/-/milestones/#{milestone.iid}/edit") end end end diff --git a/spec/javascripts/boards/boards_store_spec.js b/spec/javascripts/boards/boards_store_spec.js index d3e6eb78e5a..68e66346bfd 100644 --- a/spec/javascripts/boards/boards_store_spec.js +++ b/spec/javascripts/boards/boards_store_spec.js @@ -12,6 +12,7 @@ import '~/boards/models/issue'; import '~/boards/models/list'; import '~/boards/services/board_service'; import boardsStore from '~/boards/stores/boards_store'; +import eventHub from '~/boards/eventhub'; import { listObj, listObjDuplicate, boardsMockInterceptor, mockBoardService } from './mock_data'; describe('Store', () => { @@ -53,6 +54,39 @@ describe('Store', () => { }); }); + describe('toggleFilter', () => { + const dummyFilter = 'x=42'; + let updateTokensSpy; + + beforeEach(() => { + updateTokensSpy = jasmine.createSpy('updateTokens'); + eventHub.$once('updateTokens', updateTokensSpy); + + // prevent using window.history + spyOn(boardsStore, 'updateFiltersUrl').and.callFake(() => {}); + }); + + it('adds the filter if it is not present', () => { + boardsStore.filter.path = 'something'; + + boardsStore.toggleFilter(dummyFilter); + + expect(boardsStore.filter.path).toEqual(`something&${dummyFilter}`); + expect(updateTokensSpy).toHaveBeenCalled(); + expect(boardsStore.updateFiltersUrl).toHaveBeenCalled(); + }); + + it('removes the filter if it is present', () => { + boardsStore.filter.path = `something&${dummyFilter}`; + + boardsStore.toggleFilter(dummyFilter); + + expect(boardsStore.filter.path).toEqual('something'); + expect(updateTokensSpy).toHaveBeenCalled(); + expect(boardsStore.updateFiltersUrl).toHaveBeenCalled(); + }); + }); + describe('lists', () => { it('creates new list without persisting to DB', () => { boardsStore.addList(listObj); @@ -287,4 +321,27 @@ describe('Store', () => { expect(boardsStore.detail.issue).toEqual({}); }); }); + + describe('setIssueDetail', () => { + it('sets issue details', () => { + boardsStore.detail.issue = 'some details'; + + const dummyValue = 'new details'; + boardsStore.setIssueDetail(dummyValue); + + expect(boardsStore.detail.issue).toEqual(dummyValue); + }); + }); + + describe('startMoving', () => { + it('stores list and issue', () => { + const dummyIssue = 'some issue'; + const dummyList = 'some list'; + + boardsStore.startMoving(dummyList, dummyIssue); + + expect(boardsStore.moving.issue).toEqual(dummyIssue); + expect(boardsStore.moving.list).toEqual(dummyList); + }); + }); }); diff --git a/spec/javascripts/boards/mock_data.js b/spec/javascripts/boards/mock_data.js index 93a0f29af0a..9854cf49e97 100644 --- a/spec/javascripts/boards/mock_data.js +++ b/spec/javascripts/boards/mock_data.js @@ -47,7 +47,7 @@ export const BoardsMockData = { }, ], }, - '/test/issue-boards/milestones.json': [ + '/test/issue-boards/-/milestones.json': [ { id: 1, title: 'test', @@ -58,10 +58,10 @@ export const BoardsMockData = { '/test/-/boards/1/lists': listObj, }, PUT: { - '/test/issue-boards/board/1/lists{/id}': {}, + '/test/issue-boards/-/board/1/lists{/id}': {}, }, DELETE: { - '/test/issue-boards/board/1/lists{/id}': {}, + '/test/issue-boards/-/board/1/lists{/id}': {}, }, }; @@ -71,7 +71,7 @@ export const boardsMockInterceptor = config => { }; export const mockBoardService = (opts = {}) => { - const boardsEndpoint = opts.boardsEndpoint || '/test/issue-boards/boards.json'; + const boardsEndpoint = opts.boardsEndpoint || '/test/issue-boards/-/boards.json'; const listsEndpoint = opts.listsEndpoint || '/test/-/boards/1/lists'; const bulkUpdatePath = opts.bulkUpdatePath || ''; const boardId = opts.boardId || '1'; diff --git a/spec/javascripts/diffs/components/commit_item_spec.js b/spec/javascripts/diffs/components/commit_item_spec.js index 8fc9b10dd0b..cfe0c4bad71 100644 --- a/spec/javascripts/diffs/components/commit_item_spec.js +++ b/spec/javascripts/diffs/components/commit_item_spec.js @@ -8,7 +8,7 @@ import getDiffWithCommit from '../mock_data/diff_with_commit'; const TEST_AUTHOR_NAME = 'test'; const TEST_AUTHOR_EMAIL = 'test+test@gitlab.com'; -const TEST_AUTHOR_GRAVATAR = `${TEST_HOST}/avatar/test?s=36`; +const TEST_AUTHOR_GRAVATAR = `${TEST_HOST}/avatar/test?s=40`; const TEST_SIGNATURE_HTML = '<a>Legit commit</a>'; const TEST_PIPELINE_STATUS_PATH = `${TEST_HOST}/pipeline/status`; @@ -65,7 +65,7 @@ describe('diffs/components/commit_item', () => { const imgElement = avatarElement.querySelector('img'); expect(avatarElement).toHaveAttr('href', commit.author.web_url); - expect(imgElement).toHaveClass('s36'); + expect(imgElement).toHaveClass('s40'); expect(imgElement).toHaveAttr('alt', commit.author.name); expect(imgElement).toHaveAttr('src', commit.author.avatar_url); }); diff --git a/spec/javascripts/helpers/vue_test_utils_helper.js b/spec/javascripts/helpers/vue_test_utils_helper.js index 19e27388eeb..121e99c9783 100644 --- a/spec/javascripts/helpers/vue_test_utils_helper.js +++ b/spec/javascripts/helpers/vue_test_utils_helper.js @@ -16,4 +16,6 @@ const vNodeContainsText = (vnode, text) => * @param {String} text */ export const shallowWrapperContainsSlotText = (shallowWrapper, slotName, text) => - !!shallowWrapper.vm.$slots[slotName].filter(vnode => vNodeContainsText(vnode, text)).length; + Boolean( + shallowWrapper.vm.$slots[slotName].filter(vnode => vNodeContainsText(vnode, text)).length, + ); diff --git a/spec/javascripts/matchers.js b/spec/javascripts/matchers.js index 406527b08a3..7d1921cabcf 100644 --- a/spec/javascripts/matchers.js +++ b/spec/javascripts/matchers.js @@ -28,7 +28,7 @@ export default { reference.getAttribute('xlink:href').endsWith(`#${iconName}`), ); const result = { - pass: !!matchingIcon, + pass: Boolean(matchingIcon), }; if (result.pass) { diff --git a/spec/javascripts/monitoring/dashboard_spec.js b/spec/javascripts/monitoring/dashboard_spec.js index 58bcd916739..cea8cb18918 100644 --- a/spec/javascripts/monitoring/dashboard_spec.js +++ b/spec/javascripts/monitoring/dashboard_spec.js @@ -176,10 +176,6 @@ describe('Dashboard', () => { store, }); - component.$store.commit( - `monitoringDashboard/${types.SET_ENVIRONMENTS_ENDPOINT}`, - '/environments', - ); component.$store.commit(`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, []); component.$store.commit( `monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, @@ -211,10 +207,6 @@ describe('Dashboard', () => { }); component.$store.commit( - `monitoringDashboard/${types.SET_ENVIRONMENTS_ENDPOINT}`, - '/environments', - ); - component.$store.commit( `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, environmentData, ); @@ -401,7 +393,7 @@ describe('Dashboard', () => { hasMetrics: true, showPanels: false, showTimeWindowDropdown: false, - externalDashboardPath: '/mockPath', + externalDashboardUrl: '/mockUrl', }, store, }); @@ -427,7 +419,7 @@ describe('Dashboard', () => { hasMetrics: true, showPanels: false, showTimeWindowDropdown: false, - externalDashboardPath: '', + externalDashboardUrl: '', }, store, }); diff --git a/spec/javascripts/projects/project_new_spec.js b/spec/javascripts/projects/project_new_spec.js index b61e0ac872f..106a3ba94e4 100644 --- a/spec/javascripts/projects/project_new_spec.js +++ b/spec/javascripts/projects/project_new_spec.js @@ -10,7 +10,17 @@ describe('New Project', () => { setFixtures(` <div class='toggle-import-form'> <div class='import-url-data'> - <input id="project_import_url" /> + <div class="form-group"> + <input id="project_import_url" /> + </div> + <div id="import-url-auth-method"> + <div class="form-group"> + <input id="project-import-url-user" /> + </div> + <div class="form-group"> + <input id="project_import_url_password" /> + </div> + </div> <input id="project_name" /> <input id="project_path" /> </div> @@ -119,7 +129,7 @@ describe('New Project', () => { }); it('changes project path for HTTPS URL in $projectImportUrl', () => { - $projectImportUrl.val('https://username:password@gitlab.company.com/group/project.git'); + $projectImportUrl.val('https://gitlab.company.com/group/project.git'); projectNew.deriveProjectPathFromUrl($projectImportUrl); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js index b9718a78fa4..8e0415b813b 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js @@ -21,7 +21,7 @@ describe('MRWidgetMergeWhenPipelineSucceeds', () => { canCancelAutomaticMerge: true, mergeUserId: 1, currentUserId: 1, - setToMWPSBy: {}, + setToAutoMergeBy: {}, sha, targetBranchPath, targetBranch, @@ -106,7 +106,7 @@ describe('MRWidgetMergeWhenPipelineSucceeds', () => { expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); expect(vm.service.merge).toHaveBeenCalledWith({ sha, - merge_when_pipeline_succeeds: true, + auto_merge_strategy: 'merge_when_pipeline_succeeds', should_remove_source_branch: true, }); done(); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index 368c997d318..3ae773b6ccb 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -80,7 +80,7 @@ describe('ReadyToMerge', () => { it('should have default data', () => { expect(vm.mergeWhenBuildSucceeds).toBeFalsy(); expect(vm.useCommitMessageWithDescription).toBeFalsy(); - expect(vm.setToMergeWhenPipelineSucceeds).toBeFalsy(); + expect(vm.autoMergeStrategy).toBeUndefined(); expect(vm.showCommitMessageEditor).toBeFalsy(); expect(vm.isMakingRequest).toBeFalsy(); expect(vm.isMergingImmediately).toBeFalsy(); @@ -91,17 +91,17 @@ describe('ReadyToMerge', () => { }); describe('computed', () => { - describe('shouldShowMergeWhenPipelineSucceedsText', () => { + describe('shouldShowAutoMergeText', () => { it('should return true with active pipeline', () => { vm.mr.isPipelineActive = true; - expect(vm.shouldShowMergeWhenPipelineSucceedsText).toBeTruthy(); + expect(vm.shouldShowAutoMergeText).toBeTruthy(); }); it('should return false with inactive pipeline', () => { vm.mr.isPipelineActive = false; - expect(vm.shouldShowMergeWhenPipelineSucceedsText).toBeFalsy(); + expect(vm.shouldShowAutoMergeText).toBeFalsy(); }); }); @@ -325,16 +325,20 @@ describe('ReadyToMerge', () => { vm.handleMergeButtonClick(true); setTimeout(() => { - expect(vm.setToMergeWhenPipelineSucceeds).toBeTruthy(); + expect(vm.autoMergeStrategy).toBe('merge_when_pipeline_succeeds'); expect(vm.isMakingRequest).toBeTruthy(); expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); const params = vm.service.merge.calls.argsFor(0)[0]; - expect(params.sha).toEqual(vm.mr.sha); - expect(params.commit_message).toEqual(vm.mr.commitMessage); - expect(params.should_remove_source_branch).toBeFalsy(); - expect(params.merge_when_pipeline_succeeds).toBeTruthy(); + expect(params).toEqual( + jasmine.objectContaining({ + sha: vm.mr.sha, + commit_message: vm.mr.commitMessage, + should_remove_source_branch: false, + auto_merge_strategy: 'merge_when_pipeline_succeeds', + }), + ); done(); }, 333); }); @@ -345,7 +349,7 @@ describe('ReadyToMerge', () => { vm.handleMergeButtonClick(false, true); setTimeout(() => { - expect(vm.setToMergeWhenPipelineSucceeds).toBeFalsy(); + expect(vm.autoMergeStrategy).toBeUndefined(); expect(vm.isMakingRequest).toBeTruthy(); expect(eventHub.$emit).toHaveBeenCalledWith('FailedToMerge', undefined); @@ -363,7 +367,7 @@ describe('ReadyToMerge', () => { vm.handleMergeButtonClick(); setTimeout(() => { - expect(vm.setToMergeWhenPipelineSucceeds).toBeFalsy(); + expect(vm.autoMergeStrategy).toBeUndefined(); expect(vm.isMakingRequest).toBeTruthy(); expect(vm.initiateMergePolling).toHaveBeenCalled(); 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 7653c10b94b..918717c4547 100644 --- a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js @@ -21,7 +21,6 @@ describe('mrWidgetOptions', () => { const COLLABORATION_MESSAGE = 'Allows commits from members who can merge to the target branch'; beforeEach(() => { - gon.features = { approvalRules: false }; // Prevent component mounting delete mrWidgetOptions.el; @@ -32,7 +31,6 @@ describe('mrWidgetOptions', () => { }); afterEach(() => { - gon.features = null; vm.$destroy(); }); diff --git a/spec/lib/banzai/filter/wiki_link_filter_spec.rb b/spec/lib/banzai/filter/wiki_link_filter_spec.rb index b9059b85fdc..cce1cd0b284 100644 --- a/spec/lib/banzai/filter/wiki_link_filter_spec.rb +++ b/spec/lib/banzai/filter/wiki_link_filter_spec.rb @@ -70,5 +70,47 @@ describe Banzai::Filter::WikiLinkFilter do expect(filtered_link.attribute('href').value).to eq(invalid_link) end end + + context "when the slug is deemed unsafe or invalid" do + let(:link) { "alert(1);" } + + invalid_slugs = [ + "javascript:", + "JaVaScRiPt:", + "\u0001java\u0003script:", + "javascript :", + "javascript: ", + "javascript : ", + ":javascript:", + "javascript:", + "javascript:", + "javascript:", + "javascript:", + "java\0script:", + "  javascript:" + ] + + invalid_slugs.each do |slug| + context "with the slug #{slug}" do + it "doesn't rewrite a (.) relative link" do + filtered_link = filter( + "<a href='.#{link}'>Link</a>", + project_wiki: wiki, + page_slug: slug).children[0] + + expect(filtered_link.attribute('href').value).not_to include(slug) + end + + it "doesn't rewrite a (..) relative link" do + filtered_link = filter( + "<a href='..#{link}'>Link</a>", + project_wiki: wiki, + page_slug: slug).children[0] + + expect(filtered_link.attribute('href').value).not_to include(slug) + end + end + end + end end end diff --git a/spec/lib/banzai/redactor_spec.rb b/spec/lib/banzai/redactor_spec.rb index aaeec953e4b..718649e0e10 100644 --- a/spec/lib/banzai/redactor_spec.rb +++ b/spec/lib/banzai/redactor_spec.rb @@ -13,10 +13,10 @@ describe Banzai::Redactor do it 'redacts an array of documents' do doc1 = Nokogiri::HTML - .fragment('<a class="gfm" data-reference-type="issue">foo</a>') + .fragment('<a class="gfm" href="https://www.gitlab.com" data-reference-type="issue">foo</a>') doc2 = Nokogiri::HTML - .fragment('<a class="gfm" data-reference-type="issue">bar</a>') + .fragment('<a class="gfm" href="https://www.gitlab.com" data-reference-type="issue">bar</a>') redacted_data = redactor.redact([doc1, doc2]) @@ -27,7 +27,7 @@ describe Banzai::Redactor do end it 'replaces redacted reference with inner HTML' do - doc = Nokogiri::HTML.fragment("<a class='gfm' data-reference-type='issue'>foo</a>") + doc = Nokogiri::HTML.fragment("<a class='gfm' href='https://www.gitlab.com' data-reference-type='issue'>foo</a>") redactor.redact([doc]) expect(doc.to_html).to eq('foo') end @@ -35,20 +35,24 @@ describe Banzai::Redactor do context 'when data-original attribute provided' do let(:original_content) { '<code>foo</code>' } it 'replaces redacted reference with original content' do - doc = Nokogiri::HTML.fragment("<a class='gfm' data-reference-type='issue' data-original='#{original_content}'>bar</a>") + doc = Nokogiri::HTML.fragment("<a class='gfm' href='https://www.gitlab.com' data-reference-type='issue' data-original='#{original_content}'>bar</a>") redactor.redact([doc]) expect(doc.to_html).to eq(original_content) end - end - - it 'returns <a> tag with original href if it is originally a link reference' do - href = 'http://localhost:3000' - doc = Nokogiri::HTML - .fragment("<a class='gfm' data-reference-type='issue' data-original=#{href} data-link-reference='true'>#{href}</a>") - redactor.redact([doc]) + it 'does not replace redacted reference with original content if href is given' do + html = "<a href='https://www.gitlab.com' data-link-reference='true' class='gfm' data-reference-type='issue' data-reference-type='issue' data-original='Marge'>Marge</a>" + doc = Nokogiri::HTML.fragment(html) + redactor.redact([doc]) + expect(doc.to_html).to eq('<a href="https://www.gitlab.com">Marge</a>') + end - expect(doc.to_html).to eq('<a href="http://localhost:3000">http://localhost:3000</a>') + it 'uses the original content as the link content if given' do + html = "<a href='https://www.gitlab.com' data-link-reference='true' class='gfm' data-reference-type='issue' data-reference-type='issue' data-original='Homer'>Marge</a>" + doc = Nokogiri::HTML.fragment(html) + redactor.redact([doc]) + expect(doc.to_html).to eq('<a href="https://www.gitlab.com">Homer</a>') + end end end @@ -61,7 +65,7 @@ describe Banzai::Redactor do end it 'redacts an issue attached' do - doc = Nokogiri::HTML.fragment("<a class='gfm' data-reference-type='issue' data-issue='#{issue.id}'>foo</a>") + doc = Nokogiri::HTML.fragment("<a class='gfm' href='https://www.gitlab.com' data-reference-type='issue' data-issue='#{issue.id}'>foo</a>") redactor.redact([doc]) @@ -69,7 +73,7 @@ describe Banzai::Redactor do end it 'redacts an external issue' do - doc = Nokogiri::HTML.fragment("<a class='gfm' data-reference-type='issue' data-external-issue='#{issue.id}' data-project='#{project.id}'>foo</a>") + doc = Nokogiri::HTML.fragment("<a class='gfm' href='https://www.gitlab.com' data-reference-type='issue' data-external-issue='#{issue.id}' data-project='#{project.id}'>foo</a>") redactor.redact([doc]) diff --git a/spec/lib/gitlab/background_migration/reset_merge_status_spec.rb b/spec/lib/gitlab/background_migration/reset_merge_status_spec.rb new file mode 100644 index 00000000000..740781f1aa5 --- /dev/null +++ b/spec/lib/gitlab/background_migration/reset_merge_status_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Gitlab::BackgroundMigration::ResetMergeStatus, :migration, schema: 20190528180441 do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:namespace) { namespaces.create(name: 'gitlab', path: 'gitlab-org') } + let(:project) { projects.create(namespace_id: namespace.id, name: 'foo') } + let(:merge_requests) { table(:merge_requests) } + + def create_merge_request(id, extra_params = {}) + params = { + id: id, + target_project_id: project.id, + target_branch: 'master', + source_project_id: project.id, + source_branch: 'mr name', + title: "mr name#{id}" + }.merge(extra_params) + + merge_requests.create!(params) + end + + it 'correctly updates opened mergeable MRs to unchecked' do + create_merge_request(1, state: 'opened', merge_status: 'can_be_merged') + create_merge_request(2, state: 'opened', merge_status: 'can_be_merged') + create_merge_request(3, state: 'opened', merge_status: 'can_be_merged') + create_merge_request(4, state: 'merged', merge_status: 'can_be_merged') + create_merge_request(5, state: 'opened', merge_status: 'cannot_be_merged') + + subject.perform(1, 5) + + expected_rows = [ + { id: 1, state: 'opened', merge_status: 'unchecked' }, + { id: 2, state: 'opened', merge_status: 'unchecked' }, + { id: 3, state: 'opened', merge_status: 'unchecked' }, + { id: 4, state: 'merged', merge_status: 'can_be_merged' }, + { id: 5, state: 'opened', merge_status: 'cannot_be_merged' } + ] + + rows = merge_requests.order(:id).map do |row| + row.attributes.slice('id', 'state', 'merge_status').symbolize_keys + end + + expect(rows).to eq(expected_rows) + end +end diff --git a/spec/lib/gitlab/bitbucket_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_import/importer_spec.rb index a02c00e3340..2e90f6c7f71 100644 --- a/spec/lib/gitlab/bitbucket_import/importer_spec.rb +++ b/spec/lib/gitlab/bitbucket_import/importer_spec.rb @@ -5,6 +5,7 @@ describe Gitlab::BitbucketImport::Importer do before do stub_omniauth_provider('bitbucket') + stub_feature_flags(stricter_mr_branch_name: false) end let(:statuses) do diff --git a/spec/lib/gitlab/ci/config/entry/service_spec.rb b/spec/lib/gitlab/ci/config/entry/service_spec.rb index d5bd139b5f1..d31866a1987 100644 --- a/spec/lib/gitlab/ci/config/entry/service_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/service_spec.rb @@ -112,6 +112,16 @@ describe Gitlab::Ci::Config::Entry::Service do it 'is valid' do expect(entry).to be_valid end + + context 'when unknown port keys detected' do + let(:ports) { [{ number: 80, invalid_key: 'foo' }] } + + it 'is not valid' do + expect(entry).not_to be_valid + expect(entry.errors.first) + .to match /port config contains unknown keys: invalid_key/ + end + end end describe '#ports' do diff --git a/spec/lib/gitlab/ci/config/external/file/remote_spec.rb b/spec/lib/gitlab/ci/config/external/file/remote_spec.rb index d8a61618e77..46d68097fff 100644 --- a/spec/lib/gitlab/ci/config/external/file/remote_spec.rb +++ b/spec/lib/gitlab/ci/config/external/file/remote_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe Gitlab::Ci::Config::External::File::Remote do + include StubRequests + let(:context) { described_class::Context.new(nil, '12345', nil, Set.new) } let(:params) { { remote: location } } let(:remote_file) { described_class.new(params, context) } @@ -46,7 +48,7 @@ describe Gitlab::Ci::Config::External::File::Remote do describe "#valid?" do context 'when is a valid remote url' do before do - WebMock.stub_request(:get, location).to_return(body: remote_file_content) + stub_full_request(location).to_return(body: remote_file_content) end it 'returns true' do @@ -92,7 +94,7 @@ describe Gitlab::Ci::Config::External::File::Remote do describe "#content" do context 'with a valid remote file' do before do - WebMock.stub_request(:get, location).to_return(body: remote_file_content) + stub_full_request(location).to_return(body: remote_file_content) end it 'returns the content of the file' do @@ -114,7 +116,7 @@ describe Gitlab::Ci::Config::External::File::Remote do let(:location) { 'https://asdasdasdaj48ggerexample.com' } before do - WebMock.stub_request(:get, location).to_raise(SocketError.new('Some HTTP error')) + stub_full_request(location).to_raise(SocketError.new('Some HTTP error')) end it 'is nil' do @@ -144,7 +146,7 @@ describe Gitlab::Ci::Config::External::File::Remote do context 'when timeout error has been raised' do before do - WebMock.stub_request(:get, location).to_timeout + stub_full_request(location).to_timeout end it 'returns error message about a timeout' do @@ -154,7 +156,7 @@ describe Gitlab::Ci::Config::External::File::Remote do context 'when HTTP error has been raised' do before do - WebMock.stub_request(:get, location).to_raise(Gitlab::HTTP::Error) + stub_full_request(location).to_raise(Gitlab::HTTP::Error) end it 'returns error message about a HTTP error' do @@ -164,7 +166,7 @@ describe Gitlab::Ci::Config::External::File::Remote do context 'when response has 404 status' do before do - WebMock.stub_request(:get, location).to_return(body: remote_file_content, status: 404) + stub_full_request(location).to_return(body: remote_file_content, status: 404) end it 'returns error message about a timeout' do diff --git a/spec/lib/gitlab/ci/config/external/mapper_spec.rb b/spec/lib/gitlab/ci/config/external/mapper_spec.rb index 136974569de..e068b786b02 100644 --- a/spec/lib/gitlab/ci/config/external/mapper_spec.rb +++ b/spec/lib/gitlab/ci/config/external/mapper_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe Gitlab::Ci::Config::External::Mapper do + include StubRequests + set(:project) { create(:project, :repository) } set(:user) { create(:user) } @@ -18,7 +20,7 @@ describe Gitlab::Ci::Config::External::Mapper do end before do - WebMock.stub_request(:get, remote_url).to_return(body: file_content) + stub_full_request(remote_url).to_return(body: file_content) end describe '#process' do diff --git a/spec/lib/gitlab/ci/config/external/processor_spec.rb b/spec/lib/gitlab/ci/config/external/processor_spec.rb index 0f58a4f1d44..856187371e1 100644 --- a/spec/lib/gitlab/ci/config/external/processor_spec.rb +++ b/spec/lib/gitlab/ci/config/external/processor_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe Gitlab::Ci::Config::External::Processor do + include StubRequests + set(:project) { create(:project, :repository) } set(:another_project) { create(:project, :repository) } set(:user) { create(:user) } @@ -42,7 +44,7 @@ describe Gitlab::Ci::Config::External::Processor do let(:values) { { include: remote_file, image: 'ruby:2.2' } } before do - WebMock.stub_request(:get, remote_file).to_raise(SocketError.new('Some HTTP error')) + stub_full_request(remote_file).and_raise(SocketError.new('Some HTTP error')) end it 'raises an error' do @@ -75,7 +77,7 @@ describe Gitlab::Ci::Config::External::Processor do end before do - WebMock.stub_request(:get, remote_file).to_return(body: external_file_content) + stub_full_request(remote_file).to_return(body: external_file_content) end it 'appends the file to the values' do @@ -145,7 +147,7 @@ describe Gitlab::Ci::Config::External::Processor do allow_any_instance_of(Gitlab::Ci::Config::External::File::Local) .to receive(:fetch_local_content).and_return(local_file_content) - WebMock.stub_request(:get, remote_file).to_return(body: remote_file_content) + stub_full_request(remote_file).to_return(body: remote_file_content) end it 'appends the files to the values' do @@ -191,7 +193,8 @@ describe Gitlab::Ci::Config::External::Processor do end it 'takes precedence' do - WebMock.stub_request(:get, remote_file).to_return(body: remote_file_content) + stub_full_request(remote_file).to_return(body: remote_file_content) + expect(processor.perform[:image]).to eq('ruby:2.2') end end @@ -231,7 +234,8 @@ describe Gitlab::Ci::Config::External::Processor do HEREDOC end - WebMock.stub_request(:get, 'http://my.domain.com/config.yml').to_return(body: 'remote_build: { script: echo Hello World }') + stub_full_request('http://my.domain.com/config.yml') + .to_return(body: 'remote_build: { script: echo Hello World }') end context 'when project is public' do @@ -273,8 +277,10 @@ describe Gitlab::Ci::Config::External::Processor do context 'when config includes an external configuration file via SSL web request' do before do - stub_request(:get, 'https://sha256.badssl.com/fake.yml').to_return(body: 'image: ruby:2.6', status: 200) - stub_request(:get, 'https://self-signed.badssl.com/fake.yml') + stub_full_request('https://sha256.badssl.com/fake.yml', ip_address: '8.8.8.8') + .to_return(body: 'image: ruby:2.6', status: 200) + + stub_full_request('https://self-signed.badssl.com/fake.yml', ip_address: '8.8.8.9') .to_raise(OpenSSL::SSL::SSLError.new('SSL_connect returned=1 errno=0 state=error: certificate verify failed (self signed certificate)')) end diff --git a/spec/lib/gitlab/ci/config_spec.rb b/spec/lib/gitlab/ci/config_spec.rb index 092e9f242b7..7f336ee853e 100644 --- a/spec/lib/gitlab/ci/config_spec.rb +++ b/spec/lib/gitlab/ci/config_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Gitlab::Ci::Config do + include StubRequests + set(:user) { create(:user) } let(:config) do @@ -216,8 +218,7 @@ describe Gitlab::Ci::Config do end before do - WebMock.stub_request(:get, remote_location) - .to_return(body: remote_file_content) + stub_full_request(remote_location).to_return(body: remote_file_content) allow(project.repository) .to receive(:blob_data_at).and_return(local_file_content) diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index 0d998d89d73..29276d5b686 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' module Gitlab module Ci describe YamlProcessor do + include StubRequests + subject { described_class.new(config, user: nil) } describe '#build_attributes' do @@ -648,7 +650,7 @@ module Gitlab end before do - WebMock.stub_request(:get, 'https://gitlab.com/awesome-project/raw/master/.before-script-template.yml') + stub_full_request('https://gitlab.com/awesome-project/raw/master/.before-script-template.yml') .to_return( status: 200, headers: { 'Content-Type' => 'application/json' }, diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 7644d83992f..cb4701e8edc 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -29,51 +29,6 @@ describe Gitlab::Git::Repository, :seed_helper do let(:storage_path) { TestEnv.repos_path } let(:user) { build(:user) } - describe '.create_hooks' do - let(:repo_path) { File.join(storage_path, 'hook-test.git') } - let(:hooks_dir) { File.join(repo_path, 'hooks') } - let(:target_hooks_dir) { Gitlab::Shell.new.hooks_path } - let(:existing_target) { File.join(repo_path, 'foobar') } - - before do - FileUtils.rm_rf(repo_path) - FileUtils.mkdir_p(repo_path) - end - - context 'hooks is a directory' do - let(:existing_file) { File.join(hooks_dir, 'my-file') } - - before do - FileUtils.mkdir_p(hooks_dir) - FileUtils.touch(existing_file) - described_class.create_hooks(repo_path, target_hooks_dir) - end - - it { expect(File.readlink(hooks_dir)).to eq(target_hooks_dir) } - it { expect(Dir[File.join(repo_path, "hooks.old.*/my-file")].count).to eq(1) } - end - - context 'hooks is a valid symlink' do - before do - FileUtils.mkdir_p existing_target - File.symlink(existing_target, hooks_dir) - described_class.create_hooks(repo_path, target_hooks_dir) - end - - it { expect(File.readlink(hooks_dir)).to eq(target_hooks_dir) } - end - - context 'hooks is a broken symlink' do - before do - FileUtils.rm_f(existing_target) - File.symlink(existing_target, hooks_dir) - described_class.create_hooks(repo_path, target_hooks_dir) - end - - it { expect(File.readlink(hooks_dir)).to eq(target_hooks_dir) } - end - end - describe "Respond to" do subject { repository } @@ -1959,13 +1914,6 @@ describe Gitlab::Git::Repository, :seed_helper do expect { imported_repo.fsck }.not_to raise_exception end - it 'creates a symlink to the global hooks dir' do - imported_repo.create_from_bundle(valid_bundle_path) - hooks_path = Gitlab::GitalyClient::StorageSettings.allow_disk_access { File.join(imported_repo.path, 'hooks') } - - expect(File.readlink(hooks_path)).to eq(Gitlab::Shell.new.hooks_path) - end - it 'raises an error if the bundle is an attempted malicious payload' do expect do imported_repo.create_from_bundle(malicious_bundle_path) diff --git a/spec/lib/gitlab/git_ref_validator_spec.rb b/spec/lib/gitlab/git_ref_validator_spec.rb index 3ab04a1c46d..b63389af29f 100644 --- a/spec/lib/gitlab/git_ref_validator_spec.rb +++ b/spec/lib/gitlab/git_ref_validator_spec.rb @@ -1,31 +1,69 @@ require 'spec_helper' describe Gitlab::GitRefValidator do - it { expect(described_class.validate('feature/new')).to be_truthy } - it { expect(described_class.validate('implement_@all')).to be_truthy } - it { expect(described_class.validate('my_new_feature')).to be_truthy } - it { expect(described_class.validate('my-branch')).to be_truthy } - it { expect(described_class.validate('#1')).to be_truthy } - it { expect(described_class.validate('feature/refs/heads/foo')).to be_truthy } - it { expect(described_class.validate('feature/~new/')).to be_falsey } - it { expect(described_class.validate('feature/^new/')).to be_falsey } - it { expect(described_class.validate('feature/:new/')).to be_falsey } - it { expect(described_class.validate('feature/?new/')).to be_falsey } - it { expect(described_class.validate('feature/*new/')).to be_falsey } - it { expect(described_class.validate('feature/[new/')).to be_falsey } - it { expect(described_class.validate('feature/new/')).to be_falsey } - it { expect(described_class.validate('feature/new.')).to be_falsey } - it { expect(described_class.validate('feature\@{')).to be_falsey } - it { expect(described_class.validate('feature\new')).to be_falsey } - it { expect(described_class.validate('feature//new')).to be_falsey } - it { expect(described_class.validate('feature new')).to be_falsey } - it { expect(described_class.validate('refs/heads/')).to be_falsey } - it { expect(described_class.validate('refs/remotes/')).to be_falsey } - it { expect(described_class.validate('refs/heads/feature')).to be_falsey } - it { expect(described_class.validate('refs/remotes/origin')).to be_falsey } - it { expect(described_class.validate('-')).to be_falsey } - it { expect(described_class.validate('-branch')).to be_falsey } - it { expect(described_class.validate('.tag')).to be_falsey } - it { expect(described_class.validate('my branch')).to be_falsey } - it { expect(described_class.validate("\xA0\u0000\xB0")).to be_falsey } + using RSpec::Parameterized::TableSyntax + + context '.validate' do + it { expect(described_class.validate('feature/new')).to be true } + it { expect(described_class.validate('implement_@all')).to be true } + it { expect(described_class.validate('my_new_feature')).to be true } + it { expect(described_class.validate('my-branch')).to be true } + it { expect(described_class.validate('#1')).to be true } + it { expect(described_class.validate('feature/refs/heads/foo')).to be true } + it { expect(described_class.validate('feature/~new/')).to be false } + it { expect(described_class.validate('feature/^new/')).to be false } + it { expect(described_class.validate('feature/:new/')).to be false } + it { expect(described_class.validate('feature/?new/')).to be false } + it { expect(described_class.validate('feature/*new/')).to be false } + it { expect(described_class.validate('feature/[new/')).to be false } + it { expect(described_class.validate('feature/new/')).to be false } + it { expect(described_class.validate('feature/new.')).to be false } + it { expect(described_class.validate('feature\@{')).to be false } + it { expect(described_class.validate('feature\new')).to be false } + it { expect(described_class.validate('feature//new')).to be false } + it { expect(described_class.validate('feature new')).to be false } + it { expect(described_class.validate('refs/heads/')).to be false } + it { expect(described_class.validate('refs/remotes/')).to be false } + it { expect(described_class.validate('refs/heads/feature')).to be false } + it { expect(described_class.validate('refs/remotes/origin')).to be false } + it { expect(described_class.validate('-')).to be false } + it { expect(described_class.validate('-branch')).to be false } + it { expect(described_class.validate('+foo:bar')).to be false } + it { expect(described_class.validate('foo:bar')).to be false } + it { expect(described_class.validate('.tag')).to be false } + it { expect(described_class.validate('my branch')).to be false } + it { expect(described_class.validate("\xA0\u0000\xB0")).to be false } + end + + context '.validate_merge_request_branch' do + it { expect(described_class.validate_merge_request_branch('HEAD')).to be true } + it { expect(described_class.validate_merge_request_branch('feature/new')).to be true } + it { expect(described_class.validate_merge_request_branch('implement_@all')).to be true } + it { expect(described_class.validate_merge_request_branch('my_new_feature')).to be true } + it { expect(described_class.validate_merge_request_branch('my-branch')).to be true } + it { expect(described_class.validate_merge_request_branch('#1')).to be true } + it { expect(described_class.validate_merge_request_branch('feature/refs/heads/foo')).to be true } + it { expect(described_class.validate_merge_request_branch('feature/~new/')).to be false } + it { expect(described_class.validate_merge_request_branch('feature/^new/')).to be false } + it { expect(described_class.validate_merge_request_branch('feature/:new/')).to be false } + it { expect(described_class.validate_merge_request_branch('feature/?new/')).to be false } + it { expect(described_class.validate_merge_request_branch('feature/*new/')).to be false } + it { expect(described_class.validate_merge_request_branch('feature/[new/')).to be false } + it { expect(described_class.validate_merge_request_branch('feature/new/')).to be false } + it { expect(described_class.validate_merge_request_branch('feature/new.')).to be false } + it { expect(described_class.validate_merge_request_branch('feature\@{')).to be false } + it { expect(described_class.validate_merge_request_branch('feature\new')).to be false } + it { expect(described_class.validate_merge_request_branch('feature//new')).to be false } + it { expect(described_class.validate_merge_request_branch('feature new')).to be false } + it { expect(described_class.validate_merge_request_branch('refs/heads/master')).to be true } + it { expect(described_class.validate_merge_request_branch('refs/heads/')).to be false } + it { expect(described_class.validate_merge_request_branch('refs/remotes/')).to be false } + it { expect(described_class.validate_merge_request_branch('-')).to be false } + it { expect(described_class.validate_merge_request_branch('-branch')).to be false } + it { expect(described_class.validate_merge_request_branch('+foo:bar')).to be false } + it { expect(described_class.validate_merge_request_branch('foo:bar')).to be false } + it { expect(described_class.validate_merge_request_branch('.tag')).to be false } + it { expect(described_class.validate_merge_request_branch('my branch')).to be false } + it { expect(described_class.validate_merge_request_branch("\xA0\u0000\xB0")).to be false } + end end diff --git a/spec/lib/gitlab/graphql/loaders/batch_project_statistics_loader_spec.rb b/spec/lib/gitlab/graphql/loaders/batch_project_statistics_loader_spec.rb new file mode 100644 index 00000000000..ec2fcad31e5 --- /dev/null +++ b/spec/lib/gitlab/graphql/loaders/batch_project_statistics_loader_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader do + describe '#find' do + it 'only queries once for project statistics' do + stats = create_list(:project_statistics, 2) + project1 = stats.first.project + project2 = stats.last.project + + expect do + described_class.new(project1.id).find + described_class.new(project2.id).find + end.not_to exceed_query_limit(1) + end + end +end diff --git a/spec/lib/gitlab/http_connection_adapter_spec.rb b/spec/lib/gitlab/http_connection_adapter_spec.rb new file mode 100644 index 00000000000..930d1f62272 --- /dev/null +++ b/spec/lib/gitlab/http_connection_adapter_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::HTTPConnectionAdapter do + describe '#connection' do + context 'when local requests are not allowed' do + it 'sets up the connection' do + uri = URI('https://example.org') + + connection = described_class.new(uri).connection + + expect(connection).to be_a(Net::HTTP) + expect(connection.address).to eq('93.184.216.34') + expect(connection.hostname_override).to eq('example.org') + expect(connection.addr_port).to eq('example.org') + expect(connection.port).to eq(443) + end + + it 'raises error when it is a request to local address' do + uri = URI('http://172.16.0.0/12') + + expect { described_class.new(uri).connection } + .to raise_error(Gitlab::HTTP::BlockedUrlError, + "URL 'http://172.16.0.0/12' is blocked: Requests to the local network are not allowed") + end + + it 'raises error when it is a request to localhost address' do + uri = URI('http://127.0.0.1') + + expect { described_class.new(uri).connection } + .to raise_error(Gitlab::HTTP::BlockedUrlError, + "URL 'http://127.0.0.1' is blocked: Requests to localhost are not allowed") + end + + context 'when port different from URL scheme is used' do + it 'sets up the addr_port accordingly' do + uri = URI('https://example.org:8080') + + connection = described_class.new(uri).connection + + expect(connection.address).to eq('93.184.216.34') + expect(connection.hostname_override).to eq('example.org') + expect(connection.addr_port).to eq('example.org:8080') + expect(connection.port).to eq(8080) + end + end + end + + context 'when DNS rebinding protection is disabled' do + it 'sets up the connection' do + stub_application_setting(dns_rebinding_protection_enabled: false) + + uri = URI('https://example.org') + + connection = described_class.new(uri).connection + + expect(connection).to be_a(Net::HTTP) + expect(connection.address).to eq('example.org') + expect(connection.hostname_override).to eq(nil) + expect(connection.addr_port).to eq('example.org') + expect(connection.port).to eq(443) + end + end + + context 'when http(s) environment variable is set' do + it 'sets up the connection' do + stub_env('https_proxy' => 'https://my.proxy') + + uri = URI('https://example.org') + + connection = described_class.new(uri).connection + + expect(connection).to be_a(Net::HTTP) + expect(connection.address).to eq('example.org') + expect(connection.hostname_override).to eq(nil) + expect(connection.addr_port).to eq('example.org') + expect(connection.port).to eq(443) + end + end + + context 'when local requests are allowed' do + it 'sets up the connection' do + uri = URI('https://example.org') + + connection = described_class.new(uri, allow_local_requests: true).connection + + expect(connection).to be_a(Net::HTTP) + expect(connection.address).to eq('93.184.216.34') + expect(connection.hostname_override).to eq('example.org') + expect(connection.addr_port).to eq('example.org') + expect(connection.port).to eq(443) + end + + it 'sets up the connection when it is a local network' do + uri = URI('http://172.16.0.0/12') + + connection = described_class.new(uri, allow_local_requests: true).connection + + expect(connection).to be_a(Net::HTTP) + expect(connection.address).to eq('172.16.0.0') + expect(connection.hostname_override).to be(nil) + expect(connection.addr_port).to eq('172.16.0.0') + expect(connection.port).to eq(80) + end + + it 'sets up the connection when it is localhost' do + uri = URI('http://127.0.0.1') + + connection = described_class.new(uri, allow_local_requests: true).connection + + expect(connection).to be_a(Net::HTTP) + expect(connection.address).to eq('127.0.0.1') + expect(connection.hostname_override).to be(nil) + expect(connection.addr_port).to eq('127.0.0.1') + expect(connection.port).to eq(80) + end + end + end +end diff --git a/spec/lib/gitlab/http_spec.rb b/spec/lib/gitlab/http_spec.rb index 6c37c157f5d..158f77cab2c 100644 --- a/spec/lib/gitlab/http_spec.rb +++ b/spec/lib/gitlab/http_spec.rb @@ -1,6 +1,28 @@ require 'spec_helper' describe Gitlab::HTTP do + include StubRequests + + context 'when allow_local_requests' do + it 'sends the request to the correct URI' do + stub_full_request('https://example.org:8080', ip_address: '8.8.8.8').to_return(status: 200) + + described_class.get('https://example.org:8080', allow_local_requests: false) + + expect(WebMock).to have_requested(:get, 'https://8.8.8.8:8080').once + end + end + + context 'when not allow_local_requests' do + it 'sends the request to the correct URI' do + stub_full_request('https://example.org:8080') + + described_class.get('https://example.org:8080', allow_local_requests: true) + + expect(WebMock).to have_requested(:get, 'https://8.8.8.9:8080').once + end + end + describe 'allow_local_requests_from_hooks_and_services is' do before do WebMock.stub_request(:get, /.*/).to_return(status: 200, body: 'Success') @@ -21,6 +43,8 @@ describe Gitlab::HTTP do context 'if allow_local_requests set to true' do it 'override the global value and allow requests to localhost or private network' do + stub_full_request('http://localhost:3003') + expect { described_class.get('http://localhost:3003', allow_local_requests: true) }.not_to raise_error end end @@ -32,6 +56,8 @@ describe Gitlab::HTTP do end it 'allow requests to localhost' do + stub_full_request('http://localhost:3003') + expect { described_class.get('http://localhost:3003') }.not_to raise_error end @@ -49,7 +75,7 @@ describe Gitlab::HTTP do describe 'handle redirect loops' do before do - WebMock.stub_request(:any, "http://example.org").to_raise(HTTParty::RedirectionTooDeep.new("Redirection Too Deep")) + stub_full_request("http://example.org", method: :any).to_raise(HTTParty::RedirectionTooDeep.new("Redirection Too Deep")) end it 'handles GET requests' do diff --git a/spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb b/spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb index 7c4ac62790e..21a227335cd 100644 --- a/spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb +++ b/spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Gitlab::ImportExport::AfterExportStrategies::WebUploadStrategy do + include StubRequests + let(:example_url) { 'http://www.example.com' } let(:strategy) { subject.new(url: example_url, http_method: 'post') } let!(:project) { create(:project, :with_export) } @@ -35,7 +37,7 @@ describe Gitlab::ImportExport::AfterExportStrategies::WebUploadStrategy do context 'when upload fails' do it 'stores the export error' do - stub_request(:post, example_url).to_return(status: [404, 'Page not found']) + stub_full_request(example_url, method: :post).to_return(status: [404, 'Page not found']) strategy.execute(user, project) diff --git a/spec/lib/gitlab/import_export/attribute_cleaner_spec.rb b/spec/lib/gitlab/import_export/attribute_cleaner_spec.rb index 536cc359d39..99669285d5b 100644 --- a/spec/lib/gitlab/import_export/attribute_cleaner_spec.rb +++ b/spec/lib/gitlab/import_export/attribute_cleaner_spec.rb @@ -18,7 +18,11 @@ describe Gitlab::ImportExport::AttributeCleaner do 'notid' => 99, 'import_source' => 'whatever', 'import_type' => 'whatever', - 'non_existent_attr' => 'whatever' + 'non_existent_attr' => 'whatever', + 'some_html' => '<p>dodgy html</p>', + 'legit_html' => '<p>legit html</p>', + '_html' => '<p>perfectly ordinary html</p>', + 'cached_markdown_version' => 12345 } end diff --git a/spec/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json index 4a7accc4c52..fb7bddb386c 100644 --- a/spec/lib/gitlab/import_export/project.json +++ b/spec/lib/gitlab/import_export/project.json @@ -158,6 +158,8 @@ { "id": 351, "note": "Quo reprehenderit aliquam qui dicta impedit cupiditate eligendi.", + "note_html": "<p>something else entirely</p>", + "cached_markdown_version": 917504, "noteable_type": "Issue", "author_id": 26, "created_at": "2016-06-14T15:02:47.770Z", @@ -2363,6 +2365,8 @@ { "id": 671, "note": "Sit voluptatibus eveniet architecto quidem.", + "note_html": "<p>something else entirely</p>", + "cached_markdown_version": 917504, "noteable_type": "MergeRequest", "author_id": 26, "created_at": "2016-06-14T15:02:56.632Z", diff --git a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb index 651aa600fb2..ca46006ea58 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -58,6 +58,26 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do expect(Milestone.find_by_description('test milestone').issues.count).to eq(2) end + context 'when importing a project with cached_markdown_version and note_html' do + context 'for an Issue' do + it 'does not import note_html' do + note_content = 'Quo reprehenderit aliquam qui dicta impedit cupiditate eligendi' + issue_note = Issue.find_by(description: 'Aliquam enim illo et possimus.').notes.select { |n| n.note.match(/#{note_content}/)}.first + + expect(issue_note.note_html).to match(/#{note_content}/) + end + end + + context 'for a Merge Request' do + it 'does not import note_html' do + note_content = 'Sit voluptatibus eveniet architecto quidem' + merge_request_note = MergeRequest.find_by(title: 'MR1').notes.select { |n| n.note.match(/#{note_content}/)}.first + + expect(merge_request_note.note_html).to match(/#{note_content}/) + end + end + end + it 'creates a valid pipeline note' do expect(Ci::Pipeline.find_by_sha('sha-notes').notes).not_to be_empty end diff --git a/spec/lib/gitlab/import_export/repo_restorer_spec.rb b/spec/lib/gitlab/import_export/repo_restorer_spec.rb index 8a699eb1461..e2ffb2adb9b 100644 --- a/spec/lib/gitlab/import_export/repo_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/repo_restorer_spec.rb @@ -34,11 +34,5 @@ describe Gitlab::ImportExport::RepoRestorer do it 'restores the repo successfully' do expect(restorer.restore).to be_truthy end - - it 'has the webhooks' do - restorer.restore - - expect(project_hook_exists?(project)).to be true - end end end diff --git a/spec/lib/gitlab/metrics/samplers/puma_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/puma_sampler_spec.rb index c471c30a194..f4a6e1fc7d9 100644 --- a/spec/lib/gitlab/metrics/samplers/puma_sampler_spec.rb +++ b/spec/lib/gitlab/metrics/samplers/puma_sampler_spec.rb @@ -61,6 +61,33 @@ describe Gitlab::Metrics::Samplers::PumaSampler do end end + context 'with empty worker stats' do + let(:puma_stats) do + <<~EOS + { + "workers": 2, + "phase": 2, + "booted_workers": 2, + "old_workers": 0, + "worker_status": [{ + "pid": 32534, + "index": 0, + "phase": 1, + "booted": true, + "last_checkin": "2019-05-15T07:57:55Z", + "last_status": {} + }] + } + EOS + end + + it 'does not log worker stats' do + expect(subject).not_to receive(:set_worker_metrics) + + subject.sample + end + end + context 'in single mode' do let(:puma_stats) do <<~EOS diff --git a/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb index 7972ff253fe..aaf8c9fa2a0 100644 --- a/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb +++ b/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb @@ -10,17 +10,20 @@ describe Gitlab::Metrics::Samplers::RubySampler do describe '#sample' do it 'samples various statistics' do - expect(Gitlab::Metrics::System).to receive(:memory_usage) + expect(Gitlab::Metrics::System).to receive(:cpu_time) expect(Gitlab::Metrics::System).to receive(:file_descriptor_count) + expect(Gitlab::Metrics::System).to receive(:memory_usage) + expect(Gitlab::Metrics::System).to receive(:process_start_time) + expect(Gitlab::Metrics::System).to receive(:max_open_file_descriptors) expect(sampler).to receive(:sample_gc) sampler.sample end - it 'adds a metric containing the memory usage' do + it 'adds a metric containing the process resident memory bytes' do expect(Gitlab::Metrics::System).to receive(:memory_usage).and_return(9000) - expect(sampler.metrics[:memory_usage]).to receive(:set).with({}, 9000) + expect(sampler.metrics[:process_resident_memory_bytes]).to receive(:set).with({}, 9000) sampler.sample end @@ -34,6 +37,27 @@ describe Gitlab::Metrics::Samplers::RubySampler do sampler.sample end + it 'adds a metric containing the process total cpu time' do + expect(Gitlab::Metrics::System).to receive(:cpu_time).and_return(0.51) + expect(sampler.metrics[:process_cpu_seconds_total]).to receive(:set).with({}, 0.51) + + sampler.sample + end + + it 'adds a metric containing the process start time' do + expect(Gitlab::Metrics::System).to receive(:process_start_time).and_return(12345) + expect(sampler.metrics[:process_start_time_seconds]).to receive(:set).with({}, 12345) + + sampler.sample + end + + it 'adds a metric containing the process max file descriptors' do + expect(Gitlab::Metrics::System).to receive(:max_open_file_descriptors).and_return(1024) + expect(sampler.metrics[:process_max_fds]).to receive(:set).with({}, 1024) + + sampler.sample + end + it 'clears any GC profiles' do expect(GC::Profiler).to receive(:clear) diff --git a/spec/lib/gitlab/metrics/samplers/unicorn_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/unicorn_sampler_spec.rb index 4b03f3c2532..090e456644f 100644 --- a/spec/lib/gitlab/metrics/samplers/unicorn_sampler_spec.rb +++ b/spec/lib/gitlab/metrics/samplers/unicorn_sampler_spec.rb @@ -39,8 +39,8 @@ describe Gitlab::Metrics::Samplers::UnicornSampler do it 'updates metrics type unix and with addr' do labels = { socket_type: 'unix', socket_address: socket_address } - expect(subject).to receive_message_chain(:unicorn_active_connections, :set).with(labels, 'active') - expect(subject).to receive_message_chain(:unicorn_queued_connections, :set).with(labels, 'queued') + expect(subject.metrics[:unicorn_active_connections]).to receive(:set).with(labels, 'active') + expect(subject.metrics[:unicorn_queued_connections]).to receive(:set).with(labels, 'queued') subject.sample end @@ -50,7 +50,6 @@ describe Gitlab::Metrics::Samplers::UnicornSampler do context 'unicorn listens on tcp sockets' do let(:tcp_socket_address) { '0.0.0.0:8080' } let(:tcp_sockets) { [tcp_socket_address] } - before do allow(unicorn).to receive(:listener_names).and_return(tcp_sockets) end @@ -71,13 +70,29 @@ describe Gitlab::Metrics::Samplers::UnicornSampler do it 'updates metrics type unix and with addr' do labels = { socket_type: 'tcp', socket_address: tcp_socket_address } - expect(subject).to receive_message_chain(:unicorn_active_connections, :set).with(labels, 'active') - expect(subject).to receive_message_chain(:unicorn_queued_connections, :set).with(labels, 'queued') + expect(subject.metrics[:unicorn_active_connections]).to receive(:set).with(labels, 'active') + expect(subject.metrics[:unicorn_queued_connections]).to receive(:set).with(labels, 'queued') subject.sample end end end + + context 'additional metrics' do + let(:unicorn_workers) { 2 } + + before do + allow(unicorn).to receive(:listener_names).and_return([""]) + allow(::Gitlab::Metrics::System).to receive(:cpu_time).and_return(3.14) + allow(subject).to receive(:unicorn_workers_count).and_return(unicorn_workers) + end + + it "sets additional metrics" do + expect(subject.metrics[:unicorn_workers]).to receive(:set).with({}, unicorn_workers) + + subject.sample + end + end end describe '#start' do diff --git a/spec/lib/gitlab/metrics/system_spec.rb b/spec/lib/gitlab/metrics/system_spec.rb index 14afcdf5daa..b0603d96eb2 100644 --- a/spec/lib/gitlab/metrics/system_spec.rb +++ b/spec/lib/gitlab/metrics/system_spec.rb @@ -13,6 +13,18 @@ describe Gitlab::Metrics::System do expect(described_class.file_descriptor_count).to be > 0 end end + + describe '.max_open_file_descriptors' do + it 'returns the max allowed open file descriptors' do + expect(described_class.max_open_file_descriptors).to be > 0 + end + end + + describe '.process_start_time' do + it 'returns the process start time' do + expect(described_class.process_start_time).to be > 0 + end + end else describe '.memory_usage' do it 'returns 0.0' do @@ -25,6 +37,18 @@ describe Gitlab::Metrics::System do expect(described_class.file_descriptor_count).to eq(0) end end + + describe '.max_open_file_descriptors' do + it 'returns 0' do + expect(described_class.max_open_file_descriptors).to eq(0) + end + end + + describe 'process_start_time' do + it 'returns 0' do + expect(described_class.process_start_time).to eq(0) + end + end end describe '.cpu_time' do diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index 312aa3be490..3d27156b356 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -256,4 +256,28 @@ describe Gitlab::SearchResults do expect(results.objects('merge_requests')).not_to include merge_request end + + context 'milestones' do + it 'returns correct set of milestones' do + private_project_1 = create(:project, :private) + private_project_2 = create(:project, :private) + internal_project = create(:project, :internal) + public_project_1 = create(:project, :public) + public_project_2 = create(:project, :public, :issues_disabled, :merge_requests_disabled) + private_project_1.add_developer(user) + # milestones that should not be visible + create(:milestone, project: private_project_2, title: 'Private project without access milestone') + create(:milestone, project: public_project_2, title: 'Public project with milestones disabled milestone') + # milestones that should be visible + milestone_1 = create(:milestone, project: private_project_1, title: 'Private project with access milestone', state: 'closed') + milestone_2 = create(:milestone, project: internal_project, title: 'Internal project milestone') + milestone_3 = create(:milestone, project: public_project_1, title: 'Public project with milestones enabled milestone') + # Global search scope takes user authorized projects, internal projects and public projects. + limit_projects = ProjectsFinder.new(current_user: user).execute + + milestones = described_class.new(user, limit_projects, 'milestone').objects('milestones') + + expect(milestones).to match_array([milestone_1, milestone_2, milestone_3]) + end + end end diff --git a/spec/lib/gitlab/shell_spec.rb b/spec/lib/gitlab/shell_spec.rb index e2f09de2808..bce2e754176 100644 --- a/spec/lib/gitlab/shell_spec.rb +++ b/spec/lib/gitlab/shell_spec.rb @@ -612,16 +612,6 @@ describe Gitlab::Shell do FileUtils.rm_rf(created_path) end - it 'creates a repository' do - expect(gitlab_shell.create_repository(repository_storage, repo_name, repo_name)).to be_truthy - - expect(File.stat(created_path).mode & 0o777).to eq(0o770) - - hooks_path = File.join(created_path, 'hooks') - expect(File.lstat(hooks_path)).to be_symlink - expect(File.realpath(hooks_path)).to eq(gitlab_shell_hooks_path) - end - it 'returns false when the command fails' do FileUtils.mkdir_p(File.dirname(created_path)) # This file will block the creation of the repo's .git directory. That diff --git a/spec/lib/gitlab/url_blocker_spec.rb b/spec/lib/gitlab/url_blocker_spec.rb index 445a56ab0d8..253366e0789 100644 --- a/spec/lib/gitlab/url_blocker_spec.rb +++ b/spec/lib/gitlab/url_blocker_spec.rb @@ -2,6 +2,87 @@ require 'spec_helper' describe Gitlab::UrlBlocker do + describe '#validate!' do + context 'when URI is nil' do + let(:import_url) { nil } + + it 'returns no URI and hostname' do + uri, hostname = described_class.validate!(import_url) + + expect(uri).to be(nil) + expect(hostname).to be(nil) + end + end + + context 'when URI is internal' do + let(:import_url) { 'http://localhost' } + + it 'returns URI and no hostname' do + uri, hostname = described_class.validate!(import_url) + + expect(uri).to eq(Addressable::URI.parse('http://[::1]')) + expect(hostname).to eq('localhost') + end + end + + context 'when the URL hostname is a domain' do + let(:import_url) { 'https://example.org' } + + it 'returns URI and hostname' do + uri, hostname = described_class.validate!(import_url) + + expect(uri).to eq(Addressable::URI.parse('https://93.184.216.34')) + expect(hostname).to eq('example.org') + end + end + + context 'when the URL hostname is an IP address' do + let(:import_url) { 'https://93.184.216.34' } + + it 'returns URI and no hostname' do + uri, hostname = described_class.validate!(import_url) + + expect(uri).to eq(Addressable::URI.parse('https://93.184.216.34')) + expect(hostname).to be(nil) + end + end + + context 'disabled DNS rebinding protection' do + context 'when URI is internal' do + let(:import_url) { 'http://localhost' } + + it 'returns URI and no hostname' do + uri, hostname = described_class.validate!(import_url, dns_rebind_protection: false) + + expect(uri).to eq(Addressable::URI.parse('http://localhost')) + expect(hostname).to be(nil) + end + end + + context 'when the URL hostname is a domain' do + let(:import_url) { 'https://example.org' } + + it 'returns URI and no hostname' do + uri, hostname = described_class.validate!(import_url, dns_rebind_protection: false) + + expect(uri).to eq(Addressable::URI.parse('https://example.org')) + expect(hostname).to eq(nil) + end + end + + context 'when the URL hostname is an IP address' do + let(:import_url) { 'https://93.184.216.34' } + + it 'returns URI and no hostname' do + uri, hostname = described_class.validate!(import_url, dns_rebind_protection: false) + + expect(uri).to eq(Addressable::URI.parse('https://93.184.216.34')) + expect(hostname).to be(nil) + end + end + end + end + describe '#blocked_url?' do let(:ports) { Project::VALID_IMPORT_PORTS } @@ -208,7 +289,7 @@ describe Gitlab::UrlBlocker do end def stub_domain_resolv(domain, ip) - address = double(ip_address: ip, ipv4_private?: true, ipv6_link_local?: false, ipv4_loopback?: false, ipv6_loopback?: false) + address = double(ip_address: ip, ipv4_private?: true, ipv6_link_local?: false, ipv4_loopback?: false, ipv6_loopback?: false, ipv4?: false) allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([address]) allow(address).to receive(:ipv6_v4mapped?).and_return(false) end diff --git a/spec/lib/gitlab/url_builder_spec.rb b/spec/lib/gitlab/url_builder_spec.rb index 9f495a5d50b..bbcb92608d8 100644 --- a/spec/lib/gitlab/url_builder_spec.rb +++ b/spec/lib/gitlab/url_builder_spec.rb @@ -32,7 +32,7 @@ describe Gitlab::UrlBuilder do url = described_class.build(milestone) - expect(url).to eq "#{Settings.gitlab['url']}/#{milestone.project.full_path}/milestones/#{milestone.iid}" + expect(url).to eq "#{Settings.gitlab['url']}/#{milestone.project.full_path}/-/milestones/#{milestone.iid}" end end diff --git a/spec/lib/gitlab/url_sanitizer_spec.rb b/spec/lib/gitlab/url_sanitizer_spec.rb index 5861e6955a6..7242255d535 100644 --- a/spec/lib/gitlab/url_sanitizer_spec.rb +++ b/spec/lib/gitlab/url_sanitizer_spec.rb @@ -115,6 +115,40 @@ describe Gitlab::UrlSanitizer do end end + describe '#user' do + context 'credentials in hash' do + it 'overrides URL-provided user' do + sanitizer = described_class.new('http://a:b@example.com', credentials: { user: 'c', password: 'd' }) + + expect(sanitizer.user).to eq('c') + end + end + + context 'credentials in URL' do + where(:url, :user) do + 'http://foo:bar@example.com' | 'foo' + 'http://foo:bar:baz@example.com' | 'foo' + 'http://:bar@example.com' | nil + 'http://foo:@example.com' | 'foo' + 'http://foo@example.com' | 'foo' + 'http://:@example.com' | nil + 'http://@example.com' | nil + 'http://example.com' | nil + + # Other invalid URLs + nil | nil + '' | nil + 'no' | nil + end + + with_them do + subject { described_class.new(url).user } + + it { is_expected.to eq(user) } + end + end + end + describe '#full_url' do context 'credentials in hash' do where(:credentials, :userinfo) do diff --git a/spec/lib/gitlab_spec.rb b/spec/lib/gitlab_spec.rb index 767b5779a79..e075904b0cc 100644 --- a/spec/lib/gitlab_spec.rb +++ b/spec/lib/gitlab_spec.rb @@ -109,4 +109,34 @@ describe Gitlab do expect(described_class.ee?).to eq(false) end end + + describe '.http_proxy_env?' do + it 'returns true when lower case https' do + stub_env('https_proxy', 'https://my.proxy') + + expect(described_class.http_proxy_env?).to eq(true) + end + + it 'returns true when upper case https' do + stub_env('HTTPS_PROXY', 'https://my.proxy') + + expect(described_class.http_proxy_env?).to eq(true) + end + + it 'returns true when lower case http' do + stub_env('http_proxy', 'http://my.proxy') + + expect(described_class.http_proxy_env?).to eq(true) + end + + it 'returns true when upper case http' do + stub_env('HTTP_PROXY', 'http://my.proxy') + + expect(described_class.http_proxy_env?).to eq(true) + end + + it 'returns false when not set' do + expect(described_class.http_proxy_env?).to eq(false) + end + end end diff --git a/spec/lib/mattermost/session_spec.rb b/spec/lib/mattermost/session_spec.rb index 77fea5b2d24..346455067a7 100644 --- a/spec/lib/mattermost/session_spec.rb +++ b/spec/lib/mattermost/session_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe Mattermost::Session, type: :request do include ExclusiveLeaseHelpers + include StubRequests let(:user) { create(:user) } @@ -24,7 +25,7 @@ describe Mattermost::Session, type: :request do let(:location) { 'http://location.tld' } let(:cookie_header) {'MMOAUTH=taskik8az7rq8k6rkpuas7htia; Path=/;'} let!(:stub) do - WebMock.stub_request(:get, "#{mattermost_url}/oauth/gitlab/login") + stub_full_request("#{mattermost_url}/oauth/gitlab/login") .to_return(headers: { 'location' => location, 'Set-Cookie' => cookie_header }, status: 302) end @@ -63,7 +64,7 @@ describe Mattermost::Session, type: :request do end before do - WebMock.stub_request(:get, "#{mattermost_url}/signup/gitlab/complete") + stub_full_request("#{mattermost_url}/signup/gitlab/complete") .with(query: hash_including({ 'state' => state })) .to_return do |request| post "/oauth/token", @@ -80,7 +81,7 @@ describe Mattermost::Session, type: :request do end end - WebMock.stub_request(:post, "#{mattermost_url}/api/v4/users/logout") + stub_full_request("#{mattermost_url}/api/v4/users/logout", method: :post) .to_return(headers: { Authorization: 'token thisworksnow' }, status: 200) end diff --git a/spec/migrations/enqueue_reset_merge_status_spec.rb b/spec/migrations/enqueue_reset_merge_status_spec.rb new file mode 100644 index 00000000000..0d5e33bfd46 --- /dev/null +++ b/spec/migrations/enqueue_reset_merge_status_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20190528180441_enqueue_reset_merge_status.rb') + +describe EnqueueResetMergeStatus, :migration, :sidekiq do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:namespace) { namespaces.create(name: 'gitlab', path: 'gitlab-org') } + let(:project) { projects.create(namespace_id: namespace.id, name: 'foo') } + let(:merge_requests) { table(:merge_requests) } + + def create_merge_request(id, extra_params = {}) + params = { + id: id, + target_project_id: project.id, + target_branch: 'master', + source_project_id: project.id, + source_branch: 'mr name', + title: "mr name#{id}" + }.merge(extra_params) + + merge_requests.create!(params) + end + + it 'correctly schedules background migrations' do + create_merge_request(1, state: 'opened', merge_status: 'can_be_merged') + create_merge_request(2, state: 'opened', merge_status: 'can_be_merged') + create_merge_request(3, state: 'opened', merge_status: 'can_be_merged') + create_merge_request(4, state: 'merged', merge_status: 'can_be_merged') + create_merge_request(5, state: 'opened', merge_status: 'unchecked') + + stub_const("#{described_class.name}::BATCH_SIZE", 2) + + Sidekiq::Testing.fake! do + Timecop.freeze do + migrate! + + expect(described_class::MIGRATION) + .to be_scheduled_delayed_migration(5.minutes, 1, 2) + + expect(described_class::MIGRATION) + .to be_scheduled_delayed_migration(10.minutes, 3, 3) + + expect(BackgroundMigrationWorker.jobs.size).to eq(2) + end + end + end +end diff --git a/spec/migrations/generate_missing_routes_spec.rb b/spec/migrations/generate_missing_routes_spec.rb index 32515d353b0..30ad135d4df 100644 --- a/spec/migrations/generate_missing_routes_spec.rb +++ b/spec/migrations/generate_missing_routes_spec.rb @@ -8,7 +8,7 @@ describe GenerateMissingRoutes, :migration do let(:routes) { table(:routes) } it 'creates routes for projects without a route' do - namespace = namespaces.create!(name: 'GitLab', path: 'gitlab') + namespace = namespaces.create!(name: 'GitLab', path: 'gitlab', type: 'Group') routes.create!( path: 'gitlab', diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb index e6d682c24d9..1ba66565e03 100644 --- a/spec/models/ci/job_artifact_spec.rb +++ b/spec/models/ci/job_artifact_spec.rb @@ -5,10 +5,6 @@ require 'spec_helper' describe Ci::JobArtifact do let(:artifact) { create(:ci_job_artifact, :archive) } - it_behaves_like 'UpdateProjectStatistics' do - subject { build(:ci_job_artifact, :archive, size: 106365) } - end - describe "Associations" do it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:job) } @@ -23,6 +19,10 @@ describe Ci::JobArtifact do it_behaves_like 'having unique enum values' + it_behaves_like 'UpdateProjectStatistics' do + subject { build(:ci_job_artifact, :archive, size: 106365) } + end + describe '.with_reports' do let!(:artifact) { create(:ci_job_artifact, :archive) } diff --git a/spec/models/ci/pipeline_schedule_spec.rb b/spec/models/ci/pipeline_schedule_spec.rb index 42d4769a921..6382be73ea7 100644 --- a/spec/models/ci/pipeline_schedule_spec.rb +++ b/spec/models/ci/pipeline_schedule_spec.rb @@ -48,32 +48,116 @@ describe Ci::PipelineSchedule do end end + describe '.runnable_schedules' do + subject { described_class.runnable_schedules } + + let!(:pipeline_schedule) do + Timecop.freeze(1.day.ago) do + create(:ci_pipeline_schedule, :hourly) + end + end + + it 'returns the runnable schedule' do + is_expected.to eq([pipeline_schedule]) + end + + context 'when there are no runnable schedules' do + let!(:pipeline_schedule) { } + + it 'returns an empty array' do + is_expected.to be_empty + end + end + end + + describe '.preloaded' do + subject { described_class.preloaded } + + before do + create_list(:ci_pipeline_schedule, 3) + end + + it 'preloads the associations' do + subject + + query = ActiveRecord::QueryRecorder.new { subject.each(&:project) } + + expect(query.count).to eq(2) + end + end + describe '#set_next_run_at' do - let!(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly) } + let(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly) } + let(:ideal_next_run_at) { pipeline_schedule.send(:ideal_next_run_at) } + + let(:expected_next_run_at) do + Gitlab::Ci::CronParser.new(Settings.cron_jobs['pipeline_schedule_worker']['cron'], Time.zone.name) + .next_time_from(ideal_next_run_at) + end + + let(:cron_worker_next_run_at) do + Gitlab::Ci::CronParser.new(Settings.cron_jobs['pipeline_schedule_worker']['cron'], Time.zone.name) + .next_time_from(Time.now) + end context 'when creates new pipeline schedule' do - let(:expected_next_run_at) do - Gitlab::Ci::CronParser.new(pipeline_schedule.cron, pipeline_schedule.cron_timezone) - .next_time_from(Time.now) + it 'updates next_run_at automatically' do + expect(pipeline_schedule.next_run_at).to eq(expected_next_run_at) end + end - it 'updates next_run_at automatically' do - expect(described_class.last.next_run_at).to eq(expected_next_run_at) + context 'when PipelineScheduleWorker runs at a specific interval' do + before do + allow(Settings).to receive(:cron_jobs) do + { + 'pipeline_schedule_worker' => { + 'cron' => '0 1 2 3 *' + } + } + end + end + + it "updates next_run_at to the sidekiq worker's execution time" do + expect(pipeline_schedule.next_run_at.min).to eq(0) + expect(pipeline_schedule.next_run_at.hour).to eq(1) + expect(pipeline_schedule.next_run_at.day).to eq(2) + expect(pipeline_schedule.next_run_at.month).to eq(3) end end - context 'when updates cron of exsisted pipeline schedule' do - let(:new_cron) { '0 0 1 1 *' } + context 'when pipeline schedule runs every minute' do + let(:pipeline_schedule) { create(:ci_pipeline_schedule, :every_minute) } - let(:expected_next_run_at) do - Gitlab::Ci::CronParser.new(new_cron, pipeline_schedule.cron_timezone) - .next_time_from(Time.now) + it "updates next_run_at to the sidekiq worker's execution time" do + expect(pipeline_schedule.next_run_at).to eq(cron_worker_next_run_at) + end + end + + context 'when there are two different pipeline schedules in different time zones' do + let(:pipeline_schedule_1) { create(:ci_pipeline_schedule, :weekly, cron_timezone: 'Eastern Time (US & Canada)') } + let(:pipeline_schedule_2) { create(:ci_pipeline_schedule, :weekly, cron_timezone: 'UTC') } + + it 'sets different next_run_at' do + expect(pipeline_schedule_1.next_run_at).not_to eq(pipeline_schedule_2.next_run_at) + end + end + + context 'when there are two different pipeline schedules in the same time zones' do + let(:pipeline_schedule_1) { create(:ci_pipeline_schedule, :weekly, cron_timezone: 'UTC') } + let(:pipeline_schedule_2) { create(:ci_pipeline_schedule, :weekly, cron_timezone: 'UTC') } + + it 'sets the sames next_run_at' do + expect(pipeline_schedule_1.next_run_at).to eq(pipeline_schedule_2.next_run_at) end + end + + context 'when updates cron of exsisted pipeline schedule' do + let(:new_cron) { '0 0 1 1 *' } it 'updates next_run_at automatically' do pipeline_schedule.update!(cron: new_cron) - expect(described_class.last.next_run_at).to eq(expected_next_run_at) + expect(pipeline_schedule.next_run_at).to eq(expected_next_run_at) end end end @@ -83,10 +167,11 @@ describe Ci::PipelineSchedule do context 'when reschedules after 10 days from now' do let(:future_time) { 10.days.from_now } + let(:ideal_next_run_at) { pipeline_schedule.send(:ideal_next_run_at) } let(:expected_next_run_at) do - Gitlab::Ci::CronParser.new(pipeline_schedule.cron, pipeline_schedule.cron_timezone) - .next_time_from(future_time) + Gitlab::Ci::CronParser.new(Settings.cron_jobs['pipeline_schedule_worker']['cron'], Time.zone.name) + .next_time_from(ideal_next_run_at) end it 'points to proper next_run_at' do @@ -99,38 +184,6 @@ describe Ci::PipelineSchedule do end end - describe '#real_next_run' do - subject do - described_class.last.real_next_run(worker_cron: worker_cron, - worker_time_zone: worker_time_zone) - end - - context 'when GitLab time_zone is UTC' do - before do - allow(Time).to receive(:zone) - .and_return(ActiveSupport::TimeZone[worker_time_zone]) - end - - let(:worker_time_zone) { 'UTC' } - - context 'when cron_timezone is Eastern Time (US & Canada)' do - before do - create(:ci_pipeline_schedule, :nightly, - cron_timezone: 'Eastern Time (US & Canada)') - end - - let(:worker_cron) { '0 1 2 3 *' } - - it 'returns the next time worker executes' do - expect(subject.min).to eq(0) - expect(subject.hour).to eq(1) - expect(subject.day).to eq(2) - expect(subject.month).to eq(3) - end - end - end - end - describe '#job_variables' do let!(:pipeline_schedule) { create(:ci_pipeline_schedule) } diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index a0319b3eb0a..a8701f0efa4 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -1381,6 +1381,40 @@ describe Ci::Pipeline, :mailer do end end + describe 'auto merge' do + let(:merge_request) { create(:merge_request, :merge_when_pipeline_succeeds) } + + let(:pipeline) do + create(:ci_pipeline, :running, project: merge_request.source_project, + ref: merge_request.source_branch, + sha: merge_request.diff_head_sha) + end + + before do + merge_request.update_head_pipeline + end + + %w[succeed! drop! cancel! skip!].each do |action| + context "when the pipeline recieved #{action} event" do + it 'performs AutoMergeProcessWorker' do + expect(AutoMergeProcessWorker).to receive(:perform_async).with(merge_request.id) + + pipeline.public_send(action) + end + end + end + + context 'when auto merge is not enabled in the merge request' do + let(:merge_request) { create(:merge_request) } + + it 'performs AutoMergeProcessWorker' do + expect(AutoMergeProcessWorker).not_to receive(:perform_async) + + pipeline.succeed! + end + end + end + def create_build(name, *traits, queued_at: current, started_from: 0, **opts) create(:ci_build, *traits, name: name, diff --git a/spec/models/clusters/applications/knative_spec.rb b/spec/models/clusters/applications/knative_spec.rb index d5974f47190..b38cf96de7e 100644 --- a/spec/models/clusters/applications/knative_spec.rb +++ b/spec/models/clusters/applications/knative_spec.rb @@ -3,9 +3,6 @@ require 'rails_helper' describe Clusters::Applications::Knative do - include KubernetesHelpers - include ReactiveCachingHelpers - let(:knative) { create(:clusters_applications_knative) } include_examples 'cluster application core specs', :clusters_applications_knative @@ -146,77 +143,4 @@ describe Clusters::Applications::Knative do describe 'validations' do it { is_expected.to validate_presence_of(:hostname) } end - - describe '#service_pod_details' do - let(:cluster) { create(:cluster, :project, :provided_by_gcp) } - let(:service) { cluster.platform_kubernetes } - let(:knative) { create(:clusters_applications_knative, cluster: cluster) } - - let(:namespace) do - create(:cluster_kubernetes_namespace, - cluster: cluster, - cluster_project: cluster.cluster_project, - project: cluster.cluster_project.project) - end - - before do - stub_kubeclient_discover(service.api_url) - stub_kubeclient_knative_services - stub_kubeclient_service_pods - stub_reactive_cache(knative, - { - services: kube_response(kube_knative_services_body), - pods: kube_response(kube_knative_pods_body(cluster.cluster_project.project.name, namespace.namespace)) - }) - synchronous_reactive_cache(knative) - end - - it 'is able k8s core for pod details' do - expect(knative.service_pod_details(namespace.namespace, cluster.cluster_project.project.name)).not_to be_nil - end - end - - describe '#services' do - let(:cluster) { create(:cluster, :project, :provided_by_gcp) } - let(:service) { cluster.platform_kubernetes } - let(:knative) { create(:clusters_applications_knative, cluster: cluster) } - - let(:namespace) do - create(:cluster_kubernetes_namespace, - cluster: cluster, - cluster_project: cluster.cluster_project, - project: cluster.cluster_project.project) - end - - subject { knative.services } - - before do - stub_kubeclient_discover(service.api_url) - stub_kubeclient_knative_services - stub_kubeclient_service_pods - end - - it 'has an unintialized cache' do - is_expected.to be_nil - end - - context 'when using synchronous reactive cache' do - before do - stub_reactive_cache(knative, - { - services: kube_response(kube_knative_services_body), - pods: kube_response(kube_knative_pods_body(cluster.cluster_project.project.name, namespace.namespace)) - }) - synchronous_reactive_cache(knative) - end - - it 'has cached services' do - is_expected.not_to be_nil - end - - it 'matches our namespace' do - expect(knative.services_for(ns: namespace)).not_to be_nil - end - end - end end diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb index 4739e62289a..f206bb41f45 100644 --- a/spec/models/clusters/cluster_spec.rb +++ b/spec/models/clusters/cluster_spec.rb @@ -38,6 +38,11 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do it { is_expected.to respond_to :project } + it do + expect(subject.knative_services_finder(subject.project)) + .to be_instance_of(Clusters::KnativeServicesFinder) + end + describe '.enabled' do subject { described_class.enabled } diff --git a/spec/models/concerns/noteable_spec.rb b/spec/models/concerns/noteable_spec.rb index ee613b199ad..e17b98536fa 100644 --- a/spec/models/concerns/noteable_spec.rb +++ b/spec/models/concerns/noteable_spec.rb @@ -260,4 +260,16 @@ describe Noteable do end end end + + describe '.replyable_types' do + it 'exposes the replyable types' do + expect(described_class.replyable_types).to include('Issue', 'MergeRequest') + end + end + + describe '.resolvable_types' do + it 'exposes the replyable types' do + expect(described_class.resolvable_types).to include('MergeRequest') + end + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index c72b6e9033d..956c5675f38 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -173,6 +173,42 @@ describe MergeRequest do end end + context 'for branch' do + before do + stub_feature_flags(stricter_mr_branch_name: false) + end + + using RSpec::Parameterized::TableSyntax + + where(:branch_name, :valid) do + 'foo' | true + 'foo:bar' | false + '+foo:bar' | false + 'foo bar' | false + '-foo' | false + 'HEAD' | true + 'refs/heads/master' | true + end + + with_them do + it "validates source_branch" do + subject = build(:merge_request, source_branch: branch_name, target_branch: 'master') + + subject.valid? + + expect(subject.errors.added?(:source_branch)).to eq(!valid) + end + + it "validates target_branch" do + subject = build(:merge_request, source_branch: 'master', target_branch: branch_name) + + subject.valid? + + expect(subject.errors.added?(:target_branch)).to eq(!valid) + end + end + end + context 'for forks' do let(:project) { create(:project) } let(:fork1) { fork_project(project) } @@ -1038,14 +1074,28 @@ describe MergeRequest do end end - describe "#reset_merge_when_pipeline_succeeds" do + describe "#auto_merge_strategy" do + subject { merge_request.auto_merge_strategy } + + let(:merge_request) { create(:merge_request, :merge_when_pipeline_succeeds) } + + it { is_expected.to eq('merge_when_pipeline_succeeds') } + + context 'when auto merge is disabled' do + let(:merge_request) { create(:merge_request) } + + it { is_expected.to be_nil } + end + end + + describe "#reset_auto_merge" do let(:merge_if_green) do create :merge_request, merge_when_pipeline_succeeds: true, merge_user: create(:user), merge_params: { "should_remove_source_branch" => "1", "commit_message" => "msg" } end it "sets the item to false" do - merge_if_green.reset_merge_when_pipeline_succeeds + merge_if_green.reset_auto_merge merge_if_green.reload expect(merge_if_green.merge_when_pipeline_succeeds).to be_falsey @@ -1962,57 +2012,6 @@ describe MergeRequest do end end - describe '#check_if_can_be_merged' do - let(:project) { create(:project, only_allow_merge_if_pipeline_succeeds: true) } - - shared_examples 'checking if can be merged' do - context 'when it is not broken and has no conflicts' do - before do - allow(subject).to receive(:broken?) { false } - allow(project.repository).to receive(:can_be_merged?).and_return(true) - end - - it 'is marked as mergeable' do - expect { subject.check_if_can_be_merged }.to change { subject.merge_status }.to('can_be_merged') - end - end - - context 'when broken' do - before do - allow(subject).to receive(:broken?) { true } - allow(project.repository).to receive(:can_be_merged?).and_return(false) - end - - it 'becomes unmergeable' do - expect { subject.check_if_can_be_merged }.to change { subject.merge_status }.to('cannot_be_merged') - end - end - - context 'when it has conflicts' do - before do - allow(subject).to receive(:broken?) { false } - allow(project.repository).to receive(:can_be_merged?).and_return(false) - end - - it 'becomes unmergeable' do - expect { subject.check_if_can_be_merged }.to change { subject.merge_status }.to('cannot_be_merged') - end - end - end - - context 'when merge_status is unchecked' do - subject { create(:merge_request, source_project: project, merge_status: :unchecked) } - - it_behaves_like 'checking if can be merged' - end - - context 'when merge_status is unchecked' do - subject { create(:merge_request, source_project: project, merge_status: :cannot_be_merged_recheck) } - - it_behaves_like 'checking if can be merged' - end - end - describe '#mergeable?' do let(:project) { create(:project) } @@ -2026,7 +2025,7 @@ describe MergeRequest do it 'return true if #mergeable_state? is true and the MR #can_be_merged? is true' do allow(subject).to receive(:mergeable_state?) { true } - expect(subject).to receive(:check_if_can_be_merged) + expect(subject).to receive(:check_mergeability) expect(subject).to receive(:can_be_merged?) { true } expect(subject.mergeable?).to be_truthy @@ -2040,7 +2039,7 @@ describe MergeRequest do it 'checks if merge request can be merged' do allow(subject).to receive(:mergeable_ci_state?) { true } - expect(subject).to receive(:check_if_can_be_merged) + expect(subject).to receive(:check_mergeability) subject.mergeable? end @@ -3108,38 +3107,6 @@ describe MergeRequest do end end - describe '#mergeable_to_ref?' do - it 'returns true when merge request is mergeable' do - subject = create(:merge_request) - - expect(subject.mergeable_to_ref?).to be(true) - end - - it 'returns false when merge request is already merged' do - subject = create(:merge_request, :merged) - - expect(subject.mergeable_to_ref?).to be(false) - end - - it 'returns false when merge request is closed' do - subject = create(:merge_request, :closed) - - expect(subject.mergeable_to_ref?).to be(false) - end - - it 'returns false when merge request is work in progress' do - subject = create(:merge_request, title: 'WIP: The feature') - - expect(subject.mergeable_to_ref?).to be(false) - end - - it 'returns false when merge request has no commits' do - subject = create(:merge_request, source_branch: 'empty-branch', target_branch: 'master') - - expect(subject.mergeable_to_ref?).to be(false) - end - end - describe '#merge_participants' do it 'contains author' do expect(subject.merge_participants).to eq([subject.author]) diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb index e47f2de1ae9..3704a2d468d 100644 --- a/spec/models/milestone_spec.rb +++ b/spec/models/milestone_spec.rb @@ -498,4 +498,20 @@ describe Milestone do end end end + + describe '.reference_pattern' do + subject { described_class.reference_pattern } + + it { is_expected.to match('gitlab-org/gitlab-ce%123') } + it { is_expected.to match('gitlab-org/gitlab-ce%"my-milestone"') } + end + + describe '.link_reference_pattern' do + subject { described_class.link_reference_pattern } + + it { is_expected.to match("#{Gitlab.config.gitlab.url}/gitlab-org/gitlab-ce/milestones/123") } + it { is_expected.to match("#{Gitlab.config.gitlab.url}/gitlab-org/gitlab-ce/-/milestones/123") } + it { is_expected.not_to match("#{Gitlab.config.gitlab.url}/gitlab-org/gitlab-ce/issues/123") } + it { is_expected.not_to match("gitlab-org/gitlab-ce/milestones/123") } + end end diff --git a/spec/models/project_services/assembla_service_spec.rb b/spec/models/project_services/assembla_service_spec.rb index 7742e33e901..2c86c0ec7be 100644 --- a/spec/models/project_services/assembla_service_spec.rb +++ b/spec/models/project_services/assembla_service_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe AssemblaService do + include StubRequests + describe "Associations" do it { is_expected.to belong_to :project } it { is_expected.to have_one :service_hook } @@ -23,12 +25,12 @@ describe AssemblaService do ) @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user) @api_url = 'https://atlas.assembla.com/spaces/project_name/github_tool?secret_key=verySecret' - WebMock.stub_request(:post, @api_url) + stub_full_request(@api_url, method: :post) end it "calls Assembla API" do @assembla_service.execute(@sample_data) - expect(WebMock).to have_requested(:post, @api_url).with( + expect(WebMock).to have_requested(:post, stubbed_hostname(@api_url)).with( body: /#{@sample_data[:before]}.*#{@sample_data[:after]}.*#{project.path}/ ).once end diff --git a/spec/models/project_services/bamboo_service_spec.rb b/spec/models/project_services/bamboo_service_spec.rb index 08c510f09df..65d227a17f9 100644 --- a/spec/models/project_services/bamboo_service_spec.rb +++ b/spec/models/project_services/bamboo_service_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' describe BambooService, :use_clean_rails_memory_store_caching do include ReactiveCachingHelpers + include StubRequests let(:bamboo_url) { 'http://gitlab.com/bamboo' } @@ -257,7 +258,7 @@ describe BambooService, :use_clean_rails_memory_store_caching do end def stub_bamboo_request(url, status, body) - WebMock.stub_request(:get, url).to_return( + stub_full_request(url).to_return( status: status, headers: { 'Content-Type' => 'application/json' }, body: body diff --git a/spec/models/project_services/buildkite_service_spec.rb b/spec/models/project_services/buildkite_service_spec.rb index 091d4d8f695..ca196069055 100644 --- a/spec/models/project_services/buildkite_service_spec.rb +++ b/spec/models/project_services/buildkite_service_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' describe BuildkiteService, :use_clean_rails_memory_store_caching do include ReactiveCachingHelpers + include StubRequests let(:project) { create(:project) } @@ -110,10 +111,9 @@ describe BuildkiteService, :use_clean_rails_memory_store_caching do body ||= %q({"status":"success"}) buildkite_full_url = 'https://gitlab.buildkite.com/status/secret-sauce-status-token.json?commit=123' - WebMock.stub_request(:get, buildkite_full_url).to_return( - status: status, - headers: { 'Content-Type' => 'application/json' }, - body: body - ) + stub_full_request(buildkite_full_url) + .to_return(status: status, + headers: { 'Content-Type' => 'application/json' }, + body: body) end end diff --git a/spec/models/project_services/campfire_service_spec.rb b/spec/models/project_services/campfire_service_spec.rb index bf4c52fc7ab..0d3dd89e93b 100644 --- a/spec/models/project_services/campfire_service_spec.rb +++ b/spec/models/project_services/campfire_service_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe CampfireService do + include StubRequests + describe 'Associations' do it { is_expected.to belong_to :project } it { is_expected.to have_one :service_hook } @@ -49,39 +51,37 @@ describe CampfireService do it "calls Campfire API to get a list of rooms and speak in a room" do # make sure a valid list of rooms is returned body = File.read(Rails.root + 'spec/fixtures/project_services/campfire/rooms.json') - WebMock.stub_request(:get, @rooms_url).with(basic_auth: @auth).to_return( + + stub_full_request(@rooms_url).with(basic_auth: @auth).to_return( body: body, status: 200, headers: @headers ) + # stub the speak request with the room id found in the previous request's response speak_url = 'https://project-name.campfirenow.com/room/123/speak.json' - WebMock.stub_request(:post, speak_url).with(basic_auth: @auth) + stub_full_request(speak_url, method: :post).with(basic_auth: @auth) @campfire_service.execute(@sample_data) - expect(WebMock).to have_requested(:get, @rooms_url).once - expect(WebMock).to have_requested(:post, speak_url).with( - body: /#{project.path}.*#{@sample_data[:before]}.*#{@sample_data[:after]}/ - ).once + expect(WebMock).to have_requested(:get, stubbed_hostname(@rooms_url)).once + expect(WebMock).to have_requested(:post, stubbed_hostname(speak_url)) + .with(body: /#{project.path}.*#{@sample_data[:before]}.*#{@sample_data[:after]}/).once end it "calls Campfire API to get a list of rooms but shouldn't speak in a room" do # return a list of rooms that do not contain a room named 'test-room' body = File.read(Rails.root + 'spec/fixtures/project_services/campfire/rooms2.json') - WebMock.stub_request(:get, @rooms_url).with(basic_auth: @auth).to_return( + stub_full_request(@rooms_url).with(basic_auth: @auth).to_return( body: body, status: 200, headers: @headers ) - # we want to make sure no request is sent to the /speak endpoint, here is a basic - # regexp that matches this endpoint - speak_url = 'https://verySecret:X@project-name.campfirenow.com/room/.*/speak.json' @campfire_service.execute(@sample_data) - expect(WebMock).to have_requested(:get, @rooms_url).once - expect(WebMock).not_to have_requested(:post, /#{speak_url}/) + expect(WebMock).to have_requested(:get, 'https://8.8.8.9/rooms.json').once + expect(WebMock).not_to have_requested(:post, '*/room/.*/speak.json') end end end diff --git a/spec/models/project_services/pivotaltracker_service_spec.rb b/spec/models/project_services/pivotaltracker_service_spec.rb index 773b8b7890f..dde46c82df6 100644 --- a/spec/models/project_services/pivotaltracker_service_spec.rb +++ b/spec/models/project_services/pivotaltracker_service_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe PivotaltrackerService do + include StubRequests + describe 'Associations' do it { is_expected.to belong_to :project } it { is_expected.to have_one :service_hook } @@ -53,12 +55,12 @@ describe PivotaltrackerService do end before do - WebMock.stub_request(:post, url) + stub_full_request(url, method: :post) end it 'posts correct message' do service.execute(push_data) - expect(WebMock).to have_requested(:post, url).with( + expect(WebMock).to have_requested(:post, stubbed_hostname(url)).with( body: { 'source_commit' => { 'commit_id' => '21c12ea', @@ -85,14 +87,14 @@ describe PivotaltrackerService do service.execute(push_data(branch: 'master')) service.execute(push_data(branch: 'v10')) - expect(WebMock).to have_requested(:post, url).twice + expect(WebMock).to have_requested(:post, stubbed_hostname(url)).twice end it 'does not post message if branch is not in the list' do service.execute(push_data(branch: 'mas')) service.execute(push_data(branch: 'v11')) - expect(WebMock).not_to have_requested(:post, url) + expect(WebMock).not_to have_requested(:post, stubbed_hostname(url)) end end end diff --git a/spec/models/project_services/pushover_service_spec.rb b/spec/models/project_services/pushover_service_spec.rb index d2a45f48705..380f02739bc 100644 --- a/spec/models/project_services/pushover_service_spec.rb +++ b/spec/models/project_services/pushover_service_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe PushoverService do + include StubRequests + describe 'Associations' do it { is_expected.to belong_to :project } it { is_expected.to have_one :service_hook } @@ -57,13 +59,13 @@ describe PushoverService do sound: sound ) - WebMock.stub_request(:post, api_url) + stub_full_request(api_url, method: :post, ip_address: '8.8.8.8') end it 'calls Pushover API' do pushover.execute(sample_data) - expect(WebMock).to have_requested(:post, api_url).once + expect(WebMock).to have_requested(:post, 'https://8.8.8.8/1/messages.json').once end end end diff --git a/spec/models/project_services/teamcity_service_spec.rb b/spec/models/project_services/teamcity_service_spec.rb index 96dccae733b..1c434b25205 100644 --- a/spec/models/project_services/teamcity_service_spec.rb +++ b/spec/models/project_services/teamcity_service_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' describe TeamcityService, :use_clean_rails_memory_store_caching do include ReactiveCachingHelpers + include StubRequests let(:teamcity_url) { 'http://gitlab.com/teamcity' } @@ -212,7 +213,7 @@ describe TeamcityService, :use_clean_rails_memory_store_caching do body ||= %Q({"build":{"status":"#{build_status}","id":"666"}}) - WebMock.stub_request(:get, teamcity_full_url).with(basic_auth: auth).to_return( + stub_full_request(teamcity_full_url).with(basic_auth: auth).to_return( status: status, headers: { 'Content-Type' => 'application/json' }, body: body diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 08662231fdf..aad08b9d4aa 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1147,7 +1147,7 @@ describe Project do allow(project).to receive(:avatar_in_git) { true } end - let(:avatar_path) { "/#{project.full_path}/avatar" } + let(:avatar_path) { "/#{project.full_path}/-/avatar" } it { is_expected.to eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } end @@ -3170,6 +3170,23 @@ describe Project do end end + describe '.ids_with_milestone_available_for' do + let!(:user) { create(:user) } + + it 'returns project ids with milestones available for user' do + project_1 = create(:project, :public, :merge_requests_disabled, :issues_disabled) + project_2 = create(:project, :public, :merge_requests_disabled) + project_3 = create(:project, :public, :issues_disabled) + project_4 = create(:project, :public) + project_4.project_feature.update(issues_access_level: ProjectFeature::PRIVATE, merge_requests_access_level: ProjectFeature::PRIVATE ) + + project_ids = described_class.ids_with_milestone_available_for(user).pluck(:id) + + expect(project_ids).to include(project_2.id, project_3.id) + expect(project_ids).not_to include(project_1.id, project_4.id) + end + end + describe '.with_feature_available_for_user' do let(:user) { create(:user) } let(:feature) { MergeRequest } diff --git a/spec/models/project_statistics_spec.rb b/spec/models/project_statistics_spec.rb index f985c114d4b..358873f9a2f 100644 --- a/spec/models/project_statistics_spec.rb +++ b/spec/models/project_statistics_spec.rb @@ -11,6 +11,20 @@ describe ProjectStatistics do it { is_expected.to belong_to(:namespace) } end + describe 'scopes' do + describe '.for_project_ids' do + it 'returns only requested projects' do + stats = create_list(:project_statistics, 3) + project_ids = stats[0..1].map { |s| s.project_id } + expected_ids = stats[0..1].map { |s| s.id } + + requested_stats = described_class.for_project_ids(project_ids).pluck(:id) + + expect(requested_stats).to eq(expected_ids) + end + end + end + describe 'statistics columns' do it "support values up to 8 exabytes" do statistics.update!( diff --git a/spec/presenters/issue_presenter_spec.rb b/spec/presenters/issue_presenter_spec.rb new file mode 100644 index 00000000000..8e24559341b --- /dev/null +++ b/spec/presenters/issue_presenter_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe IssuePresenter do + include Gitlab::Routing.url_helpers + + let(:user) { create(:user) } + let(:group) { create(:group) } + let(:project) { create(:project, group: group) } + let(:issue) { create(:issue, project: project) } + let(:presenter) { described_class.new(issue, current_user: user) } + + before do + group.add_developer(user) + end + + describe '#web_url' do + it 'returns correct path' do + expect(presenter.web_url).to eq "http://localhost/#{group.name}/#{project.name}/issues/#{issue.iid}" + end + end + + describe '#issue_path' do + it 'returns correct path' do + expect(presenter.issue_path).to eq "/#{group.name}/#{project.name}/issues/#{issue.iid}" + end + end +end diff --git a/spec/presenters/merge_request_presenter_spec.rb b/spec/presenters/merge_request_presenter_spec.rb index 0e1aed42cc5..6408b0bd748 100644 --- a/spec/presenters/merge_request_presenter_spec.rb +++ b/spec/presenters/merge_request_presenter_spec.rb @@ -207,25 +207,25 @@ describe MergeRequestPresenter do end end - describe '#cancel_merge_when_pipeline_succeeds_path' do + describe '#cancel_auto_merge_path' do subject do described_class.new(resource, current_user: user) - .cancel_merge_when_pipeline_succeeds_path + .cancel_auto_merge_path end context 'when can cancel mwps' do it 'returns path' do - allow(resource).to receive(:can_cancel_merge_when_pipeline_succeeds?) + allow(resource).to receive(:can_cancel_auto_merge?) .with(user) .and_return(true) - is_expected.to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}/cancel_merge_when_pipeline_succeeds") + is_expected.to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}/cancel_auto_merge") end end context 'when cannot cancel mwps' do it 'returns nil' do - allow(resource).to receive(:can_cancel_merge_when_pipeline_succeeds?) + allow(resource).to receive(:can_cancel_auto_merge?) .with(user) .and_return(false) diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index a132b85b878..f104da6ebba 100644 --- a/spec/requests/api/commits_spec.rb +++ b/spec/requests/api/commits_spec.rb @@ -2,6 +2,8 @@ require 'spec_helper' require 'mime/types' describe API::Commits do + include ProjectForksHelper + let(:user) { create(:user) } let(:guest) { create(:user).tap { |u| project.add_guest(u) } } let(:project) { create(:project, :repository, creator: user, path: 'my.project') } @@ -317,6 +319,96 @@ describe API::Commits do expect(response).to have_gitlab_http_status(201) end end + + context 'when the API user is a guest' do + def last_commit_id(project, branch_name) + project.repository.find_branch(branch_name)&.dereferenced_target&.id + end + + let(:public_project) { create(:project, :public, :repository) } + let!(:url) { "/projects/#{public_project.id}/repository/commits" } + let(:guest) { create(:user).tap { |u| public_project.add_guest(u) } } + + it 'returns a 403' do + post api(url, guest), params: valid_c_params + + expect(response).to have_gitlab_http_status(403) + end + + context 'when start_project is provided' do + context 'when posting to a forked project the user owns' do + let!(:forked_project) { fork_project(public_project, guest, namespace: guest.namespace, repository: true) } + let!(:url) { "/projects/#{forked_project.id}/repository/commits" } + + before do + valid_c_params[:start_branch] = "master" + valid_c_params[:branch] = "patch" + end + + context 'identified by Integer (id)' do + before do + valid_c_params[:start_project] = public_project.id + end + + it 'adds a new commit to forked_project and returns a 201' do + expect { post api(url, guest), params: valid_c_params } + .to change { last_commit_id(forked_project, valid_c_params[:branch]) } + .and not_change { last_commit_id(public_project, valid_c_params[:start_branch]) } + + expect(response).to have_gitlab_http_status(201) + end + end + + context 'identified by String (full_path)' do + before do + valid_c_params[:start_project] = public_project.full_path + end + + it 'adds a new commit to forked_project and returns a 201' do + expect { post api(url, guest), params: valid_c_params } + .to change { last_commit_id(forked_project, valid_c_params[:branch]) } + .and not_change { last_commit_id(public_project, valid_c_params[:start_branch]) } + + expect(response).to have_gitlab_http_status(201) + end + end + end + + context 'when the target project is not part of the fork network of start_project' do + let(:unrelated_project) { create(:project, :public, :repository, creator: guest) } + let!(:url) { "/projects/#{unrelated_project.id}/repository/commits" } + + before do + valid_c_params[:start_branch] = "master" + valid_c_params[:branch] = "patch" + valid_c_params[:start_project] = public_project.id + end + + it 'returns a 403' do + post api(url, guest), params: valid_c_params + + expect(response).to have_gitlab_http_status(403) + end + end + end + + context 'when posting to a forked project the user does not have write access' do + let!(:forked_project) { fork_project(public_project, user, namespace: user.namespace, repository: true) } + let!(:url) { "/projects/#{forked_project.id}/repository/commits" } + + before do + valid_c_params[:start_branch] = "master" + valid_c_params[:branch] = "patch" + valid_c_params[:start_project] = public_project.id + end + + it 'returns a 403' do + post api(url, guest), params: valid_c_params + + expect(response).to have_gitlab_http_status(403) + end + end + end end describe 'delete' do diff --git a/spec/requests/api/graphql/group_query_spec.rb b/spec/requests/api/graphql/group_query_spec.rb index 8ff95cc9af2..db9f2ac9dd0 100644 --- a/spec/requests/api/graphql/group_query_spec.rb +++ b/spec/requests/api/graphql/group_query_spec.rb @@ -86,17 +86,18 @@ describe 'getting group information' do end it 'avoids N+1 queries' do - post_graphql(group_query(group1), current_user: admin) - control_count = ActiveRecord::QueryRecorder.new do post_graphql(group_query(group1), current_user: admin) end.count - create(:project, namespace: group1) + queries = [{ query: group_query(group1) }, + { query: group_query(group2) }] expect do - post_graphql(group_query(group1), current_user: admin) + post_multiplex(queries, current_user: admin) end.not_to exceed_query_limit(control_count) + + expect(graphql_errors).to contain_exactly(nil, nil) end end diff --git a/spec/requests/api/graphql/namespace/projects_spec.rb b/spec/requests/api/graphql/namespace/projects_spec.rb new file mode 100644 index 00000000000..e05273da4bd --- /dev/null +++ b/spec/requests/api/graphql/namespace/projects_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'getting projects', :nested_groups do + include GraphqlHelpers + + let(:group) { create(:group) } + let!(:project) { create(:project, namespace: subject) } + let(:nested_group) { create(:group, parent: group) } + let!(:nested_project) { create(:project, group: nested_group) } + let!(:public_project) { create(:project, :public, namespace: subject) } + let(:user) { create(:user) } + let(:include_subgroups) { true } + + subject { group } + + let(:query) do + graphql_query_for( + 'namespace', + { 'fullPath' => subject.full_path }, + <<~QUERY + projects(includeSubgroups: #{include_subgroups}) { + edges { + node { + #{all_graphql_fields_for('Project')} + } + } + } + QUERY + ) + end + + before do + group.add_owner(user) + end + + shared_examples 'a graphql namespace' do + it_behaves_like 'a working graphql query' do + before do + post_graphql(query, current_user: user) + end + end + + it "includes the packages size if the user can read the statistics" do + post_graphql(query, current_user: user) + + count = if include_subgroups + subject.all_projects.count + else + subject.projects.count + end + + expect(graphql_data['namespace']['projects']['edges'].size).to eq(count) + end + + context 'with no user' do + it 'finds only public projects' do + post_graphql(query, current_user: nil) + + expect(graphql_data['namespace']['projects']['edges'].size).to eq(1) + project = graphql_data['namespace']['projects']['edges'][0]['node'] + expect(project['id']).to eq(public_project.id.to_s) + end + end + end + + it_behaves_like 'a graphql namespace' + + context 'when the namespace is a user' do + subject { user.namespace } + let(:include_subgroups) { false } + + it_behaves_like 'a graphql namespace' + end + + context 'when not including subgroups' do + let(:include_subgroups) { false } + + it_behaves_like 'a graphql namespace' + end +end diff --git a/spec/requests/api/graphql/project/project_statistics_spec.rb b/spec/requests/api/graphql/project/project_statistics_spec.rb new file mode 100644 index 00000000000..8683fa1f390 --- /dev/null +++ b/spec/requests/api/graphql/project/project_statistics_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'rendering namespace statistics' do + include GraphqlHelpers + + let(:project) { create(:project) } + let!(:project_statistics) { create(:project_statistics, project: project, packages_size: 5.megabytes) } + let(:user) { create(:user) } + + let(:query) do + graphql_query_for('project', + { 'fullPath' => project.full_path }, + "statistics { #{all_graphql_fields_for('ProjectStatistics')} }") + end + + before do + project.add_reporter(user) + end + + it_behaves_like 'a working graphql query' do + before do + post_graphql(query, current_user: user) + end + end + + it "includes the packages size if the user can read the statistics" do + post_graphql(query, current_user: user) + + expect(graphql_data['project']['statistics']['packagesSize']).to eq(5.megabytes) + end + + context 'when the project is public' do + let(:project) { create(:project, :public) } + + it 'includes the statistics regardless of the user' do + post_graphql(query, current_user: nil) + + expect(graphql_data['project']['statistics']).to be_present + end + end +end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 5c94a87529b..4cb4fcc890d 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -1473,7 +1473,7 @@ describe API::MergeRequests do end it "enables merge when pipeline succeeds if the pipeline is active" do - allow_any_instance_of(MergeRequest).to receive(:head_pipeline).and_return(pipeline) + allow_any_instance_of(MergeRequest).to receive_messages(head_pipeline: pipeline, actual_head_pipeline: pipeline) allow(pipeline).to receive(:active?).and_return(true) put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user), params: { merge_when_pipeline_succeeds: true } @@ -1484,7 +1484,7 @@ describe API::MergeRequests do end it "enables merge when pipeline succeeds if the pipeline is active and only_allow_merge_if_pipeline_succeeds is true" do - allow_any_instance_of(MergeRequest).to receive(:head_pipeline).and_return(pipeline) + allow_any_instance_of(MergeRequest).to receive_messages(head_pipeline: pipeline, actual_head_pipeline: pipeline) allow(pipeline).to receive(:active?).and_return(true) project.update_attribute(:only_allow_merge_if_pipeline_succeeds, true) @@ -1546,52 +1546,65 @@ describe API::MergeRequests do end end - describe "PUT /projects/:id/merge_requests/:merge_request_iid/merge_to_ref" do - let(:pipeline) { create(:ci_pipeline_without_jobs) } + describe "GET /projects/:id/merge_requests/:merge_request_iid/merge_ref" do + before do + merge_request.mark_as_unchecked! + end + + let(:merge_request_iid) { merge_request.iid } + let(:url) do - "/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge_to_ref" + "/projects/#{project.id}/merge_requests/#{merge_request_iid}/merge_ref" end it 'returns the generated ID from the merge service in case of success' do - put api(url, user), params: { merge_commit_message: 'Custom message' } - - commit = project.commit(json_response['commit_id']) + get api(url, user) expect(response).to have_gitlab_http_status(200) - expect(json_response['commit_id']).to be_present - expect(commit.message).to eq('Custom message') + expect(json_response['commit_id']).to eq(merge_request.merge_ref_head.sha) end it "returns 400 if branch can't be merged" do - merge_request.update!(state: 'merged') + merge_request.update!(merge_status: 'cannot_be_merged') - put api(url, user) + get api(url, user) expect(response).to have_gitlab_http_status(400) - expect(json_response['message']) - .to eq("Merge request is not mergeable to #{merge_request.merge_ref_path}") + expect(json_response['message']).to eq('Merge request is not mergeable') end - it 'returns 403 if user has no permissions to merge to the ref' do - user2 = create(:user) - project.add_reporter(user2) + context 'when user has no access to the MR' do + let(:project) { create(:project, :private) } + let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } - put api(url, user2) + it 'returns 404' do + project.add_guest(user) - expect(response).to have_gitlab_http_status(403) - expect(json_response['message']).to eq('403 Forbidden') + get api(url, user) + + expect(response).to have_gitlab_http_status(404) + expect(json_response['message']).to eq('404 Not found') + end end - it 'returns 404 for an invalid merge request IID' do - put api("/projects/#{project.id}/merge_requests/12345/merge_to_ref", user) + context 'when invalid merge request IID' do + let(:merge_request_iid) { '12345' } - expect(response).to have_gitlab_http_status(404) + it 'returns 404' do + get api(url, user) + + expect(response).to have_gitlab_http_status(404) + end end - it "returns 404 if the merge request id is used instead of iid" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) + context 'when merge request ID is used instead IID' do + let(:merge_request_iid) { merge_request.id } - expect(response).to have_gitlab_http_status(404) + it 'returns 404' do + get api(url, user) + + expect(response).to have_gitlab_http_status(404) + end end end @@ -1950,7 +1963,7 @@ describe API::MergeRequests do describe 'POST :id/merge_requests/:merge_request_iid/cancel_merge_when_pipeline_succeeds' do before do - ::MergeRequests::MergeWhenPipelineSucceedsService.new(merge_request.target_project, user).execute(merge_request) + ::AutoMergeService.new(merge_request.target_project, user).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) end it 'removes the merge_when_pipeline_succeeds status' do diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb index 7d61ec9c4d8..3e0b478abb3 100644 --- a/spec/requests/api/search_spec.rb +++ b/spec/requests/api/search_spec.rb @@ -70,11 +70,30 @@ describe API::Search do context 'for milestones scope' do before do create(:milestone, project: project, title: 'awesome milestone') + end + + context 'when user can read project milestones' do + before do + get api('/search', user), params: { scope: 'milestones', search: 'awesome' } + end - get api('/search', user), params: { scope: 'milestones', search: 'awesome' } + it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' end - it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' + context 'when user cannot read project milestones' do + before do + project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE) + project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE) + end + + it 'returns empty array' do + get api('/search', user), params: { scope: 'milestones', search: 'awesome' } + + milestones = JSON.parse(response.body) + + expect(milestones).to be_empty + end + end end context 'for users scope' do @@ -318,11 +337,30 @@ describe API::Search do context 'for milestones scope' do before do create(:milestone, project: project, title: 'awesome milestone') + end + + context 'when user can read milestones' do + before do + get api("/projects/#{project.id}/search", user), params: { scope: 'milestones', search: 'awesome' } + end - get api("/projects/#{project.id}/search", user), params: { scope: 'milestones', search: 'awesome' } + it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' end - it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' + context 'when user cannot read project milestones' do + before do + project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE) + project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE) + end + + it 'returns empty array' do + get api("/projects/#{project.id}/search", user), params: { scope: 'milestones', search: 'awesome' } + + milestones = JSON.parse(response.body) + + expect(milestones).to be_empty + end + end end context 'for users scope' do diff --git a/spec/requests/api/system_hooks_spec.rb b/spec/requests/api/system_hooks_spec.rb index b6e8d74c2e9..0e2f3face71 100644 --- a/spec/requests/api/system_hooks_spec.rb +++ b/spec/requests/api/system_hooks_spec.rb @@ -1,12 +1,14 @@ require 'spec_helper' describe API::SystemHooks do + include StubRequests + let(:user) { create(:user) } let(:admin) { create(:admin) } let!(:hook) { create(:system_hook, url: "http://example.com") } before do - stub_request(:post, hook.url) + stub_full_request(hook.url, method: :post) end describe "GET /hooks" do @@ -68,6 +70,8 @@ describe API::SystemHooks do end it 'sets default values for events' do + stub_full_request('http://mep.mep', method: :post) + post api('/hooks', admin), params: { url: 'http://mep.mep' } expect(response).to have_gitlab_http_status(201) @@ -78,6 +82,8 @@ describe API::SystemHooks do end it 'sets explicit values for events' do + stub_full_request('http://mep.mep', method: :post) + post api('/hooks', admin), params: { url: 'http://mep.mep', diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index 6f40e88d26f..83775b1040e 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -470,18 +470,23 @@ describe 'project routing' do it_behaves_like 'RESTful project resources' do let(:controller) { 'milestones' } let(:actions) { [:index, :create, :new, :edit, :show, :update] } + let(:controller_path) { '/-/milestones' } end it 'to #promote' do - expect(post('/gitlab/gitlabhq/milestones/1/promote')).to route_to('projects/milestones#promote', namespace_id: 'gitlab', project_id: 'gitlabhq', id: "1") + expect(post('/gitlab/gitlabhq/-/milestones/1/promote')).to route_to('projects/milestones#promote', namespace_id: 'gitlab', project_id: 'gitlabhq', id: "1") end + + it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/milestones", "/gitlab/gitlabhq/-/milestones" end # project_labels GET /:project_id/labels(.:format) labels#index describe Projects::LabelsController, 'routing' do it 'to #index' do - expect(get('/gitlab/gitlabhq/labels')).to route_to('projects/labels#index', namespace_id: 'gitlab', project_id: 'gitlabhq') + expect(get('/gitlab/gitlabhq/-/labels')).to route_to('projects/labels#index', namespace_id: 'gitlab', project_id: 'gitlabhq') end + + it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/labels", "/gitlab/gitlabhq/-/labels" end # sort_project_issues POST /:project_id/issues/sort(.:format) issues#sort @@ -623,20 +628,24 @@ describe 'project routing' do describe Projects::ForksController, 'routing' do it 'to #new' do - expect(get('/gitlab/gitlabhq/forks/new')).to route_to('projects/forks#new', namespace_id: 'gitlab', project_id: 'gitlabhq') + expect(get('/gitlab/gitlabhq/-/forks/new')).to route_to('projects/forks#new', namespace_id: 'gitlab', project_id: 'gitlabhq') end it 'to #create' do - expect(post('/gitlab/gitlabhq/forks')).to route_to('projects/forks#create', namespace_id: 'gitlab', project_id: 'gitlabhq') + expect(post('/gitlab/gitlabhq/-/forks')).to route_to('projects/forks#create', namespace_id: 'gitlab', project_id: 'gitlabhq') end + + it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/forks", "/gitlab/gitlabhq/-/forks" end # project_avatar DELETE /project/avatar(.:format) projects/avatars#destroy describe Projects::AvatarsController, 'routing' do it 'to #destroy' do - expect(delete('/gitlab/gitlabhq/avatar')).to route_to( + expect(delete('/gitlab/gitlabhq/-/avatar')).to route_to( 'projects/avatars#destroy', namespace_id: 'gitlab', project_id: 'gitlabhq') end + + it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/avatar", "/gitlab/gitlabhq/-/avatar" end describe Projects::PagesDomainsController, 'routing' do diff --git a/spec/serializers/merge_request_widget_entity_spec.rb b/spec/serializers/merge_request_widget_entity_spec.rb index b89898f26f7..a27c22191f4 100644 --- a/spec/serializers/merge_request_widget_entity_spec.rb +++ b/spec/serializers/merge_request_widget_entity_spec.rb @@ -297,4 +297,50 @@ describe MergeRequestWidgetEntity do end end end + + describe 'auto merge' do + context 'when auto merge is enabled' do + let(:resource) { create(:merge_request, :merge_when_pipeline_succeeds) } + + it 'returns auto merge related information' do + expect(subject[:auto_merge_enabled]).to be_truthy + expect(subject[:auto_merge_strategy]).to eq('merge_when_pipeline_succeeds') + end + end + + context 'when auto merge is not enabled' do + let(:resource) { create(:merge_request) } + + it 'returns auto merge related information' do + expect(subject[:auto_merge_enabled]).to be_falsy + expect(subject[:auto_merge_strategy]).to be_nil + end + end + + context 'when head pipeline is running' do + before do + create(:ci_pipeline, :running, project: project, + ref: resource.source_branch, + sha: resource.diff_head_sha) + resource.update_head_pipeline + end + + it 'returns available auto merge strategies' do + expect(subject[:available_auto_merge_strategies]).to eq(%w[merge_when_pipeline_succeeds]) + end + end + + context 'when head pipeline is finished' do + before do + create(:ci_pipeline, :success, project: project, + ref: resource.source_branch, + sha: resource.diff_head_sha) + resource.update_head_pipeline + end + + it 'returns available auto merge strategies' do + expect(subject[:available_auto_merge_strategies]).to be_empty + end + end + end end diff --git a/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb b/spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb index 96f61f3f103..a20bf8e17e4 100644 --- a/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb +++ b/spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe MergeRequests::MergeWhenPipelineSucceedsService do +describe AutoMerge::MergeWhenPipelineSucceedsService do let(:user) { create(:user) } let(:project) { create(:project, :repository) } @@ -21,6 +21,27 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do described_class.new(project, user, commit_message: 'Awesome message') end + describe "#available_for?" do + subject { service.available_for?(mr_merge_if_green_enabled) } + + let(:pipeline_status) { :running } + + before do + create(:ci_pipeline, pipeline_status, ref: mr_merge_if_green_enabled.source_branch, + sha: mr_merge_if_green_enabled.diff_head_sha, + project: mr_merge_if_green_enabled.source_project) + mr_merge_if_green_enabled.update_head_pipeline + end + + it { is_expected.to be_truthy } + + context 'when the head piipeline succeeded' do + let(:pipeline_status) { :success } + + it { is_expected.to be_falsy } + end + end + describe "#execute" do let(:merge_request) do create(:merge_request, target_project: project, source_project: project, @@ -30,8 +51,7 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do context 'first time enabling' do before do allow(merge_request) - .to receive(:head_pipeline) - .and_return(pipeline) + .to receive_messages(head_pipeline: pipeline, actual_head_pipeline: pipeline) service.execute(merge_request) end @@ -39,7 +59,7 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do it 'sets the params, merge_user, and flag' do expect(merge_request).to be_valid expect(merge_request.merge_when_pipeline_succeeds).to be_truthy - expect(merge_request.merge_params).to eq commit_message: 'Awesome message' + expect(merge_request.merge_params).to include commit_message: 'Awesome message' expect(merge_request.merge_user).to be user end @@ -54,8 +74,8 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do let(:build) { create(:ci_build, ref: mr_merge_if_green_enabled.source_branch) } before do - allow(mr_merge_if_green_enabled).to receive(:head_pipeline) - .and_return(pipeline) + allow(mr_merge_if_green_enabled) + .to receive_messages(head_pipeline: pipeline, actual_head_pipeline: pipeline) allow(mr_merge_if_green_enabled).to receive(:mergeable?) .and_return(true) @@ -72,7 +92,7 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do end end - describe "#trigger" do + describe "#process" do let(:merge_request_ref) { mr_merge_if_green_enabled.source_branch } let(:merge_request_head) do project.commit(mr_merge_if_green_enabled.source_branch).id @@ -86,8 +106,11 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do end it "merges all merge requests with merge when the pipeline succeeds enabled" do + allow(mr_merge_if_green_enabled) + .to receive_messages(head_pipeline: triggering_pipeline, actual_head_pipeline: triggering_pipeline) + expect(MergeWorker).to receive(:perform_async) - service.trigger(triggering_pipeline) + service.process(mr_merge_if_green_enabled) end end @@ -99,7 +122,7 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do it 'does not merge request' do expect(MergeWorker).not_to receive(:perform_async) - service.trigger(old_pipeline) + service.process(mr_merge_if_green_enabled) end end @@ -111,7 +134,7 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do it 'does not merge request' do expect(MergeWorker).not_to receive(:perform_async) - service.trigger(unrelated_pipeline) + service.process(mr_merge_if_green_enabled) end end @@ -125,8 +148,11 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do end it 'merges the associated merge request' do + allow(mr_merge_if_green_enabled) + .to receive_messages(head_pipeline: pipeline, actual_head_pipeline: pipeline) + expect(MergeWorker).to receive(:perform_async) - service.trigger(pipeline) + service.process(mr_merge_if_green_enabled) end end end diff --git a/spec/services/auto_merge_service_spec.rb b/spec/services/auto_merge_service_spec.rb new file mode 100644 index 00000000000..d0eefed3150 --- /dev/null +++ b/spec/services/auto_merge_service_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe AutoMergeService do + set(:project) { create(:project) } + set(:user) { create(:user) } + let(:service) { described_class.new(project, user) } + + describe '.all_strategies' do + subject { described_class.all_strategies } + + it 'returns all strategies' do + is_expected.to eq(AutoMergeService::STRATEGIES) + end + end + + describe '#available_strategies' do + subject { service.available_strategies(merge_request) } + + let(:merge_request) { create(:merge_request) } + let(:pipeline_status) { :running } + + before do + create(:ci_pipeline, pipeline_status, ref: merge_request.source_branch, + sha: merge_request.diff_head_sha, + project: merge_request.source_project) + + merge_request.update_head_pipeline + end + + it 'returns available strategies' do + is_expected.to include('merge_when_pipeline_succeeds') + end + + context 'when the head piipeline succeeded' do + let(:pipeline_status) { :success } + + it 'returns available strategies' do + is_expected.to be_empty + end + end + end + + describe '.get_service_class' do + subject { described_class.get_service_class(strategy) } + + let(:strategy) { AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS } + + it 'returns service instance' do + is_expected.to eq(AutoMerge::MergeWhenPipelineSucceedsService) + end + + context 'when strategy is not present' do + let(:strategy) { } + + it 'returns nil' do + is_expected.to be_nil + end + end + end + + describe '#execute' do + subject { service.execute(merge_request, strategy) } + + let(:merge_request) { create(:merge_request) } + let(:pipeline_status) { :running } + let(:strategy) { AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS } + + before do + create(:ci_pipeline, pipeline_status, ref: merge_request.source_branch, + sha: merge_request.diff_head_sha, + project: merge_request.source_project) + + merge_request.update_head_pipeline + end + + it 'delegates to a relevant service instance' do + expect_next_instance_of(AutoMerge::MergeWhenPipelineSucceedsService) do |service| + expect(service).to receive(:execute).with(merge_request) + end + + subject + end + + context 'when the head piipeline succeeded' do + let(:pipeline_status) { :success } + + it 'returns failed' do + is_expected.to eq(:failed) + end + end + end + + describe '#process' do + subject { service.process(merge_request) } + + let(:merge_request) { create(:merge_request, :merge_when_pipeline_succeeds) } + + it 'delegates to a relevant service instance' do + expect_next_instance_of(AutoMerge::MergeWhenPipelineSucceedsService) do |service| + expect(service).to receive(:process).with(merge_request) + end + + subject + end + + context 'when auto merge is not enabled' do + let(:merge_request) { create(:merge_request) } + + it 'returns nil' do + is_expected.to be_nil + end + end + end + + describe '#cancel' do + subject { service.cancel(merge_request) } + + let(:merge_request) { create(:merge_request, :merge_when_pipeline_succeeds) } + + it 'delegates to a relevant service instance' do + expect_next_instance_of(AutoMerge::MergeWhenPipelineSucceedsService) do |service| + expect(service).to receive(:cancel).with(merge_request) + end + + subject + end + + context 'when auto merge is not enabled' do + let(:merge_request) { create(:merge_request) } + + it 'returns error' do + expect(subject[:message]).to eq("Can't cancel the automatic merge") + expect(subject[:status]).to eq(:error) + expect(subject[:http_status]).to eq(406) + end + end + end +end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index 9a3ac75e418..867692d4d64 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -973,7 +973,7 @@ describe Ci::CreatePipelineService do let(:merge_request) do create(:merge_request, source_project: project, - source_branch: ref_name, + source_branch: Gitlab::Git.ref_name(ref_name), target_project: project, target_branch: 'master') end @@ -1004,7 +1004,7 @@ describe Ci::CreatePipelineService do let(:merge_request) do create(:merge_request, source_project: project, - source_branch: ref_name, + source_branch: Gitlab::Git.ref_name(ref_name), target_project: project, target_branch: 'master') end @@ -1033,7 +1033,7 @@ describe Ci::CreatePipelineService do let(:merge_request) do create(:merge_request, source_project: project, - source_branch: ref_name, + source_branch: Gitlab::Git.ref_name(ref_name), target_project: project, target_branch: 'master') end diff --git a/spec/services/ci/pipeline_schedule_service_spec.rb b/spec/services/ci/pipeline_schedule_service_spec.rb new file mode 100644 index 00000000000..f2ac53cb25a --- /dev/null +++ b/spec/services/ci/pipeline_schedule_service_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Ci::PipelineScheduleService do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:service) { described_class.new(project, user) } + + describe '#execute' do + subject { service.execute(schedule) } + + let(:schedule) { create(:ci_pipeline_schedule, project: project, owner: user) } + + it 'schedules next run' do + expect(schedule).to receive(:schedule_next_run!) + + subject + end + + it 'runs RunPipelineScheduleWorker' do + expect(RunPipelineScheduleWorker) + .to receive(:perform_async).with(schedule.id, schedule.owner.id) + + subject + end + end +end diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb index ffa612cf315..29b7e0f17e2 100644 --- a/spec/services/merge_requests/close_service_spec.rb +++ b/spec/services/merge_requests/close_service_spec.rb @@ -52,6 +52,14 @@ describe MergeRequests::CloseService do it 'marks todos as done' do expect(todo.reload).to be_done end + + context 'when auto merge is enabled' do + let(:merge_request) { create(:merge_request, :merge_when_pipeline_succeeds) } + + it 'cancels the auto merge' do + expect(@merge_request).not_to be_auto_merge_enabled + end + end end it 'updates metrics' do diff --git a/spec/services/merge_requests/merge_to_ref_service_spec.rb b/spec/services/merge_requests/merge_to_ref_service_spec.rb index 0ac23050caf..5d492e4b013 100644 --- a/spec/services/merge_requests/merge_to_ref_service_spec.rb +++ b/spec/services/merge_requests/merge_to_ref_service_spec.rb @@ -32,10 +32,8 @@ describe MergeRequests::MergeToRefService do expect(result[:status]).to eq(:success) expect(result[:commit_id]).to be_present - expect(result[:source_id]).to eq(merge_request.source_branch_sha) - expect(result[:target_id]).to eq(merge_request.target_branch_sha) expect(repository.ref_exists?(target_ref)).to be(true) - expect(ref_head.id).to eq(result[:commit_id]) + expect(ref_head.sha).to eq(result[:commit_id]) end end @@ -72,10 +70,6 @@ describe MergeRequests::MergeToRefService do let(:merge_request) { create(:merge_request, :simple) } let(:project) { merge_request.project } - before do - project.add_maintainer(user) - end - describe '#execute' do let(:service) do described_class.new(project, user, commit_message: 'Awesome message', @@ -92,6 +86,12 @@ describe MergeRequests::MergeToRefService do it_behaves_like 'successfully evaluates pre-condition checks' context 'commit history comparison with regular MergeService' do + before do + # The merge service needs an authorized user while merge-to-ref + # doesn't. + project.add_maintainer(user) + end + let(:merge_ref_service) do described_class.new(project, user, {}) end @@ -136,9 +136,9 @@ describe MergeRequests::MergeToRefService do let(:merge_method) { :merge } it 'returns error' do - allow(merge_request).to receive(:mergeable_to_ref?) { false } + allow(project).to receive_message_chain(:repository, :merge_to_ref) { nil } - error_message = "Merge request is not mergeable to #{merge_request.merge_ref_path}" + error_message = 'Conflicts detected during merge' result = service.execute(merge_request) @@ -170,28 +170,5 @@ describe MergeRequests::MergeToRefService do it { expect(todo).not_to be_done } end - - context 'when merge request is WIP state' do - it 'fails to merge' do - merge_request = create(:merge_request, title: 'WIP: The feature') - - result = service.execute(merge_request) - - expect(result[:status]).to eq(:error) - expect(result[:message]).to eq("Merge request is not mergeable to #{merge_request.merge_ref_path}") - end - end - - it 'returns error when user has no authorization to admin the merge request' do - unauthorized_user = create(:user) - project.add_reporter(unauthorized_user) - - service = described_class.new(project, unauthorized_user) - - result = service.execute(merge_request) - - expect(result[:status]).to eq(:error) - expect(result[:message]).to eq('You are not allowed to merge to this ref') - end end end diff --git a/spec/services/merge_requests/mergeability_check_service_spec.rb b/spec/services/merge_requests/mergeability_check_service_spec.rb new file mode 100644 index 00000000000..aa0485467ed --- /dev/null +++ b/spec/services/merge_requests/mergeability_check_service_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe MergeRequests::MergeabilityCheckService do + shared_examples_for 'unmergeable merge request' do + it 'updates or keeps merge status as cannot_be_merged' do + subject + + expect(merge_request.merge_status).to eq('cannot_be_merged') + end + + it 'does not change the merge ref HEAD' do + expect { subject }.not_to change(merge_request, :merge_ref_head) + end + + it 'returns ServiceResponse.error' do + result = subject + + expect(result).to be_a(ServiceResponse) + expect(result).to be_error + end + end + + shared_examples_for 'mergeable merge request' do + it 'updates or keeps merge status as can_be_merged' do + subject + + expect(merge_request.merge_status).to eq('can_be_merged') + end + + it 'updates the merge ref' do + expect { subject }.to change(merge_request, :merge_ref_head).from(nil) + end + + it 'returns ServiceResponse.success' do + result = subject + + expect(result).to be_a(ServiceResponse) + expect(result).to be_success + end + + it 'ServiceResponse has merge_ref_head payload' do + result = subject + + expect(result.payload.keys).to contain_exactly(:merge_ref_head) + expect(result.payload[:merge_ref_head].keys) + .to contain_exactly(:commit_id, :target_id, :source_id) + end + end + + describe '#execute' do + let(:project) { create(:project, :repository) } + let(:merge_request) { create(:merge_request, merge_status: :unchecked, source_project: project, target_project: project) } + let(:repo) { project.repository } + + subject { described_class.new(merge_request).execute } + + before do + project.add_developer(merge_request.author) + end + + it_behaves_like 'mergeable merge request' + + context 'when multiple calls to the service' do + it 'returns success' do + subject + result = subject + + expect(result).to be_a(ServiceResponse) + expect(result.success?).to be(true) + end + + it 'second call does not change the merge-ref' do + expect { subject }.to change(merge_request, :merge_ref_head).from(nil) + expect { subject }.not_to change(merge_request, :merge_ref_head) + end + end + + context 'when broken' do + before do + allow(merge_request).to receive(:broken?) { true } + allow(project.repository).to receive(:can_be_merged?) { false } + end + + it_behaves_like 'unmergeable merge request' + + it 'returns ServiceResponse.error' do + result = subject + + expect(result).to be_a(ServiceResponse) + expect(result.error?).to be(true) + expect(result.message).to eq('Merge request is not mergeable') + end + end + + context 'when it has conflicts' do + before do + allow(merge_request).to receive(:broken?) { false } + allow(project.repository).to receive(:can_be_merged?) { false } + end + + it_behaves_like 'unmergeable merge request' + + it 'returns ServiceResponse.error' do + result = subject + + expect(result).to be_a(ServiceResponse) + expect(result.error?).to be(true) + expect(result.message).to eq('Merge request is not mergeable') + end + end + + context 'when MR cannot be merged and has no merge ref' do + before do + merge_request.mark_as_unmergeable! + end + + it_behaves_like 'unmergeable merge request' + + it 'returns ServiceResponse.error' do + result = subject + + expect(result).to be_a(ServiceResponse) + expect(result.error?).to be(true) + expect(result.message).to eq('Merge request is not mergeable') + end + end + + context 'when MR cannot be merged and has outdated merge ref' do + before do + MergeRequests::MergeToRefService.new(project, merge_request.author).execute(merge_request) + merge_request.mark_as_unmergeable! + end + + it_behaves_like 'unmergeable merge request' + + it 'returns ServiceResponse.error' do + result = subject + + expect(result).to be_a(ServiceResponse) + expect(result.error?).to be(true) + expect(result.message).to eq('Merge request is not mergeable') + end + end + + context 'when merge request is not given' do + subject { described_class.new(nil).execute } + + it 'returns ServiceResponse.error' do + result = subject + + expect(result).to be_a(ServiceResponse) + expect(result.message).to eq('Invalid argument') + end + end + + context 'when read only DB' do + it 'returns ServiceResponse.error' do + allow(Gitlab::Database).to receive(:read_only?) { true } + + result = subject + + expect(result).to be_a(ServiceResponse) + expect(result.message).to eq('Unsupported operation') + end + end + + context 'when MR is mergeable but merge-ref does not exists' do + before do + merge_request.mark_as_mergeable! + end + + it 'keeps merge status as can_be_merged' do + expect { subject }.not_to change(merge_request, :merge_status).from('can_be_merged') + end + + it 'returns ServiceResponse.error' do + result = subject + + expect(result).to be_a(ServiceResponse) + expect(result.error?).to be(true) + expect(result.message).to eq('Merge ref was not found') + end + end + end +end diff --git a/spec/services/merge_requests/push_options_handler_service_spec.rb b/spec/services/merge_requests/push_options_handler_service_spec.rb index f7a39bb42d5..54b9c6dae38 100644 --- a/spec/services/merge_requests/push_options_handler_service_spec.rb +++ b/spec/services/merge_requests/push_options_handler_service_spec.rb @@ -76,10 +76,11 @@ describe MergeRequests::PushOptionsHandlerService do shared_examples_for 'a service that can set the merge request to merge when pipeline succeeds' do subject(:last_mr) { MergeRequest.last } - it 'sets merge_when_pipeline_succeeds' do + it 'sets auto_merge_enabled' do service.execute - expect(last_mr.merge_when_pipeline_succeeds).to eq(true) + expect(last_mr.auto_merge_enabled).to eq(true) + expect(last_mr.auto_merge_strategy).to eq(AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) end it 'sets merge_user to the user' do diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb index 7258428589f..6ba67c7165c 100644 --- a/spec/services/merge_requests/refresh_service_spec.rb +++ b/spec/services/merge_requests/refresh_service_spec.rb @@ -23,7 +23,8 @@ describe MergeRequests::RefreshService do source_branch: 'master', target_branch: 'feature', target_project: @project, - merge_when_pipeline_succeeds: true, + auto_merge_enabled: true, + auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS, merge_user: @user) @another_merge_request = create(:merge_request, @@ -31,7 +32,8 @@ describe MergeRequests::RefreshService do source_branch: 'master', target_branch: 'test', target_project: @project, - merge_when_pipeline_succeeds: true, + auto_merge_enabled: true, + auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS, merge_user: @user) @fork_merge_request = create(:merge_request, @@ -83,7 +85,7 @@ describe MergeRequests::RefreshService do expect(@merge_request.notes).not_to be_empty expect(@merge_request).to be_open - expect(@merge_request.merge_when_pipeline_succeeds).to be_falsey + expect(@merge_request.auto_merge_enabled).to be_falsey expect(@merge_request.diff_head_sha).to eq(@newrev) expect(@fork_merge_request).to be_open expect(@fork_merge_request.notes).to be_empty @@ -292,7 +294,7 @@ describe MergeRequests::RefreshService do expect(@merge_request.notes).not_to be_empty expect(@merge_request).to be_open - expect(@merge_request.merge_when_pipeline_succeeds).to be_falsey + expect(@merge_request.auto_merge_enabled).to be_falsey expect(@merge_request.diff_head_sha).to eq(@newrev) expect(@fork_merge_request).to be_open expect(@fork_merge_request.notes).to be_empty diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index ba4c9ce60f3..fbfcd95e204 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -217,8 +217,9 @@ describe MergeRequests::UpdateService, :mailer do head_pipeline_of: merge_request ) - expect(MergeRequests::MergeWhenPipelineSucceedsService).to receive(:new).with(project, user) + expect(AutoMerge::MergeWhenPipelineSucceedsService).to receive(:new).with(project, user, {}) .and_return(service_mock) + allow(service_mock).to receive(:available_for?) { true } expect(service_mock).to receive(:execute).with(merge_request) end diff --git a/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb b/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb index f4470b50753..75d534c59bf 100644 --- a/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb +++ b/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb @@ -2,6 +2,8 @@ require 'spec_helper' describe Projects::LfsPointers::LfsDownloadService do + include StubRequests + let(:project) { create(:project) } let(:lfs_content) { SecureRandom.random_bytes(10) } let(:oid) { Digest::SHA256.hexdigest(lfs_content) } @@ -62,7 +64,7 @@ describe Projects::LfsPointers::LfsDownloadService do describe '#execute' do context 'when file download succeeds' do before do - WebMock.stub_request(:get, download_link).to_return(body: lfs_content) + stub_full_request(download_link).to_return(body: lfs_content) end it_behaves_like 'lfs object is created' @@ -104,7 +106,7 @@ describe Projects::LfsPointers::LfsDownloadService do let(:size) { 1 } before do - WebMock.stub_request(:get, download_link).to_return(body: lfs_content) + stub_full_request(download_link).to_return(body: lfs_content) end it_behaves_like 'no lfs object is created' @@ -118,7 +120,7 @@ describe Projects::LfsPointers::LfsDownloadService do context 'when downloaded lfs file has a different oid' do before do - WebMock.stub_request(:get, download_link).to_return(body: lfs_content) + stub_full_request(download_link).to_return(body: lfs_content) allow_any_instance_of(Digest::SHA256).to receive(:hexdigest).and_return('foobar') end @@ -136,7 +138,7 @@ describe Projects::LfsPointers::LfsDownloadService do let(:lfs_object) { LfsDownloadObject.new(oid: oid, size: size, link: download_link_with_credentials) } before do - WebMock.stub_request(:get, download_link).with(headers: { 'Authorization' => 'Basic dXNlcjpwYXNzd29yZA==' }).to_return(body: lfs_content) + stub_full_request(download_link).with(headers: { 'Authorization' => 'Basic dXNlcjpwYXNzd29yZA==' }).to_return(body: lfs_content) end it 'the request adds authorization headers' do @@ -149,7 +151,7 @@ describe Projects::LfsPointers::LfsDownloadService do let(:local_request_setting) { true } before do - WebMock.stub_request(:get, download_link).to_return(body: lfs_content) + stub_full_request(download_link, ip_address: '192.168.2.120').to_return(body: lfs_content) end it_behaves_like 'lfs object is created' @@ -173,7 +175,8 @@ describe Projects::LfsPointers::LfsDownloadService do with_them do before do - WebMock.stub_request(:get, download_link).to_return(status: 301, headers: { 'Location' => redirect_link }) + stub_full_request(download_link, ip_address: '192.168.2.120') + .to_return(status: 301, headers: { 'Location' => redirect_link }) end it_behaves_like 'no lfs object is created' @@ -184,8 +187,8 @@ describe Projects::LfsPointers::LfsDownloadService do let(:redirect_link) { "http://example.com/"} before do - WebMock.stub_request(:get, download_link).to_return(status: 301, headers: { 'Location' => redirect_link }) - WebMock.stub_request(:get, redirect_link).to_return(body: lfs_content) + stub_full_request(download_link).to_return(status: 301, headers: { 'Location' => redirect_link }) + stub_full_request(redirect_link).to_return(body: lfs_content) end it_behaves_like 'lfs object is created' diff --git a/spec/services/service_response_spec.rb b/spec/services/service_response_spec.rb index 30bd4d6820b..e790d272e61 100644 --- a/spec/services/service_response_spec.rb +++ b/spec/services/service_response_spec.rb @@ -16,6 +16,13 @@ describe ServiceResponse do expect(response).to be_success expect(response.message).to eq('Good orange') end + + it 'creates a successful response with payload' do + response = described_class.success(payload: { good: 'orange' }) + + expect(response).to be_success + expect(response.payload).to eq(good: 'orange') + end end describe '.error' do @@ -33,6 +40,15 @@ describe ServiceResponse do expect(response.message).to eq('Bad apple') expect(response.http_status).to eq(400) end + + it 'creates a failed response with payload' do + response = described_class.error(message: 'Bad apple', + payload: { bad: 'apple' }) + + expect(response).to be_error + expect(response.message).to eq('Bad apple') + expect(response.payload).to eq(bad: 'apple') + end end describe '#success?' do diff --git a/spec/services/submit_usage_ping_service_spec.rb b/spec/services/submit_usage_ping_service_spec.rb index 78df9bf96bf..653f17a4324 100644 --- a/spec/services/submit_usage_ping_service_spec.rb +++ b/spec/services/submit_usage_ping_service_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe SubmitUsagePingService do + include StubRequests + context 'when usage ping is disabled' do before do stub_application_setting(usage_ping_enabled: false) @@ -99,7 +101,7 @@ describe SubmitUsagePingService do end def stub_response(body) - stub_request(:post, 'https://version.gitlab.com/usage_data') + stub_full_request('https://version.gitlab.com/usage_data', method: :post) .to_return( headers: { 'Content-Type' => 'application/json' }, body: body.to_json diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index 75ba2479b63..37bafc0c002 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe WebHookService do + include StubRequests + let(:project) { create(:project) } let(:project_hook) { create(:project_hook) } let(:headers) do @@ -67,11 +69,11 @@ describe WebHookService do let(:project_hook) { create(:project_hook, url: 'https://demo:demo@example.org/') } it 'uses the credentials' do - WebMock.stub_request(:post, url) + stub_full_request(url, method: :post) service_instance.execute - expect(WebMock).to have_requested(:post, url).with( + expect(WebMock).to have_requested(:post, stubbed_hostname(url)).with( headers: headers.merge('Authorization' => 'Basic ZGVtbzpkZW1v') ).once end @@ -82,11 +84,11 @@ describe WebHookService do let(:project_hook) { create(:project_hook, url: 'https://demo@example.org/') } it 'uses the credentials anyways' do - WebMock.stub_request(:post, url) + stub_full_request(url, method: :post) service_instance.execute - expect(WebMock).to have_requested(:post, url).with( + expect(WebMock).to have_requested(:post, stubbed_hostname(url)).with( headers: headers.merge('Authorization' => 'Basic ZGVtbzo=') ).once end diff --git a/spec/support/helpers/git_helpers.rb b/spec/support/helpers/git_helpers.rb index 99a7c39852e..99c5871ba54 100644 --- a/spec/support/helpers/git_helpers.rb +++ b/spec/support/helpers/git_helpers.rb @@ -6,12 +6,4 @@ module GitHelpers Rugged::Repository.new(path) end - - def project_hook_exists?(project) - Gitlab::GitalyClient::StorageSettings.allow_disk_access do - project_path = project.repository.raw_repository.path - - File.exist?(File.join(project_path, 'hooks', 'post-receive')) - end - end end diff --git a/spec/support/helpers/kubernetes_helpers.rb b/spec/support/helpers/kubernetes_helpers.rb index 78b7ae9c00c..011c4df0fe5 100644 --- a/spec/support/helpers/kubernetes_helpers.rb +++ b/spec/support/helpers/kubernetes_helpers.rb @@ -17,17 +17,38 @@ module KubernetesHelpers kube_response(kube_deployments_body) end - def stub_kubeclient_discover(api_url) + def stub_kubeclient_discover_base(api_url) WebMock.stub_request(:get, api_url + '/api/v1').to_return(kube_response(kube_v1_discovery_body)) - WebMock.stub_request(:get, api_url + '/apis/extensions/v1beta1').to_return(kube_response(kube_v1beta1_discovery_body)) - WebMock.stub_request(:get, api_url + '/apis/rbac.authorization.k8s.io/v1').to_return(kube_response(kube_v1_rbac_authorization_discovery_body)) - WebMock.stub_request(:get, api_url + '/apis/serving.knative.dev/v1alpha1').to_return(kube_response(kube_v1alpha1_serving_knative_discovery_body)) + WebMock + .stub_request(:get, api_url + '/apis/extensions/v1beta1') + .to_return(kube_response(kube_v1beta1_discovery_body)) + WebMock + .stub_request(:get, api_url + '/apis/rbac.authorization.k8s.io/v1') + .to_return(kube_response(kube_v1_rbac_authorization_discovery_body)) + end + + def stub_kubeclient_discover(api_url) + stub_kubeclient_discover_base(api_url) + + WebMock + .stub_request(:get, api_url + '/apis/serving.knative.dev/v1alpha1') + .to_return(kube_response(kube_v1alpha1_serving_knative_discovery_body)) + end + + def stub_kubeclient_discover_knative_not_found(api_url) + stub_kubeclient_discover_base(api_url) + + WebMock + .stub_request(:get, api_url + '/apis/serving.knative.dev/v1alpha1') + .to_return(status: [404, "Resource Not Found"]) end - def stub_kubeclient_service_pods(status: nil) + def stub_kubeclient_service_pods(response = nil, options = {}) stub_kubeclient_discover(service.api_url) - pods_url = service.api_url + "/api/v1/pods" - response = { status: status } if status + + namespace_path = options[:namespace].present? ? "namespaces/#{options[:namespace]}/" : "" + + pods_url = service.api_url + "/api/v1/#{namespace_path}pods" WebMock.stub_request(:get, pods_url).to_return(response || kube_pods_response) end @@ -56,15 +77,18 @@ module KubernetesHelpers WebMock.stub_request(:get, deployments_url).to_return(response || kube_deployments_response) end - def stub_kubeclient_knative_services(**options) + def stub_kubeclient_knative_services(options = {}) + namespace_path = options[:namespace].present? ? "namespaces/#{options[:namespace]}/" : "" + options[:name] ||= "kubetest" - options[:namespace] ||= "default" options[:domain] ||= "example.com" + options[:response] ||= kube_response(kube_knative_services_body(options)) stub_kubeclient_discover(service.api_url) - knative_url = service.api_url + "/apis/serving.knative.dev/v1alpha1/services" - WebMock.stub_request(:get, knative_url).to_return(kube_response(kube_knative_services_body(options))) + knative_url = service.api_url + "/apis/serving.knative.dev/v1alpha1/#{namespace_path}services" + + WebMock.stub_request(:get, knative_url).to_return(options[:response]) end def stub_kubeclient_get_secret(api_url, **options) diff --git a/spec/support/helpers/stub_requests.rb b/spec/support/helpers/stub_requests.rb new file mode 100644 index 00000000000..5cad35282c0 --- /dev/null +++ b/spec/support/helpers/stub_requests.rb @@ -0,0 +1,40 @@ +module StubRequests + IP_ADDRESS_STUB = '8.8.8.9'.freeze + + # Fully stubs a request using WebMock class. This class also + # stubs the IP address the URL is translated to (DNS lookup). + # + # It expects the final request to go to the `ip_address` instead the given url. + # That's primarily a DNS rebind attack prevention of Gitlab::HTTP + # (see: Gitlab::UrlBlocker). + # + def stub_full_request(url, ip_address: IP_ADDRESS_STUB, port: 80, method: :get) + stub_dns(url, ip_address: ip_address, port: port) + + url = stubbed_hostname(url, hostname: ip_address) + WebMock.stub_request(method, url) + end + + def stub_dns(url, ip_address:, port: 80) + url = parse_url(url) + socket = Socket.sockaddr_in(port, ip_address) + addr = Addrinfo.new(socket) + + # See Gitlab::UrlBlocker + allow(Addrinfo).to receive(:getaddrinfo) + .with(url.hostname, url.port, nil, :STREAM) + .and_return([addr]) + end + + def stubbed_hostname(url, hostname: IP_ADDRESS_STUB) + url = parse_url(url) + url.hostname = hostname + url.to_s + end + + private + + def parse_url(url) + url.is_a?(URI) ? url : URI(url) + end +end diff --git a/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb b/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb index 7a04e940ee5..1b09c3dd636 100644 --- a/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb +++ b/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb @@ -3,16 +3,16 @@ require 'spec_helper' shared_examples_for 'UpdateProjectStatistics' do - let(:project) { subject.project } - let(:stat) { described_class.statistic_name } - let(:attribute) { described_class.statistic_attribute } + let(:project) { subject.project } + let(:project_statistics_name) { described_class.project_statistics_name } + let(:statistic_attribute) { described_class.statistic_attribute } def reload_stat - project.statistics.reload.send(stat).to_i + project.statistics.reload.send(project_statistics_name).to_i end def read_attribute - subject.read_attribute(attribute).to_i + subject.read_attribute(statistic_attribute).to_i end it { is_expected.to be_new_record } @@ -39,7 +39,8 @@ shared_examples_for 'UpdateProjectStatistics' do .to receive(:increment_statistic) .and_call_original - subject.write_attribute(attribute, read_attribute + delta) + subject.write_attribute(statistic_attribute, read_attribute + delta) + expect { subject.save! } .to change { reload_stat } .by(delta) diff --git a/spec/tasks/gitlab/shell_rake_spec.rb b/spec/tasks/gitlab/shell_rake_spec.rb index a9d14070177..c3e912b02c5 100644 --- a/spec/tasks/gitlab/shell_rake_spec.rb +++ b/spec/tasks/gitlab/shell_rake_spec.rb @@ -7,14 +7,8 @@ describe 'gitlab:shell rake tasks' do stub_warn_user_is_not_gitlab end - after do - TestEnv.sabotage_gitlab_shell_hooks - end - describe 'install task' do - it 'invokes create_hooks task' do - expect(Rake::Task['gitlab:shell:create_hooks']).to receive(:invoke) - + it 'installs and compiles gitlab-shell' do storages = Gitlab::GitalyClient::StorageSettings.allow_disk_access do Gitlab.config.repositories.storages.values.map(&:legacy_disk_path) end @@ -24,14 +18,4 @@ describe 'gitlab:shell rake tasks' do run_rake_task('gitlab:shell:install') end end - - describe 'create_hooks task' do - it 'calls gitlab-shell bin/create_hooks' do - expect_any_instance_of(Object).to receive(:system) - .with("#{Gitlab.config.gitlab_shell.path}/bin/create-hooks", - *Gitlab::TaskHelpers.repository_storage_paths_args) - - run_rake_task('gitlab:shell:create_hooks') - end - end end diff --git a/spec/tasks/tokens_spec.rb b/spec/tasks/tokens_spec.rb index 555a58e9aa1..4188e7caccb 100644 --- a/spec/tasks/tokens_spec.rb +++ b/spec/tasks/tokens_spec.rb @@ -8,13 +8,13 @@ describe 'tokens rake tasks' do end describe 'reset_all_email task' do - it 'invokes create_hooks task' do + it 'changes the incoming email token' do expect { run_rake_task('tokens:reset_all_email') }.to change { user.reload.incoming_email_token } end end describe 'reset_all_feed task' do - it 'invokes create_hooks task' do + it 'changes the feed token for the user' do expect { run_rake_task('tokens:reset_all_feed') }.to change { user.reload.feed_token } end end diff --git a/spec/workers/auto_merge_process_worker_spec.rb b/spec/workers/auto_merge_process_worker_spec.rb new file mode 100644 index 00000000000..616727ce5ca --- /dev/null +++ b/spec/workers/auto_merge_process_worker_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe AutoMergeProcessWorker do + describe '#perform' do + subject { described_class.new.perform(merge_request&.id) } + + context 'when merge request is found' do + let(:merge_request) { create(:merge_request) } + + it 'executes AutoMergeService' do + expect_next_instance_of(AutoMergeService) do |auto_merge| + expect(auto_merge).to receive(:process) + end + + subject + end + end + + context 'when merge request is not found' do + let(:merge_request) { nil } + + it 'does not execute AutoMergeService' do + expect(AutoMergeService).not_to receive(:new) + + subject + end + end + end +end diff --git a/spec/workers/pipeline_schedule_worker_spec.rb b/spec/workers/pipeline_schedule_worker_spec.rb index 8c604b13297..9326db34209 100644 --- a/spec/workers/pipeline_schedule_worker_spec.rb +++ b/spec/workers/pipeline_schedule_worker_spec.rb @@ -41,16 +41,6 @@ describe PipelineScheduleWorker do it_behaves_like 'successful scheduling' - context 'when exclusive lease has already been taken by the other instance' do - before do - stub_exclusive_lease_taken(described_class::EXCLUSIVE_LOCK_KEY, timeout: described_class::LOCK_TIMEOUT) - end - - it 'raises an error and does not start creating pipelines' do - expect { subject }.to raise_error(Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError) - end - end - context 'when the latest commit contains [ci skip]' do before do allow_any_instance_of(Ci::Pipeline) @@ -77,47 +67,19 @@ describe PipelineScheduleWorker do stub_ci_pipeline_yaml_file(YAML.dump(rspec: { variables: 'rspec' } )) end - it 'creates a failed pipeline with the reason' do - expect { subject }.to change { project.ci_pipelines.count }.by(1) - expect(Ci::Pipeline.last).to be_config_error - expect(Ci::Pipeline.last.yaml_errors).not_to be_nil + it 'does not creates a new pipeline' do + expect { subject }.not_to change { project.ci_pipelines.count } end end end context 'when the schedule is not runnable by the user' do - before do - expect(Gitlab::Sentry) - .to receive(:track_exception) - .with(Ci::CreatePipelineService::CreateError, - issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/41231', - extra: { schedule_id: pipeline_schedule.id } ).once - end - it 'does not deactivate the schedule' do subject expect(pipeline_schedule.reload.active).to be_truthy end - it 'increments Prometheus counter' do - expect(Gitlab::Metrics) - .to receive(:counter) - .with(:pipeline_schedule_creation_failed_total, "Counter of failed attempts of pipeline schedule creation") - .and_call_original - - subject - end - - it 'logging a pipeline error' do - expect(Rails.logger) - .to receive(:error) - .with(a_string_matching("Insufficient permissions to create a new pipeline")) - .and_call_original - - subject - end - it 'does not create a pipeline' do expect { subject }.not_to change { project.ci_pipelines.count } end @@ -131,21 +93,6 @@ describe PipelineScheduleWorker do before do stub_ci_pipeline_yaml_file(nil) project.add_maintainer(user) - - expect(Gitlab::Sentry) - .to receive(:track_exception) - .with(Ci::CreatePipelineService::CreateError, - issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/41231', - extra: { schedule_id: pipeline_schedule.id } ).once - end - - it 'logging a pipeline error' do - expect(Rails.logger) - .to receive(:error) - .with(a_string_matching("Missing .gitlab-ci.yml file")) - .and_call_original - - subject end it 'does not create a pipeline' do diff --git a/spec/workers/pipeline_success_worker_spec.rb b/spec/workers/pipeline_success_worker_spec.rb deleted file mode 100644 index 4cbe384b47a..00000000000 --- a/spec/workers/pipeline_success_worker_spec.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe PipelineSuccessWorker do - describe '#perform' do - context 'when pipeline exists' do - let(:pipeline) { create(:ci_pipeline, status: 'success') } - - it 'performs "merge when pipeline succeeds"' do - expect_any_instance_of( - MergeRequests::MergeWhenPipelineSucceedsService - ).to receive(:trigger) - - described_class.new.perform(pipeline.id) - end - end - - context 'when pipeline does not exist' do - it 'does not raise exception' do - expect { described_class.new.perform(123) } - .not_to raise_error - end - end - end -end diff --git a/spec/workers/run_pipeline_schedule_worker_spec.rb b/spec/workers/run_pipeline_schedule_worker_spec.rb index 690af22f4dc..7414470f8e7 100644 --- a/spec/workers/run_pipeline_schedule_worker_spec.rb +++ b/spec/workers/run_pipeline_schedule_worker_spec.rb @@ -32,7 +32,37 @@ describe RunPipelineScheduleWorker do it 'calls the Service' do expect(Ci::CreatePipelineService).to receive(:new).with(project, user, ref: pipeline_schedule.ref).and_return(create_pipeline_service) - expect(create_pipeline_service).to receive(:execute).with(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: pipeline_schedule) + expect(create_pipeline_service).to receive(:execute!).with(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: pipeline_schedule) + + worker.perform(pipeline_schedule.id, user.id) + end + end + + context 'when database statement timeout happens' do + before do + allow(Ci::CreatePipelineService).to receive(:new) { raise ActiveRecord::StatementInvalid } + + expect(Gitlab::Sentry) + .to receive(:track_exception) + .with(ActiveRecord::StatementInvalid, + issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/41231', + extra: { schedule_id: pipeline_schedule.id } ).once + end + + it 'increments Prometheus counter' do + expect(Gitlab::Metrics) + .to receive(:counter) + .with(:pipeline_schedule_creation_failed_total, "Counter of failed attempts of pipeline schedule creation") + .and_call_original + + worker.perform(pipeline_schedule.id, user.id) + end + + it 'logging a pipeline error' do + expect(Rails.logger) + .to receive(:error) + .with(a_string_matching('ActiveRecord::StatementInvalid')) + .and_call_original worker.perform(pipeline_schedule.id, user.id) end diff --git a/yarn.lock b/yarn.lock index c8f3e102bc5..6906d6af89f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -680,10 +680,10 @@ dependencies: bootstrap "^4.1.3" -"@gitlab/eslint-config@^1.5.0": - version "1.5.0" - resolved "https://registry.yarnpkg.com/@gitlab/eslint-config/-/eslint-config-1.5.0.tgz#0c8c3ae74f276eb6671bd7c60f331bc0f2d2e5cf" - integrity sha512-KgJgoIZNpGauFpCV1iCptesYN7I8abtYRBLU9xcH0oocC/xp3JmbLfsZ+lEtrk8pl99Q2mKiAuaPpzxjXr6hBw== +"@gitlab/eslint-config@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@gitlab/eslint-config/-/eslint-config-1.6.0.tgz#1fd247d6ab477d53d4c330e05f007e3afa303689" + integrity sha512-EZffCwsRZmRWPP6N3wp20EJDVGYLG1v43/W7fF/gYQpUjcRclC8ks/jEv8UppasSDlanDmkh1bLWoE9CelSyyw== dependencies: babel-eslint "^10.0.1" eslint-config-airbnb-base "^13.1.0" @@ -698,10 +698,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.63.0.tgz#9dd544026d203e4ce6efed72b05db68f710c4d49" integrity sha512-YztrReFTg31B7v5wtUC5j15KHNcMebtW+kACytEU42XomMaIwk4USIbygqWlq0VRHA2VHJrHApfJHIjxiCCQcA== -"@gitlab/ui@^3.10.1": - version "3.10.1" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-3.10.1.tgz#473e1383b05847d7226257228a8fd8d2905a9415" - integrity sha512-LReB+EIBvZlfeEX2s/azuz6NvegrwwzficvBxK5yPCztjDeegcEsTDlt0F32mX6DcvNFacuuAyWN8QtgpaWkhA== +"@gitlab/ui@^3.10.3": + version "3.10.3" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-3.10.3.tgz#dba2ddc726e203ab341d870cea2fe634f583c08d" + integrity sha512-Y48DKhOSC+Yw0X8PN+TyR8gITAq6jVHbiTsw+eZkCacs367L1u6w82lr7ba/Bl+4W6DhDWT34VCG0hYbGVrUJw== dependencies: "@babel/standalone" "^7.0.0" "@gitlab/vue-toasted" "^1.2.1" |