diff options
author | Kamil Trzcinski <ayufan@ayufan.eu> | 2017-11-06 22:38:35 +0100 |
---|---|---|
committer | Kamil Trzcinski <ayufan@ayufan.eu> | 2017-11-06 22:38:35 +0100 |
commit | 7599009c2726bfdbd73da360961e4d8611641b02 (patch) | |
tree | 1578ea9216c4430e2d5d5043edc85f445716c28b | |
parent | c708931a327edcc70b92be7fcddb84fbbe2c0394 (diff) | |
parent | 83c3c19ce5eb6a88cd9ed428fa0f8d68d5fa7167 (diff) | |
download | gitlab-ce-7599009c2726bfdbd73da360961e4d8611641b02.tar.gz |
Merge remote-tracking branch 'origin/move-kubernetes-from-service-to-clusters-page-10-2-ver' into 35616-move-gke-form-1st-iteration
977 files changed, 15020 insertions, 10136 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 87d73fc0c52..fed5971233d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,7 +1,7 @@ -image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.3-golang-1.8-git-2.13-phantomjs-2.1-node-8.x-yarn-1.0-postgresql-9.6" +image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.5-golang-1.8-git-2.13-chrome-62.0-node-8.x-yarn-1.2-postgresql-9.6" .default-cache: &default-cache - key: "ruby-233-with-yarn" + key: "ruby-235-with-yarn" paths: - vendor/ruby - .yarn-cache/ @@ -23,7 +23,6 @@ variables: SIMPLECOV: "true" GIT_DEPTH: "20" GIT_SUBMODULE_STRATEGY: "none" - PHANTOMJS_VERSION: "2.1.1" GET_SOURCES_ATTEMPTS: "3" KNAPSACK_RSPEC_SUITE_REPORT_PATH: knapsack/${CI_PROJECT_NAME}/rspec_report-master.json KNAPSACK_SPINACH_SUITE_REPORT_PATH: knapsack/${CI_PROJECT_NAME}/spinach_report-master.json @@ -455,7 +454,7 @@ db:migrate:reset-mysql: variables: SETUP_DB: "false" script: - - git fetch origin v8.14.10 + - git fetch https://gitlab.com/gitlab-org/gitlab-ce.git v9.3.0 - git checkout -f FETCH_HEAD - bundle install $BUNDLE_INSTALL_FLAGS - cp config/gitlab.yml.example config/gitlab.yml @@ -551,7 +550,6 @@ karma: <<: *dedicated-runner <<: *except-docs <<: *pull-cache - image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.3-golang-1.8-git-2.13-chrome-61.0-node-8.x-yarn-1.0-postgresql-9.6" stage: test variables: BABEL_ENV: "coverage" diff --git a/.gitlab/route-map.yml b/.gitlab/route-map.yml new file mode 100644 index 00000000000..0b37dc68f8b --- /dev/null +++ b/.gitlab/route-map.yml @@ -0,0 +1,3 @@ +# Documentation +- source: /doc/(.+?)\.md/ # doc/administration/build_artifacts.md + public: '\1.html' # doc/administration/build_artifacts.html @@ -1 +1 @@ -7.5
\ No newline at end of file +9.0.0 diff --git a/.ruby-version b/.ruby-version index 0bee604df76..cc6c9a491e0 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.3.3 +2.3.5 diff --git a/.scss-lint.yml b/.scss-lint.yml index 73f8d27f78c..16a168b7c60 100644 --- a/.scss-lint.yml +++ b/.scss-lint.yml @@ -112,7 +112,7 @@ linters: # Reports when you define the same selector twice in a single sheet. MergeableSelector: - enabled: false + enabled: true # Functions, mixins, variables, and placeholders should be declared # with all lowercase letters and hyphens instead of underscores. @@ -121,7 +121,8 @@ linters: # Avoid nesting selectors too deeply. NestingDepth: - enabled: false + enabled: true + max_depth: 6 # Always use placeholder selectors in @extend. PlaceholderInExtend: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bca9944bb1..2f13eca2caf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 10.1.1 (2017-10-31) + +- [FIXED] Auto Devops kubernetes default namespace is now correctly built out of gitlab project group-name. !14642 (Mircea Danila Dumitrescu) +- [FIXED] Forbid the usage of `Redis#keys`. !14889 +- [FIXED] Make the circuitbreaker more robust by adding higher thresholds, and multiple access attempts. !14933 +- [FIXED] Only cache last push event for existing projects when pushing to a fork. !14989 +- [FIXED] Fix bug preventing secondary emails from being confirmed. !15010 +- [FIXED] Fix broken wiki pages that link to a wiki file. !15019 +- [FIXED] Don't rename paths that were freed up when upgrading. !15029 +- [FIXED] Fix bitbucket login. !15051 +- [FIXED] Update gitaly in Gitlab 10.1 to 0.43.1 for temp file cleanup. !15055 +- [FIXED] Use the correct visibility attribute for projects in system hooks. !15065 +- [FIXED] Normalize LDAP DN when looking up identity. +- [FIXED] Adds callback functions for initial request in clusters page. +- [FIXED] Fix missing Import/Export issue assignees. +- [FIXED] Allow boards as top level route. +- [FIXED] Fix widget of locked merge requests not being presented. +- [FIXED] Fix editing issue description in mobile view. +- [FIXED] Fix deletion of container registry or images returning an error. +- [FIXED] Fix the writing of invalid environment refs. +- [CHANGED] Store circuitbreaker settings in the database instead of config. !14842 +- [CHANGED] Update default disabled merge request widget message to reflect a general failure. !14960 +- [PERFORMANCE] Stop merge requests with thousands of commits from timing out. !15063 + ## 10.1.0 (2017-10-22) - [SECURITY] Use a timeout on certain git operations. !14872 @@ -194,6 +218,24 @@ entry. - creation of keys moved to services. !13331 (haseebeqx) - Add username as GL_USERNAME in hooks. +## 10.0.5 (2017-11-03) + +- [FIXED] Fix incorrect X-axis labels in Prometheus graphs. !14258 +- [FIXED] Fix `rake gitlab:incoming_email:check` and make it report the actual error. !14423 +- [FIXED] Does not check if an invariant hashed storage path exists on disk when renaming projects. !14428 +- [FIXED] Fix bottom spacing for dropdowns that open upwards. !14535 +- [FIXED] Fix the project import with issues and milestones. !14657 +- [FIXED] Fix broken Y-axis scaling in some Prometheus graphs. !14693 +- [FIXED] Fixed duplicate notifications when added multiple labels on an issue. !14798 +- [FIXED] Don't rename paths that were freed up when upgrading. !15029 +- [FIXED] Fixed issue/merge request breadcrumb titles not having links. +- [FIXED] Fix application setting to cache nil object. +- [FIXED] Fix missing Import/Export issue assignees. +- [FIXED] Allow boards as top level route. +- [FIXED] Fixed milestone breadcrumb links. +- [FIXED] Fixed merge request widget merged & closed date tooltip text. +- [FIXED] fix merge request widget status icon for failed CI. + ## 10.0.4 (2017-10-16) - [SECURITY] Move project repositories between namespaces when renaming users. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b2ee157193..c4e5fd842df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,13 @@ -## Contributor license agreement +## Developer Certificate of Origin + License -By submitting code as an individual you agree to the -[individual contributor license agreement](doc/legal/individual_contributor_license_agreement.md). -By submitting code as an entity you agree to the -[corporate contributor license agreement](doc/legal/corporate_contributor_license_agreement.md). +By contributing to GitLab B.V., You accept and agree to the following terms and +conditions for Your present and future Contributions submitted to GitLab B.V. +Except for the license granted herein to GitLab B.V. and recipients of software +distributed by GitLab B.V., You reserve all right, title, and interest in and to +Your Contributions. All Contributions are subject to the following DCO + License +terms. + +[DCO + License](https://gitlab.com/gitlab-org/dco/blob/master/README.md) _This notice should stay as the first item in the CONTRIBUTING.md file._ @@ -100,8 +104,7 @@ the remaining issues on the GitHub issue tracker. ## I want to contribute! -If you want to contribute to GitLab, but are not sure where to start, -look for [issues with the label `Accepting Merge Requests` and small weight][accepting-mrs-weight]. +If you want to contribute to GitLab, [issues with the label `Accepting Merge Requests` and small weight][accepting-mrs-weight] is a great place to start. Issues with a lower weight (1 or 2) are deemed suitable for beginners. These issues will be of reasonable size and challenge, for anyone to start contributing to GitLab. diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index fbaaafa001b..c5d4cee36a1 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.49.0
\ No newline at end of file +0.51.0 diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION index 944880fa15e..15a27998172 100644 --- a/GITLAB_WORKHORSE_VERSION +++ b/GITLAB_WORKHORSE_VERSION @@ -1 +1 @@ -3.2.0 +3.3.0 @@ -90,7 +90,7 @@ gem 'kaminari', '~> 1.0' gem 'hamlit', '~> 2.6.1' # Files attachments -gem 'carrierwave', '~> 1.1' +gem 'carrierwave', '~> 1.2' # Drag and Drop UI gem 'dropzonejs-rails', '~> 0.7.1' @@ -281,7 +281,7 @@ group :metrics do gem 'influxdb', '~> 0.2', require: false # Prometheus - gem 'prometheus-client-mmap', '~>0.7.0.beta17' + gem 'prometheus-client-mmap', '~>0.7.0.beta18' gem 'raindrops', '~> 0.18' end @@ -324,9 +324,9 @@ group :development, :test do # Generate Fake data gem 'ffaker', '~> 2.4' - gem 'capybara', '~> 2.15.0' + gem 'capybara', '~> 2.15' gem 'capybara-screenshot', '~> 1.0.0' - gem 'poltergeist', '~> 1.9.0' + gem 'selenium-webdriver', '~> 3.5' gem 'spring', '~> 2.0.0' gem 'spring-commands-rspec', '~> 1.0.4' @@ -398,7 +398,7 @@ group :ed25519 do end # Gitaly GRPC client -gem 'gitaly-proto', '~> 0.48.0', require: 'gitaly' +gem 'gitaly-proto', '~> 0.51.0', require: 'gitaly' gem 'toml-rb', '~> 0.3.15', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 53efb1c76c2..ae145ca5f69 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -107,18 +107,19 @@ GEM capybara-screenshot (1.0.14) capybara (>= 1.0, < 3) launchy - carrierwave (1.1.0) + carrierwave (1.2.1) activemodel (>= 4.0.0) activesupport (>= 4.0.0) mime-types (>= 1.16) cause (0.1) charlock_holmes (0.7.5) + childprocess (0.7.0) + ffi (~> 1.0, >= 1.0.11) chronic (0.10.2) chronic_duration (0.10.6) numerizer (~> 0.1.1) chunky_png (1.3.5) citrus (3.0.2) - cliver (0.3.2) coderay (1.1.1) coercible (1.0.0) descendants_tracker (~> 0.0.1) @@ -273,7 +274,7 @@ GEM po_to_json (>= 1.0.0) rails (>= 3.2.0) gherkin-ruby (0.3.2) - gitaly-proto (0.48.0) + gitaly-proto (0.51.0) google-protobuf (~> 3.1) grpc (~> 1.0) github-linguist (4.7.6) @@ -291,7 +292,7 @@ GEM diff-lcs (~> 1.1) mime-types (>= 1.16) posix-spawn (~> 0.3) - gitlab-markup (1.6.2) + gitlab-markup (1.6.3) gitlab_omniauth-ldap (2.0.4) net-ldap (~> 0.16) omniauth (~> 1.3) @@ -604,11 +605,6 @@ GEM pg (0.18.4) po_to_json (1.0.1) json (>= 1.6.0) - poltergeist (1.9.0) - capybara (~> 2.1) - cliver (~> 0.3.1) - multi_json (~> 1.0) - websocket-driver (>= 0.2.0) posix-spawn (0.3.13) powerpack (0.1.1) premailer (1.10.4) @@ -623,7 +619,7 @@ GEM parser unparser procto (0.0.3) - prometheus-client-mmap (0.7.0.beta17) + prometheus-client-mmap (0.7.0.beta18) mmap2 (~> 2.2, >= 2.2.7) pry (0.10.4) coderay (~> 1.1.0) @@ -818,6 +814,9 @@ GEM activesupport (>= 3.1) select2-rails (3.5.9.3) thor (~> 0.14) + selenium-webdriver (3.5.0) + childprocess (~> 0.5) + rubyzip (~> 1.0) sentry-raven (2.5.3) faraday (>= 0.7.6, < 1.0) settingslogic (2.0.9) @@ -949,9 +948,6 @@ GEM hashdiff webpack-rails (0.9.10) railties (>= 3.2.0) - websocket-driver (0.6.3) - websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.2) wikicloth (0.8.1) builder expression_parser @@ -988,9 +984,9 @@ DEPENDENCIES browser (~> 2.2) bullet (~> 5.5.0) bundler-audit (~> 0.5.0) - capybara (~> 2.15.0) + capybara (~> 2.15) capybara-screenshot (~> 1.0.0) - carrierwave (~> 1.1) + carrierwave (~> 1.2) charlock_holmes (~> 0.7.5) chronic (~> 0.10.2) chronic_duration (~> 0.10.6) @@ -1030,7 +1026,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.2.0) - gitaly-proto (~> 0.48.0) + gitaly-proto (~> 0.51.0) github-linguist (~> 4.7.0) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-markup (~> 1.6.2) @@ -1104,9 +1100,8 @@ DEPENDENCIES peek-redis (~> 1.2.0) peek-sidekiq (~> 1.0.3) pg (~> 0.18.2) - poltergeist (~> 1.9.0) premailer-rails (~> 1.9.7) - prometheus-client-mmap (~> 0.7.0.beta17) + prometheus-client-mmap (~> 0.7.0.beta18) pry-byebug (~> 3.4.1) pry-rails (~> 0.3.4) rack-attack (~> 4.4.1) @@ -1150,6 +1145,7 @@ DEPENDENCIES scss_lint (~> 0.54.0) seed-fu (~> 2.3.5) select2-rails (~> 3.5.9) + selenium-webdriver (~> 3.5) sentry-raven (~> 2.5.3) settingslogic (~> 2.0.9) sham_rack (~> 1.3.6) diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 242b3e2b990..d963101028a 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -16,6 +16,7 @@ const Api = { usersPath: '/api/:version/users.json', commitPath: '/api/:version/projects/:id/repository/commits', branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch', + createBranchPath: '/api/:version/projects/:id/repository/branches', group(groupId, callback) { const url = Api.buildUrl(Api.groupPath) diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index 4d2d4db7c0e..0f28bd233ac 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -1,8 +1,9 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-param-reassign, quotes, prefer-template, no-var, one-var, no-unused-vars, one-var-declaration-per-line, no-void, consistent-return, no-empty, max-len */ +/* eslint-disable no-param-reassign, prefer-template, no-var, no-void, consistent-return */ + import AccessorUtilities from './lib/utils/accessor'; -window.Autosave = (function() { - function Autosave(field, key, resource) { +export default class Autosave { + constructor(field, key, resource) { this.field = field; this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); this.resource = resource; @@ -12,14 +13,10 @@ window.Autosave = (function() { this.key = 'autosave/' + key; this.field.data('autosave', this); this.restore(); - this.field.on('input', (function(_this) { - return function() { - return _this.save(); - }; - })(this)); + this.field.on('input', () => this.save()); } - Autosave.prototype.restore = function() { + restore() { var text; if (!this.isLocalStorageAvailable) return; @@ -40,9 +37,9 @@ window.Autosave = (function() { field.dispatchEvent(event); } } - }; + } - Autosave.prototype.save = function() { + save() { var text; text = this.field.val(); @@ -51,15 +48,11 @@ window.Autosave = (function() { } return this.reset(); - }; + } - Autosave.prototype.reset = function() { + reset() { if (!this.isLocalStorageAvailable) return; return window.localStorage.removeItem(this.key); - }; - - return Autosave; -})(); - -export default window.Autosave; + } +} diff --git a/app/assets/javascripts/behaviors/autosize.js b/app/assets/javascripts/behaviors/autosize.js index e00af4b2fa8..add43b81f6d 100644 --- a/app/assets/javascripts/behaviors/autosize.js +++ b/app/assets/javascripts/behaviors/autosize.js @@ -1,8 +1,8 @@ -import autosize from 'vendor/autosize'; +import Autosize from 'autosize'; document.addEventListener('DOMContentLoaded', () => { const autosizeEls = document.querySelectorAll('.js-autosize'); - autosize(autosizeEls); - autosize.update(autosizeEls); + Autosize(autosizeEls); + Autosize.update(autosizeEls); }); diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index c1f902a785a..9ae5e270a4b 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -1,7 +1,5 @@ /* eslint-disable comma-dangle, space-before-function-paren, no-new */ -/* global IssuableContext */ /* global MilestoneSelect */ -/* global LabelsSelect */ /* global Sidebar */ import Vue from 'vue'; @@ -11,6 +9,8 @@ import AssigneeTitle from '../../sidebar/components/assignees/assignee_title'; import Assignees from '../../sidebar/components/assignees/assignees'; import DueDateSelectors from '../../due_date_select'; import './sidebar/remove_issue'; +import IssuableContext from '../../issuable_context'; +import LabelsSelect from '../../labels_select'; const Store = gl.issueBoards.BoardsStore; diff --git a/app/assets/javascripts/clusters.js b/app/assets/javascripts/clusters.js index 180aa30e98c..c9fef94efea 100644 --- a/app/assets/javascripts/clusters.js +++ b/app/assets/javascripts/clusters.js @@ -1,6 +1,7 @@ /* globals Flash */ import Visibility from 'visibilityjs'; import axios from 'axios'; +import setAxiosCsrfToken from './lib/utils/axios_utils'; import Poll from './lib/utils/poll'; import { s__ } from './locale'; import initSettingsPanels from './settings_panels'; @@ -17,6 +18,7 @@ import Flash from './flash'; class ClusterService { constructor(options = {}) { this.options = options; + setAxiosCsrfToken(); } fetchData() { return axios.get(this.options.endpoint); @@ -64,19 +66,16 @@ export default class Clusters { this.poll = new Poll({ resource: this.service, method: 'fetchData', - successCallback: (data) => { - const { status, status_reason } = data.data; - this.updateContainer(status, status_reason); - }, - errorCallback: () => { - Flash(s__('ClusterIntegration|Something went wrong on our end.')); - }, + successCallback: data => this.handleSuccess(data), + errorCallback: () => Clusters.handleError(), }); if (!Visibility.hidden()) { this.poll.makeRequest(); } else { - this.service.fetchData(); + this.service.fetchData() + .then(data => this.handleSuccess(data)) + .catch(() => Clusters.handleError()); } Visibility.change(() => { @@ -88,6 +87,15 @@ export default class Clusters { }); } + static handleError() { + Flash(s__('ClusterIntegration|Something went wrong on our end.')); + } + + handleSuccess(data) { + const { status, status_reason } = data.data; + this.updateContainer(status, status_reason); + } + hideAll() { this.errorContainer.classList.add('hidden'); this.successContainer.classList.add('hidden'); diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 970e83c0ecb..760fb0cdf67 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -1,9 +1,9 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, wrap-iife, no-shadow, consistent-return, one-var, one-var-declaration-per-line, camelcase, default-case, no-new, quotes, no-duplicate-case, no-case-declarations, no-fallthrough, max-len */ /* global ProjectSelect */ -/* global IssuableIndex */ +import IssuableIndex from './issuable_index'; /* global Milestone */ -/* global IssuableForm */ -/* global LabelsSelect */ +import IssuableForm from './issuable_form'; +import LabelsSelect from './labels_select'; /* global MilestoneSelect */ /* global NewBranchForm */ /* global NotificationsForm */ @@ -16,7 +16,7 @@ import CILintEditor from './ci_lint_editor'; import groupsSelect from './groups_select'; /* global Search */ /* global Admin */ -/* global NamespaceSelects */ +import NamespaceSelect from './namespace_select'; /* global NewCommitForm */ /* global NewBranchForm */ /* global Project */ @@ -173,7 +173,7 @@ import Diff from './diff'; filteredSearchManager.setup(); } const pagePrefix = page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_'; - IssuableIndex.init(pagePrefix); + new IssuableIndex(pagePrefix); shortcut_handler = new ShortcutsNavigation(); new UsersSelect(); @@ -231,12 +231,16 @@ import Diff from './diff'; case 'projects:milestones:new': case 'projects:milestones:edit': case 'projects:milestones:update': + new ZenMode(); + new DueDateSelectors(); + new GLForm($('.milestone-form'), true); + break; case 'groups:milestones:new': case 'groups:milestones:edit': case 'groups:milestones:update': new ZenMode(); new DueDateSelectors(); - new GLForm($('.milestone-form'), true); + new GLForm($('.milestone-form'), false); break; case 'projects:compare:show': new Diff(); @@ -571,7 +575,8 @@ import Diff from './diff'; new UsersSelect(); break; case 'projects': - new NamespaceSelects(); + document.querySelectorAll('.js-namespace-select') + .forEach(dropdown => new NamespaceSelect({ dropdown })); break; case 'labels': switch (path[2]) { diff --git a/app/assets/javascripts/droplab/plugins/filter.js b/app/assets/javascripts/droplab/plugins/filter.js index d6a1aadd49c..404d707cf7a 100644 --- a/app/assets/javascripts/droplab/plugins/filter.js +++ b/app/assets/javascripts/droplab/plugins/filter.js @@ -79,8 +79,6 @@ const Filter = { this.hook.trigger.addEventListener('keydown.dl', this.eventWrapper.debounceKeydown); this.hook.trigger.addEventListener('mousedown.dl', this.eventWrapper.debounceKeydown); - - this.debounceKeydown({ detail: { hook: this.hook } }); }, destroy: function destroy() { diff --git a/app/assets/javascripts/droplab/utils.js b/app/assets/javascripts/droplab/utils.js index 4da7344604e..bfe056a0fcc 100644 --- a/app/assets/javascripts/droplab/utils.js +++ b/app/assets/javascripts/droplab/utils.js @@ -30,7 +30,7 @@ const utils = { }, isDropDownParts(target) { - if (!target || target.tagName === 'HTML') return false; + if (!target || !target.hasAttribute || target.tagName === 'HTML') return false; return target.hasAttribute(DATA_TRIGGER) || target.hasAttribute(DATA_DROPDOWN); }, }; diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 7a17adcd44e..b7747ee3f83 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -119,11 +119,9 @@ export default function dropzoneInput(form) { // removeAllFiles(true) stops uploading files (if any) // and remove them from dropzone files queue. $cancelButton.on('click', (e) => { - const target = e.target.closest('.js-main-target-form').querySelector('.div-dropzone'); - e.preventDefault(); e.stopPropagation(); - Dropzone.forElement(target).removeAllFiles(true); + Dropzone.forElement($formDropzone.get(0)).removeAllFiles(true); }); // If 'error' event is fired, we store a failed files, diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 6de01fa53d0..fc0308b81ba 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -421,7 +421,11 @@ export default { </script> <template> <div - :class="{ 'js-child-row environment-child-row': model.isChildren, 'folder-row': model.isFolder, 'gl-responsive-table-row': !model.isFolder }" + class="gl-responsive-table-row" + :class="{ + 'js-child-row environment-child-row': model.isChildren, + 'folder-row': model.isFolder, + }" role="row"> <div class="table-section section-10" role="gridcell"> <div @@ -495,15 +499,16 @@ export default { </a> </div> - <div class="table-section section-25" role="gridcell"> + <div + v-if="!model.isFolder" + class="table-section section-25" role="gridcell"> <div - v-if="!model.isFolder" role="rowheader" class="table-mobile-header"> Commit </div> <div - v-if="!model.isFolder && hasLastDeploymentKey" + v-if="hasLastDeploymentKey" class="js-commit-component table-mobile-content"> <commit-component :tag="commitTag" @@ -514,21 +519,22 @@ export default { :author="commitAuthor"/> </div> <div - v-if="!model.isFolder && !hasLastDeploymentKey" + v-if="!hasLastDeploymentKey" class="commit-title table-mobile-content"> No deployments yet </div> </div> - <div class="table-section section-10" role="gridcell"> + <div + v-if="!model.isFolder" + class="table-section section-10" role="gridcell"> <div - v-if="!model.isFolder" role="rowheader" class="table-mobile-header"> Updated </div> <span - v-if="!model.isFolder && canShowDate" + v-if="canShowDate" class="environment-created-date-timeago table-mobile-content"> {{createdDate}} </span> diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index e8d8fef8579..c4202f92443 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -1,6 +1,7 @@ /* eslint-disable func-names, no-underscore-dangle, space-before-function-paren, no-var, one-var, one-var-declaration-per-line, prefer-rest-params, max-len, vars-on-top, wrap-iife, no-unused-vars, quotes, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, comma-dangle, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func, no-mixed-operators */ /* global fuzzaldrinPlus */ import _ from 'underscore'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { isObject } from './lib/utils/type_utility'; var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote, GitLabDropdownInput; diff --git a/app/assets/javascripts/graphs/stat_graph_contributors.js b/app/assets/javascripts/graphs/stat_graph_contributors.js index cdc4fcf6573..e7232ca3712 100644 --- a/app/assets/javascripts/graphs/stat_graph_contributors.js +++ b/app/assets/javascripts/graphs/stat_graph_contributors.js @@ -4,6 +4,7 @@ import _ from 'underscore'; import d3 from 'd3'; import { ContributorsGraph, ContributorsAuthorGraph, ContributorsMasterGraph } from './stat_graph_contributors_graph'; import ContributorsStatGraphUtil from './stat_graph_contributors_util'; +import { n__ } from '../locale'; export default (function() { function ContributorsStatGraph() {} @@ -44,7 +45,7 @@ export default (function() { commits = $('<span/>', { "class": 'graph-author-commits-count' }); - commits.text(author.commits + " commits"); + commits.text(n__('%d commit', '%d commits', author.commits)); return $('<span/>').append(commits); }; diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index ea2e2205077..33a352e158a 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -7,10 +7,12 @@ import { highCountTrim } from '~/lib/utils/text_utility'; * @param {jQuery.Event} e * @param {String} count */ -$(document).on('todo:toggle', (e, count) => { - const parsedCount = parseInt(count, 10); - const $todoPendingCount = $('.todos-count'); +export default function initTodoToggle() { + $(document).on('todo:toggle', (e, count) => { + const parsedCount = parseInt(count, 10); + const $todoPendingCount = $('.todos-count'); - $todoPendingCount.text(highCountTrim(parsedCount)); - $todoPendingCount.toggleClass('hidden', parsedCount === 0); -}); + $todoPendingCount.text(highCountTrim(parsedCount)); + $todoPendingCount.toggleClass('hidden', parsedCount === 0); + }); +} diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js index 5b4ca94ed30..1dc70872d92 100644 --- a/app/assets/javascripts/importer_status.js +++ b/app/assets/javascripts/importer_status.js @@ -1,83 +1,81 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, camelcase, no-var, one-var, one-var-declaration-per-line, prefer-template, quotes, object-shorthand, comma-dangle, no-unused-vars, prefer-arrow-callback, no-else-return, vars-on-top, no-new, max-len */ +class ImporterStatus { + constructor(jobsUrl, importUrl) { + this.jobsUrl = jobsUrl; + this.importUrl = importUrl; + this.initStatusPage(); + this.setAutoUpdate(); + } -(function() { - window.ImporterStatus = (function() { - function ImporterStatus(jobs_url, import_url) { - this.jobs_url = jobs_url; - this.import_url = import_url; - this.initStatusPage(); - this.setAutoUpdate(); - } + initStatusPage() { + $('.js-add-to-import') + .off('click') + .on('click', (event) => { + const $btn = $(event.currentTarget); + const $tr = $btn.closest('tr'); + const $targetField = $tr.find('.import-target'); + const $namespaceInput = $targetField.find('.js-select-namespace option:selected'); + const id = $tr.attr('id').replace('repo_', ''); + let targetNamespace; + let newName; + if ($namespaceInput.length > 0) { + targetNamespace = $namespaceInput[0].innerHTML; + newName = $targetField.find('#path').prop('value'); + $targetField.empty().append(`${targetNamespace}/${newName}`); + } + $btn.disable().addClass('is-loading'); - ImporterStatus.prototype.initStatusPage = function() { - $('.js-add-to-import').off('click').on('click', (function(_this) { - return function(e) { - var $btn, $namespace_input, $target_field, $tr, id, target_namespace, newName; - $btn = $(e.currentTarget); - $tr = $btn.closest('tr'); - $target_field = $tr.find('.import-target'); - $namespace_input = $target_field.find('.js-select-namespace option:selected'); - id = $tr.attr('id').replace('repo_', ''); - target_namespace = null; - newName = null; - if ($namespace_input.length > 0) { - target_namespace = $namespace_input[0].innerHTML; - newName = $target_field.find('#path').prop('value'); - $target_field.empty().append(target_namespace + "/" + newName); - } - $btn.disable().addClass('is-loading'); - return $.post(_this.import_url, { - repo_id: id, - target_namespace: target_namespace, - new_name: newName - }, { - dataType: 'script' - }); - }; - })(this)); - return $('.js-import-all').off('click').on('click', function(e) { - var $btn; - $btn = $(this); + return $.post(this.importUrl, { + repo_id: id, + target_namespace: targetNamespace, + new_name: newName, + }, { + dataType: 'script', + }); + }); + + $('.js-import-all') + .off('click') + .on('click', function onClickImportAll() { + const $btn = $(this); $btn.disable().addClass('is-loading'); - return $('.js-add-to-import').each(function() { + return $('.js-add-to-import').each(function triggerAddImport() { return $(this).trigger('click'); }); }); - }; + } + + setAutoUpdate() { + return setInterval(() => $.get(this.jobsUrl, data => $.each(data, (i, job) => { + const jobItem = $(`#project_${job.id}`); + const statusField = jobItem.find('.job-status'); - ImporterStatus.prototype.setAutoUpdate = function() { - return setInterval(((function(_this) { - return function() { - return $.get(_this.jobs_url, function(data) { - return $.each(data, function(i, job) { - var job_item, status_field; - job_item = $("#project_" + job.id); - status_field = job_item.find(".job-status"); - if (job.import_status === 'finished') { - job_item.removeClass("active").addClass("success"); - return status_field.html('<span><i class="fa fa-check"></i> done</span>'); - } else if (job.import_status === 'scheduled') { - return status_field.html("<i class='fa fa-spinner fa-spin'></i> scheduled"); - } else if (job.import_status === 'started') { - return status_field.html("<i class='fa fa-spinner fa-spin'></i> started"); - } else { - return status_field.html(job.import_status); - } - }); - }); - }; - })(this)), 4000); - }; + const spinner = '<i class="fa fa-spinner fa-spin"></i>'; - return ImporterStatus; - })(); + switch (job.import_status) { + case 'finished': + jobItem.removeClass('active').addClass('success'); + statusField.html('<span><i class="fa fa-check"></i> done</span>'); + break; + case 'scheduled': + statusField.html(`${spinner} scheduled`); + break; + case 'started': + statusField.html(`${spinner} started`); + break; + default: + statusField.html(job.import_status); + break; + } + })), 4000); + } +} - $(function() { - if ($('.js-importer-status').length) { - var jobsImportPath = $('.js-importer-status').data('jobs-import-path'); - var importPath = $('.js-importer-status').data('import-path'); +// eslint-disable-next-line consistent-return +export default function initImporterStatus() { + const importerStatus = document.querySelector('.js-importer-status'); - new window.ImporterStatus(jobsImportPath, importPath); - } - }); -}).call(window); + if (importerStatus) { + const data = importerStatus.dataset; + return new ImporterStatus(data.jobsImportPath, data.importPath); + } +} diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js index 32a1a269f9a..1191e0b895e 100644 --- a/app/assets/javascripts/init_issuable_sidebar.js +++ b/app/assets/javascripts/init_issuable_sidebar.js @@ -1,7 +1,7 @@ /* eslint-disable no-new */ /* global MilestoneSelect */ -/* global LabelsSelect */ -/* global IssuableContext */ +import LabelsSelect from './labels_select'; +import IssuableContext from './issuable_context'; /* global Sidebar */ import DueDateSelectors from './due_date_select'; diff --git a/app/assets/javascripts/init_legacy_filters.js b/app/assets/javascripts/init_legacy_filters.js index 1211c2c802c..1b265721581 100644 --- a/app/assets/javascripts/init_legacy_filters.js +++ b/app/assets/javascripts/init_legacy_filters.js @@ -1,15 +1,15 @@ /* eslint-disable no-new */ -/* global LabelsSelect */ +import LabelsSelect from './labels_select'; /* global MilestoneSelect */ -/* global IssueStatusSelect */ /* global SubscriptionSelect */ import UsersSelect from './users_select'; +import issueStatusSelect from './issue_status_select'; export default () => { new UsersSelect(); new LabelsSelect(); new MilestoneSelect(); - new IssueStatusSelect(); + issueStatusSelect(); new SubscriptionSelect(); }; diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js index eb15949603f..b124fafec70 100644 --- a/app/assets/javascripts/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -1,5 +1,4 @@ /* eslint-disable comma-dangle, quotes, consistent-return, func-names, array-callback-return, space-before-function-paren, prefer-arrow-callback, max-len, no-unused-expressions, no-sequences, no-underscore-dangle, no-unused-vars, no-param-reassign */ -/* global IssuableIndex */ import _ from 'underscore'; import Flash from './flash'; diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js index 0e8a0519928..af6358953cf 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js @@ -1,10 +1,12 @@ /* eslint-disable class-methods-use-this, no-new */ -/* global LabelsSelect */ /* global MilestoneSelect */ -/* global IssueStatusSelect */ /* global SubscriptionSelect */ import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; +import './milestone_select'; +import issueStatusSelect from './issue_status_select'; +import './subscription_select'; +import LabelsSelect from './labels_select'; const HIDDEN_CLASS = 'hidden'; const DISABLED_CONTENT_CLASS = 'disabled-content'; @@ -45,7 +47,7 @@ export default class IssuableBulkUpdateSidebar { initDropdowns() { new LabelsSelect(); new MilestoneSelect(); - new IssueStatusSelect(); + issueStatusSelect(); new SubscriptionSelect(); } diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index 73791edaebb..da99394ff90 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -1,33 +1,32 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-new, comma-dangle, quotes, prefer-arrow-callback, consistent-return, one-var, no-var, one-var-declaration-per-line, no-underscore-dangle, max-len */ import Cookies from 'js-cookie'; import bp from './breakpoints'; import UsersSelect from './users_select'; -const PARTICIPANTS_ROW_COUNT = 7; - -(function() { - this.IssuableContext = (function() { - function IssuableContext(currentUser) { - this.initParticipants(); - new UsersSelect(currentUser); - $('select.select2').select2({ - width: 'resolve', - dropdownAutoWidth: true - }); - $(".issuable-sidebar .inline-update").on("change", "select", function() { - return $(this).submit(); - }); - $(".issuable-sidebar .inline-update").on("change", ".js-assignee", function() { - return $(this).submit(); - }); - $(document).off('click', '.issuable-sidebar .dropdown-content a').on('click', '.issuable-sidebar .dropdown-content a', function(e) { - return e.preventDefault(); - }); - $(document).off('click', '.edit-link').on('click', '.edit-link', function(e) { - var $block, $selectbox; +export default class IssuableContext { + constructor(currentUser) { + this.userSelect = new UsersSelect(currentUser); + + $('select.select2').select2({ + width: 'resolve', + dropdownAutoWidth: true, + }); + + $('.issuable-sidebar .inline-update').on('change', 'select', function onClickSelect() { + return $(this).submit(); + }); + $('.issuable-sidebar .inline-update').on('change', '.js-assignee', function onClickAssignee() { + return $(this).submit(); + }); + $(document) + .off('click', '.issuable-sidebar .dropdown-content a') + .on('click', '.issuable-sidebar .dropdown-content a', e => e.preventDefault()); + + $(document) + .off('click', '.edit-link') + .on('click', '.edit-link', function onClickEdit(e) { e.preventDefault(); - $block = $(this).parents('.block'); - $selectbox = $block.find('.selectbox'); + const $block = $(this).parents('.block'); + const $selectbox = $block.find('.selectbox'); if ($selectbox.is(':visible')) { $selectbox.hide(); $block.find('.value').show(); @@ -35,46 +34,18 @@ const PARTICIPANTS_ROW_COUNT = 7; $selectbox.show(); $block.find('.value').hide(); } - if ($selectbox.is(':visible')) { - return setTimeout(function() { - return $block.find('.dropdown-menu-toggle').trigger('click'); - }, 0); - } - }); - window.addEventListener('beforeunload', function() { - // collapsed_gutter cookie hides the sidebar - var bpBreakpoint = bp.getBreakpointSize(); - if (bpBreakpoint === 'xs' || bpBreakpoint === 'sm') { - Cookies.set('collapsed_gutter', true); - } - }); - } - IssuableContext.prototype.initParticipants = function() { - $(document).on('click', '.js-participants-more', this.toggleHiddenParticipants); - return $('.js-participants-author').each(function(i) { - if (i >= PARTICIPANTS_ROW_COUNT) { - return $(this).addClass('js-participants-hidden').hide(); + if ($selectbox.is(':visible')) { + setTimeout(() => $block.find('.dropdown-menu-toggle').trigger('click'), 0); } }); - }; - - IssuableContext.prototype.toggleHiddenParticipants = function() { - const currentText = $(this).text().trim(); - const lessText = $(this).data('less-text'); - const originalText = $(this).data('original-text'); - if (currentText === originalText) { - $(this).text(lessText); - - if (gl.lazyLoader) gl.lazyLoader.loadCheck(); - } else { - $(this).text(originalText); + window.addEventListener('beforeunload', () => { + // collapsed_gutter cookie hides the sidebar + const bpBreakpoint = bp.getBreakpointSize(); + if (bpBreakpoint === 'xs' || bpBreakpoint === 'sm') { + Cookies.set('collapsed_gutter', true); } - - $('.js-participants-hidden').toggle(); - }; - - return IssuableContext; - })(); -}).call(window); + }); + } +} diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index cd2562bc6a9..57dcaa0e1ac 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -1,110 +1,107 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, no-useless-escape, no-new, quotes, object-shorthand, no-unused-vars, comma-dangle, no-alert, consistent-return, no-else-return, prefer-template, one-var, one-var-declaration-per-line, curly, max-len */ +/* eslint-disable func-names, prefer-rest-params, wrap-iife, no-use-before-define, no-useless-escape, no-new, object-shorthand, no-unused-vars, comma-dangle, no-alert, consistent-return, no-else-return, prefer-template, one-var, one-var-declaration-per-line, curly, max-len */ /* global GitLab */ -/* global Autosave */ import Pikaday from 'pikaday'; +import Autosave from './autosave'; import UsersSelect from './users_select'; import GfmAutoComplete from './gfm_auto_complete'; import ZenMode from './zen_mode'; import { parsePikadayDate, pikadayToString } from './lib/utils/datefix'; -(function() { - this.IssuableForm = (function() { - IssuableForm.prototype.wipRegex = /^\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i; - - function IssuableForm(form) { - var $issuableDueDate, calendar; - this.form = form; - this.toggleWip = this.toggleWip.bind(this); - this.renderWipExplanation = this.renderWipExplanation.bind(this); - this.resetAutosave = this.resetAutosave.bind(this); - this.handleSubmit = this.handleSubmit.bind(this); - new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup(); - new UsersSelect(); - new ZenMode(); - this.titleField = this.form.find("input[name*='[title]']"); - this.descriptionField = this.form.find("textarea[name*='[description]']"); - if (!(this.titleField.length && this.descriptionField.length)) { - return; - } - this.initAutosave(); - this.form.on("submit", this.handleSubmit); - this.form.on("click", ".btn-cancel", this.resetAutosave); - this.initWip(); - $issuableDueDate = $('#issuable-due-date'); - if ($issuableDueDate.length) { - calendar = new Pikaday({ - field: $issuableDueDate.get(0), - theme: 'gitlab-theme animate-picker', - format: 'yyyy-mm-dd', - container: $issuableDueDate.parent().get(0), - parse: dateString => parsePikadayDate(dateString), - toString: date => pikadayToString(date), - onSelect: function(dateText) { - $issuableDueDate.val(calendar.toString(dateText)); - } - }); - calendar.setDate(parsePikadayDate($issuableDueDate.val())); - } +export default class IssuableForm { + constructor(form) { + this.form = form; + this.toggleWip = this.toggleWip.bind(this); + this.renderWipExplanation = this.renderWipExplanation.bind(this); + this.resetAutosave = this.resetAutosave.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.wipRegex = /^\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i; + + new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup(); + new UsersSelect(); + new ZenMode(); + + this.titleField = this.form.find('input[name*="[title]"]'); + this.descriptionField = this.form.find('textarea[name*="[description]"]'); + if (!(this.titleField.length && this.descriptionField.length)) { + return; } - IssuableForm.prototype.initAutosave = function() { - new Autosave(this.titleField, [document.location.pathname, document.location.search, "title"]); - return new Autosave(this.descriptionField, [document.location.pathname, document.location.search, "description"]); - }; - - IssuableForm.prototype.handleSubmit = function() { - return this.resetAutosave(); - }; - - IssuableForm.prototype.resetAutosave = function() { - this.titleField.data("autosave").reset(); - return this.descriptionField.data("autosave").reset(); - }; - - IssuableForm.prototype.initWip = function() { - this.$wipExplanation = this.form.find(".js-wip-explanation"); - this.$noWipExplanation = this.form.find(".js-no-wip-explanation"); - if (!(this.$wipExplanation.length && this.$noWipExplanation.length)) { - return; - } - this.form.on("click", ".js-toggle-wip", this.toggleWip); - this.titleField.on("keyup blur", this.renderWipExplanation); - return this.renderWipExplanation(); - }; - - IssuableForm.prototype.workInProgress = function() { - return this.wipRegex.test(this.titleField.val()); - }; - - IssuableForm.prototype.renderWipExplanation = function() { - if (this.workInProgress()) { - this.$wipExplanation.show(); - return this.$noWipExplanation.hide(); - } else { - this.$wipExplanation.hide(); - return this.$noWipExplanation.show(); - } - }; - - IssuableForm.prototype.toggleWip = function(event) { - event.preventDefault(); - if (this.workInProgress()) { - this.removeWip(); - } else { - this.addWip(); - } - return this.renderWipExplanation(); - }; - - IssuableForm.prototype.removeWip = function() { - return this.titleField.val(this.titleField.val().replace(this.wipRegex, "")); - }; - - IssuableForm.prototype.addWip = function() { - return this.titleField.val("WIP: " + (this.titleField.val())); - }; - - return IssuableForm; - })(); -}).call(window); + this.initAutosave(); + this.form.on('submit', this.handleSubmit); + this.form.on('click', '.btn-cancel', this.resetAutosave); + this.initWip(); + + const $issuableDueDate = $('#issuable-due-date'); + + if ($issuableDueDate.length) { + const calendar = new Pikaday({ + field: $issuableDueDate.get(0), + theme: 'gitlab-theme animate-picker', + format: 'yyyy-mm-dd', + container: $issuableDueDate.parent().get(0), + parse: dateString => parsePikadayDate(dateString), + toString: date => pikadayToString(date), + onSelect: dateText => $issuableDueDate.val(calendar.toString(dateText)), + }); + calendar.setDate(parsePikadayDate($issuableDueDate.val())); + } + } + + initAutosave() { + new Autosave(this.titleField, [document.location.pathname, document.location.search, 'title']); + return new Autosave(this.descriptionField, [document.location.pathname, document.location.search, 'description']); + } + + handleSubmit() { + return this.resetAutosave(); + } + + resetAutosave() { + this.titleField.data('autosave').reset(); + return this.descriptionField.data('autosave').reset(); + } + + initWip() { + this.$wipExplanation = this.form.find('.js-wip-explanation'); + this.$noWipExplanation = this.form.find('.js-no-wip-explanation'); + if (!(this.$wipExplanation.length && this.$noWipExplanation.length)) { + return; + } + this.form.on('click', '.js-toggle-wip', this.toggleWip); + this.titleField.on('keyup blur', this.renderWipExplanation); + return this.renderWipExplanation(); + } + + workInProgress() { + return this.wipRegex.test(this.titleField.val()); + } + + renderWipExplanation() { + if (this.workInProgress()) { + this.$wipExplanation.show(); + return this.$noWipExplanation.hide(); + } else { + this.$wipExplanation.hide(); + return this.$noWipExplanation.show(); + } + } + + toggleWip(event) { + event.preventDefault(); + if (this.workInProgress()) { + this.removeWip(); + } else { + this.addWip(); + } + return this.renderWipExplanation(); + } + + removeWip() { + return this.titleField.val(this.titleField.val().replace(this.wipRegex, '')); + } + + addWip() { + this.titleField.val(`WIP: ${(this.titleField.val())}`); + } +} diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js index ece0220c927..0b123a11a3b 100644 --- a/app/assets/javascripts/issuable_index.js +++ b/app/assets/javascripts/issuable_index.js @@ -1,171 +1,42 @@ -/* eslint-disable no-param-reassign, func-names, no-var, camelcase, no-unused-vars, object-shorthand, space-before-function-paren, no-return-assign, comma-dangle, consistent-return, one-var, one-var-declaration-per-line, quotes, prefer-template, prefer-arrow-callback, wrap-iife, max-len */ -/* global IssuableIndex */ -import _ from 'underscore'; import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; -((global) => { - var issuable_created; - - issuable_created = false; - - global.IssuableIndex = { - init: function(pagePrefix) { - IssuableIndex.initTemplates(); - IssuableIndex.initSearch(); - IssuableIndex.initBulkUpdate(pagePrefix); - IssuableIndex.initResetFilters(); - IssuableIndex.resetIncomingEmailToken(); - IssuableIndex.initLabelFilterRemove(); - }, - initTemplates: function() { - return IssuableIndex.labelRow = _.template('<% _.each(labels, function(label){ %> <span class="label-row btn-group" role="group" aria-label="<%- label.title %>" style="color: <%- label.text_color %>;"> <a href="#" class="btn btn-transparent has-tooltip" style="background-color: <%- label.color %>;" title="<%- label.description %>" data-container="body"> <%- label.title %> </a> <button type="button" class="btn btn-transparent label-remove js-label-filter-remove" style="background-color: <%- label.color %>;" data-label="<%- label.title %>"> <i class="fa fa-times"></i> </button> </span> <% }); %>'); - }, - initSearch: function() { - const $searchInput = $('#issuable_search'); - - IssuableIndex.initSearchState($searchInput); - - // `immediate` param set to false debounces on the `trailing` edge, lets user finish typing - const debouncedExecSearch = _.debounce(IssuableIndex.executeSearch, 1000, false); - - $searchInput.off('keyup').on('keyup', debouncedExecSearch); - - // ensures existing filters are preserved when manually submitted - $('#issuable_search_form').on('submit', (e) => { - e.preventDefault(); - debouncedExecSearch(e); - }); - }, - initSearchState: function($searchInput) { - const currentSearchVal = $searchInput.val(); - - IssuableIndex.searchState = { - elem: $searchInput, - current: currentSearchVal - }; - - IssuableIndex.maybeFocusOnSearch(); - }, - accessSearchPristine: function(set) { - // store reference to previous value to prevent search on non-mutating keyup - const state = IssuableIndex.searchState; - const currentSearchVal = state.elem.val(); - - if (set) { - state.current = currentSearchVal; - } else { - return state.current === currentSearchVal; - } - }, - maybeFocusOnSearch: function() { - const currentSearchVal = IssuableIndex.searchState.current; - if (currentSearchVal && currentSearchVal !== '') { - const queryLength = currentSearchVal.length; - const $searchInput = IssuableIndex.searchState.elem; - - /* The following ensures that the cursor is initially placed at - * the end of search input when focus is applied. It accounts - * for differences in browser implementations of `setSelectionRange` - * and cursor placement for elements in focus. - */ - $searchInput.focus(); - if ($searchInput.setSelectionRange) { - $searchInput.setSelectionRange(queryLength, queryLength); - } else { - $searchInput.val(currentSearchVal); - } - } - }, - executeSearch: function(e) { - const $search = $('#issuable_search'); - const $searchName = $search.attr('name'); - const $searchValue = $search.val(); - const $filtersForm = $('.js-filter-form'); - const $input = $(`input[name='${$searchName}']`, $filtersForm); - const isPristine = IssuableIndex.accessSearchPristine(); - - if (isPristine) { - return; - } - - if (!$input.length) { - $filtersForm.append(`<input type='hidden' name='${$searchName}' value='${_.escape($searchValue)}'/>`); - } else { - $input.val($searchValue); - } - - IssuableIndex.filterResults($filtersForm); - }, - initLabelFilterRemove: function() { - return $(document).off('click', '.js-label-filter-remove').on('click', '.js-label-filter-remove', function(e) { - var $button; - $button = $(this); - // Remove the label input box - $('input[name="label_name[]"]').filter(function() { - return this.value === $button.data('label'); - }).remove(); - // Submit the form to get new data - IssuableIndex.filterResults($('.filter-form')); - }); - }, - filterResults: (function(_this) { - return function(form) { - var formAction, formData, issuesUrl; - formData = form.serializeArray(); - formData = formData.filter(function(data) { - return data.value !== ''; - }); - formData = $.param(formData); - formAction = form.attr('action'); - issuesUrl = formAction; - issuesUrl += "" + (formAction.indexOf('?') === -1 ? '?' : '&'); - issuesUrl += formData; - return gl.utils.visitUrl(issuesUrl); - }; - })(this), - initResetFilters: function() { - $('.reset-filters').on('click', function(e) { - e.preventDefault(); - const target = e.target; - const $form = $(target).parents('.js-filter-form'); - const baseIssuesUrl = target.href; - - $form.attr('action', baseIssuesUrl); - gl.utils.visitUrl(baseIssuesUrl); +export default class IssuableIndex { + constructor(pagePrefix) { + this.initBulkUpdate(pagePrefix); + IssuableIndex.resetIncomingEmailToken(); + } + initBulkUpdate(pagePrefix) { + const userCanBulkUpdate = $('.issues-bulk-update').length > 0; + const alreadyInitialized = !!this.bulkUpdateSidebar; + + if (userCanBulkUpdate && !alreadyInitialized) { + IssuableBulkUpdateActions.init({ + prefixId: pagePrefix, }); - }, - initBulkUpdate: function(pagePrefix) { - const userCanBulkUpdate = $('.issues-bulk-update').length > 0; - const alreadyInitialized = !!this.bulkUpdateSidebar; - - if (userCanBulkUpdate && !alreadyInitialized) { - IssuableBulkUpdateActions.init({ - prefixId: pagePrefix, - }); - - this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar(); - } - }, - resetIncomingEmailToken: function() { - $('.incoming-email-token-reset').on('click', function(e) { - e.preventDefault(); - $.ajax({ - type: 'PUT', - url: $('.incoming-email-token-reset').attr('href'), - dataType: 'json', - success: function(response) { - $('#issue_email').val(response.new_issue_address).focus(); - }, - beforeSend: function() { - $('.incoming-email-token-reset').text('resetting...'); - }, - complete: function() { - $('.incoming-email-token-reset').text('reset it'); - } - }); - }); + this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar(); } - }; -})(window); + } + + static resetIncomingEmailToken() { + $('.incoming-email-token-reset').on('click', (e) => { + e.preventDefault(); + + $.ajax({ + type: 'PUT', + url: $('.incoming-email-token-reset').attr('href'), + dataType: 'json', + success(response) { + $('#issue_email').val(response.new_issue_address).focus(); + }, + beforeSend() { + $('.incoming-email-token-reset').text('resetting...'); + }, + complete() { + $('.incoming-email-token-reset').text('reset it'); + }, + }); + }); + } +} diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 3fc29f9a661..acd5730cf3c 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -6,7 +6,7 @@ import TaskList from './task_list'; import CreateMergeRequestDropdown from './create_merge_request_dropdown'; import IssuablesHelper from './helpers/issuables_helper'; -class Issue { +export default class Issue { constructor() { if ($('a.btn-close').length) { this.taskList = new TaskList({ @@ -147,5 +147,3 @@ class Issue { }); } } - -export default Issue; diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js index 56cb536dcde..03546f61d1f 100644 --- a/app/assets/javascripts/issue_status_select.js +++ b/app/assets/javascripts/issue_status_select.js @@ -1,34 +1,23 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, quotes, object-shorthand, no-unused-vars, no-shadow, one-var, one-var-declaration-per-line, comma-dangle, max-len */ -(function() { - this.IssueStatusSelect = (function() { - function IssueStatusSelect() { - $('.js-issue-status').each(function(i, el) { - var fieldName; - fieldName = $(el).data("field-name"); - return $(el).glDropdown({ - selectable: true, - fieldName: fieldName, - toggleLabel: (function(_this) { - return function(selected, el, instance) { - var $item, label; - label = 'Author'; - $item = instance.dropdown.find('.is-active'); - if ($item.length) { - label = $item.text(); - } - return label; - }; - })(this), - clicked: function(options) { - return options.e.preventDefault(); - }, - id: function(obj, el) { - return $(el).data("id"); - } - }); - }); - } - - return IssueStatusSelect; - })(); -}).call(window); +export default function issueStatusSelect() { + $('.js-issue-status').each((i, el) => { + const fieldName = $(el).data('field-name'); + return $(el).glDropdown({ + selectable: true, + fieldName, + toggleLabel(selected, element, instance) { + let label = 'Author'; + const $item = instance.dropdown.find('.is-active'); + if ($item.length) { + label = $item.text(); + } + return label; + }, + clicked(options) { + return options.e.preventDefault(); + }, + id(obj, element) { + return $(element).data('id'); + }, + }); + }); +} diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 84602cf9207..9b35efcb499 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -6,474 +6,470 @@ import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import DropdownUtils from './filtered_search/dropdown_utils'; import CreateLabelDropdown from './create_label'; -(function() { - this.LabelsSelect = (function() { - function LabelsSelect(els) { - var _this, $els; - _this = this; +export default class LabelsSelect { + constructor(els) { + var _this, $els; + _this = this; - $els = $(els); + $els = $(els); - if (!els) { - $els = $('.js-label-select'); - } + if (!els) { + $els = $('.js-label-select'); + } - $els.each(function(i, dropdown) { - var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer; - $dropdown = $(dropdown); - $dropdownContainer = $dropdown.closest('.labels-filter'); - $toggleText = $dropdown.find('.dropdown-toggle-text'); - namespacePath = $dropdown.data('namespace-path'); - projectPath = $dropdown.data('project-path'); - labelUrl = $dropdown.data('labels'); - issueUpdateURL = $dropdown.data('issueUpdate'); - selectedLabel = $dropdown.data('selected'); - if ((selectedLabel != null) && !$dropdown.hasClass('js-multiselect')) { - selectedLabel = selectedLabel.split(','); - } - showNo = $dropdown.data('show-no'); - showAny = $dropdown.data('show-any'); - showMenuAbove = $dropdown.data('showMenuAbove'); - defaultLabel = $dropdown.data('default-label'); - abilityName = $dropdown.data('ability-name'); - $selectbox = $dropdown.closest('.selectbox'); - $block = $selectbox.closest('.block'); - $form = $dropdown.closest('form, .js-issuable-update'); - $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); - $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip'); - $value = $block.find('.value'); - $loading = $block.find('.block-loading').fadeOut(); - fieldName = $dropdown.data('field-name'); - useId = $dropdown.is('.js-issuable-form-dropdown, .js-filter-bulk-update, .js-label-sidebar-dropdown'); - propertyName = useId ? 'id' : 'title'; - initialSelected = $selectbox - .find('input[name="' + $dropdown.data('field-name') + '"]') - .map(function () { - return this.value; - }).get(); - if (issueUpdateURL != null) { - issueURLSplit = issueUpdateURL.split('/'); - } - if (issueUpdateURL) { - labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>'); - labelNoneHTMLTemplate = '<span class="no-value">None</span>'; - } + $els.each(function(i, dropdown) { + var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer; + $dropdown = $(dropdown); + $dropdownContainer = $dropdown.closest('.labels-filter'); + $toggleText = $dropdown.find('.dropdown-toggle-text'); + namespacePath = $dropdown.data('namespace-path'); + projectPath = $dropdown.data('project-path'); + labelUrl = $dropdown.data('labels'); + issueUpdateURL = $dropdown.data('issueUpdate'); + selectedLabel = $dropdown.data('selected'); + if ((selectedLabel != null) && !$dropdown.hasClass('js-multiselect')) { + selectedLabel = selectedLabel.split(','); + } + showNo = $dropdown.data('show-no'); + showAny = $dropdown.data('show-any'); + showMenuAbove = $dropdown.data('showMenuAbove'); + defaultLabel = $dropdown.data('default-label'); + abilityName = $dropdown.data('ability-name'); + $selectbox = $dropdown.closest('.selectbox'); + $block = $selectbox.closest('.block'); + $form = $dropdown.closest('form, .js-issuable-update'); + $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); + $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip'); + $value = $block.find('.value'); + $loading = $block.find('.block-loading').fadeOut(); + fieldName = $dropdown.data('field-name'); + useId = $dropdown.is('.js-issuable-form-dropdown, .js-filter-bulk-update, .js-label-sidebar-dropdown'); + propertyName = useId ? 'id' : 'title'; + initialSelected = $selectbox + .find('input[name="' + $dropdown.data('field-name') + '"]') + .map(function () { + return this.value; + }).get(); + if (issueUpdateURL != null) { + issueURLSplit = issueUpdateURL.split('/'); + } + if (issueUpdateURL) { + labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>'); + labelNoneHTMLTemplate = '<span class="no-value">None</span>'; + } - $sidebarLabelTooltip.tooltip(); + $sidebarLabelTooltip.tooltip(); - if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) { - new CreateLabelDropdown($dropdown.closest('.dropdown').find('.dropdown-new-label'), namespacePath, projectPath); - } + if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) { + new CreateLabelDropdown($dropdown.closest('.dropdown').find('.dropdown-new-label'), namespacePath, projectPath); + } - saveLabelData = function() { - var data, selected; - selected = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "']").map(function() { - return this.value; - }).get(); + saveLabelData = function() { + var data, selected; + selected = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "']").map(function() { + return this.value; + }).get(); - if (_.isEqual(initialSelected, selected)) return; - initialSelected = selected; + if (_.isEqual(initialSelected, selected)) return; + initialSelected = selected; - data = {}; - data[abilityName] = {}; - data[abilityName].label_ids = selected; - if (!selected.length) { - data[abilityName].label_ids = ['']; + data = {}; + data[abilityName] = {}; + data[abilityName].label_ids = selected; + if (!selected.length) { + data[abilityName].label_ids = ['']; + } + $loading.removeClass('hidden').fadeIn(); + $dropdown.trigger('loading.gl.dropdown'); + return $.ajax({ + type: 'PUT', + url: issueUpdateURL, + dataType: 'JSON', + data: data + }).done(function(data) { + var labelCount, template, labelTooltipTitle, labelTitles; + $loading.fadeOut(); + $dropdown.trigger('loaded.gl.dropdown'); + $selectbox.hide(); + data.issueURLSplit = issueURLSplit; + labelCount = 0; + if (data.labels.length) { + template = labelHTMLTemplate(data); + labelCount = data.labels.length; } - $loading.removeClass('hidden').fadeIn(); - $dropdown.trigger('loading.gl.dropdown'); - return $.ajax({ - type: 'PUT', - url: issueUpdateURL, - dataType: 'JSON', - data: data - }).done(function(data) { - var labelCount, template, labelTooltipTitle, labelTitles; - $loading.fadeOut(); - $dropdown.trigger('loaded.gl.dropdown'); - $selectbox.hide(); - data.issueURLSplit = issueURLSplit; - labelCount = 0; - if (data.labels.length) { - template = labelHTMLTemplate(data); - labelCount = data.labels.length; - } - else { - template = labelNoneHTMLTemplate; - } - $value.removeAttr('style').html(template); - $sidebarCollapsedValue.text(labelCount); - - if (data.labels.length) { - labelTitles = data.labels.map(function(label) { - return label.title; - }); + else { + template = labelNoneHTMLTemplate; + } + $value.removeAttr('style').html(template); + $sidebarCollapsedValue.text(labelCount); - if (labelTitles.length > 5) { - labelTitles = labelTitles.slice(0, 5); - labelTitles.push('and ' + (data.labels.length - 5) + ' more'); - } + if (data.labels.length) { + labelTitles = data.labels.map(function(label) { + return label.title; + }); - labelTooltipTitle = labelTitles.join(', '); - } - else { - labelTooltipTitle = ''; - $sidebarLabelTooltip.tooltip('destroy'); + if (labelTitles.length > 5) { + labelTitles = labelTitles.slice(0, 5); + labelTitles.push('and ' + (data.labels.length - 5) + ' more'); } - $sidebarLabelTooltip - .attr('title', labelTooltipTitle) - .tooltip('fixTitle'); + labelTooltipTitle = labelTitles.join(', '); + } + else { + labelTooltipTitle = ''; + $sidebarLabelTooltip.tooltip('destroy'); + } - $('.has-tooltip', $value).tooltip({ - container: 'body' - }); + $sidebarLabelTooltip + .attr('title', labelTooltipTitle) + .tooltip('fixTitle'); + + $('.has-tooltip', $value).tooltip({ + container: 'body' }); - }; - $dropdown.glDropdown({ - showMenuAbove: showMenuAbove, - data: function(term, callback) { - return $.ajax({ - url: labelUrl - }).done(function(data) { - data = _.chain(data).groupBy(function(label) { - return label.title; - }).map(function(label) { - var color; - color = _.map(label, function(dup) { - return dup.color; + }); + }; + $dropdown.glDropdown({ + showMenuAbove: showMenuAbove, + data: function(term, callback) { + return $.ajax({ + url: labelUrl + }).done(function(data) { + data = _.chain(data).groupBy(function(label) { + return label.title; + }).map(function(label) { + var color; + color = _.map(label, function(dup) { + return dup.color; + }); + return { + id: label[0].id, + title: label[0].title, + color: color, + duplicate: color.length > 1 + }; + }).value(); + if ($dropdown.hasClass('js-extra-options')) { + var extraData = []; + if (showNo) { + extraData.unshift({ + id: 0, + title: 'No Label' }); - return { - id: label[0].id, - title: label[0].title, - color: color, - duplicate: color.length > 1 - }; - }).value(); - if ($dropdown.hasClass('js-extra-options')) { - var extraData = []; - if (showNo) { - extraData.unshift({ - id: 0, - title: 'No Label' - }); - } - if (showAny) { - extraData.unshift({ - isAny: true, - title: 'Any Label' - }); - } - if (extraData.length) { - extraData.push('divider'); - data = extraData.concat(data); - } - } - - callback(data); - if (showMenuAbove) { - $dropdown.data('glDropdown').positionMenuAbove(); - } - }); - }, - renderRow: function(label, instance) { - var $a, $li, color, colorEl, indeterminate, removesAll, selectedClass, spacing, i, marked, dropdownName, dropdownValue; - $li = $('<li>'); - $a = $('<a href="#">'); - selectedClass = []; - removesAll = label.id <= 0 || (label.id == null); - if ($dropdown.hasClass('js-filter-bulk-update')) { - indeterminate = $dropdown.data('indeterminate') || []; - marked = $dropdown.data('marked') || []; - - if (indeterminate.indexOf(label.id) !== -1) { - selectedClass.push('is-indeterminate'); } - - if (marked.indexOf(label.id) !== -1) { - // Remove is-indeterminate class if the item will be marked as active - i = selectedClass.indexOf('is-indeterminate'); - if (i !== -1) { - selectedClass.splice(i, 1); - } - selectedClass.push('is-active'); + if (showAny) { + extraData.unshift({ + isAny: true, + title: 'Any Label' + }); } - } else { - if (this.id(label)) { - dropdownName = $dropdown.data('fieldName'); - dropdownValue = this.id(label).toString().replace(/'/g, '\\\''); - - if ($form.find("input[type='hidden'][name='" + dropdownName + "'][value='" + dropdownValue + "']").length) { - selectedClass.push('is-active'); - } + if (extraData.length) { + extraData.push('divider'); + data = extraData.concat(data); } + } - if ($dropdown.hasClass('js-multiselect') && removesAll) { - selectedClass.push('dropdown-clear-active'); - } + callback(data); + if (showMenuAbove) { + $dropdown.data('glDropdown').positionMenuAbove(); } - if (label.duplicate) { - color = gl.DropdownUtils.duplicateLabelColor(label.color); + }); + }, + renderRow: function(label, instance) { + var $a, $li, color, colorEl, indeterminate, removesAll, selectedClass, spacing, i, marked, dropdownName, dropdownValue; + $li = $('<li>'); + $a = $('<a href="#">'); + selectedClass = []; + removesAll = label.id <= 0 || (label.id == null); + if ($dropdown.hasClass('js-filter-bulk-update')) { + indeterminate = $dropdown.data('indeterminate') || []; + marked = $dropdown.data('marked') || []; + + if (indeterminate.indexOf(label.id) !== -1) { + selectedClass.push('is-indeterminate'); } - else { - if (label.color != null) { - color = label.color[0]; + + if (marked.indexOf(label.id) !== -1) { + // Remove is-indeterminate class if the item will be marked as active + i = selectedClass.indexOf('is-indeterminate'); + if (i !== -1) { + selectedClass.splice(i, 1); } + selectedClass.push('is-active'); } - if (color) { - colorEl = "<span class='dropdown-label-box' style='background: " + color + "'></span>"; - } - else { - colorEl = ''; - } - // We need to identify which items are actually labels - if (label.id) { - selectedClass.push('label-item'); - $a.attr('data-label-id', label.id); - } - $a.addClass(selectedClass.join(' ')).html(colorEl + " " + label.title); - // Return generated html - return $li.html($a).prop('outerHTML'); - }, - search: { - fields: ['title'] - }, - selectable: true, - filterable: true, - selected: $dropdown.data('selected') || [], - toggleLabel: function(selected, el) { - var isSelected = el !== null ? el.hasClass('is-active') : false; - var title = selected.title; - var selectedLabels = this.selected; - - if (selected.id === 0) { - this.selected = []; - return 'No Label'; + } else { + if (this.id(label)) { + dropdownName = $dropdown.data('fieldName'); + dropdownValue = this.id(label).toString().replace(/'/g, '\\\''); + + if ($form.find("input[type='hidden'][name='" + dropdownName + "'][value='" + dropdownValue + "']").length) { + selectedClass.push('is-active'); + } } - else if (isSelected) { - this.selected.push(title); + + if ($dropdown.hasClass('js-multiselect') && removesAll) { + selectedClass.push('dropdown-clear-active'); } - else { - var index = this.selected.indexOf(title); - this.selected.splice(index, 1); + } + if (label.duplicate) { + color = gl.DropdownUtils.duplicateLabelColor(label.color); + } + else { + if (label.color != null) { + color = label.color[0]; } + } + if (color) { + colorEl = "<span class='dropdown-label-box' style='background: " + color + "'></span>"; + } + else { + colorEl = ''; + } + // We need to identify which items are actually labels + if (label.id) { + selectedClass.push('label-item'); + $a.attr('data-label-id', label.id); + } + $a.addClass(selectedClass.join(' ')).html(colorEl + " " + label.title); + // Return generated html + return $li.html($a).prop('outerHTML'); + }, + search: { + fields: ['title'] + }, + selectable: true, + filterable: true, + selected: $dropdown.data('selected') || [], + toggleLabel: function(selected, el) { + var isSelected = el !== null ? el.hasClass('is-active') : false; + var title = selected.title; + var selectedLabels = this.selected; + + if (selected.id === 0) { + this.selected = []; + return 'No Label'; + } + else if (isSelected) { + this.selected.push(title); + } + else { + var index = this.selected.indexOf(title); + this.selected.splice(index, 1); + } + + if (selectedLabels.length === 1) { + return selectedLabels; + } + else if (selectedLabels.length) { + return selectedLabels[0] + " +" + (selectedLabels.length - 1) + " more"; + } + else { + return defaultLabel; + } + }, + fieldName: $dropdown.data('field-name'), + id: function(label) { + if (label.id <= 0) return label.title; + + if ($dropdown.hasClass('js-issuable-form-dropdown')) { + return label.id; + } - if (selectedLabels.length === 1) { - return selectedLabels; + if ($dropdown.hasClass("js-filter-submit") && (label.isAny == null)) { + return label.title; + } + else { + return label.id; + } + }, + hidden: function() { + var isIssueIndex, isMRIndex, page, selectedLabels; + page = $('body').attr('data-page'); + isIssueIndex = page === 'projects:issues:index'; + isMRIndex = page === 'projects:merge_requests:index'; + $selectbox.hide(); + // display:block overrides the hide-collapse rule + $value.removeAttr('style'); + + if ($dropdown.hasClass('js-issuable-form-dropdown')) { + return; + } + + if ($('html').hasClass('issue-boards-page')) { + return; + } + if ($dropdown.hasClass('js-multiselect')) { + if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + selectedLabels = $dropdown.closest('form').find("input:hidden[name='" + ($dropdown.data('fieldName')) + "']"); + Issuable.filterResults($dropdown.closest('form')); } - else if (selectedLabels.length) { - return selectedLabels[0] + " +" + (selectedLabels.length - 1) + " more"; + else if ($dropdown.hasClass('js-filter-submit')) { + $dropdown.closest('form').submit(); } else { - return defaultLabel; + if (!$dropdown.hasClass('js-filter-bulk-update')) { + saveLabelData(); + } } - }, - fieldName: $dropdown.data('field-name'), - id: function(label) { - if (label.id <= 0) return label.title; + } + }, + multiSelect: $dropdown.hasClass('js-multiselect'), + vue: $dropdown.hasClass('js-issue-board-sidebar'), + clicked: function(options) { + const { $el, e, isMarking } = options; + const label = options.selectedObj; + + var isIssueIndex, isMRIndex, page, boardsModel; + var fadeOutLoader = () => { + $loading.fadeOut(); + }; - if ($dropdown.hasClass('js-issuable-form-dropdown')) { - return label.id; - } + page = $('body').attr('data-page'); + isIssueIndex = page === 'projects:issues:index'; + isMRIndex = page === 'projects:merge_requests:index'; - if ($dropdown.hasClass("js-filter-submit") && (label.isAny == null)) { - return label.title; - } - else { - return label.id; - } - }, - hidden: function() { - var isIssueIndex, isMRIndex, page, selectedLabels; - page = $('body').attr('data-page'); - isIssueIndex = page === 'projects:issues:index'; - isMRIndex = page === 'projects:merge_requests:index'; - $selectbox.hide(); - // display:block overrides the hide-collapse rule - $value.removeAttr('style'); - - if ($dropdown.hasClass('js-issuable-form-dropdown')) { - return; - } + if ($dropdown.parent().find('.is-active:not(.dropdown-clear-active)').length) { + $dropdown.parent() + .find('.dropdown-clear-active') + .removeClass('is-active'); + } - if ($('html').hasClass('issue-boards-page')) { - return; - } - if ($dropdown.hasClass('js-multiselect')) { - if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { - selectedLabels = $dropdown.closest('form').find("input:hidden[name='" + ($dropdown.data('fieldName')) + "']"); - Issuable.filterResults($dropdown.closest('form')); - } - else if ($dropdown.hasClass('js-filter-submit')) { - $dropdown.closest('form').submit(); - } - else { - if (!$dropdown.hasClass('js-filter-bulk-update')) { - saveLabelData(); - } - } - } - }, - multiSelect: $dropdown.hasClass('js-multiselect'), - vue: $dropdown.hasClass('js-issue-board-sidebar'), - clicked: function(options) { - const { $el, e, isMarking } = options; - const label = options.selectedObj; - - var isIssueIndex, isMRIndex, page, boardsModel; - var fadeOutLoader = () => { - $loading.fadeOut(); - }; - - page = $('body').attr('data-page'); - isIssueIndex = page === 'projects:issues:index'; - isMRIndex = page === 'projects:merge_requests:index'; - - if ($dropdown.parent().find('.is-active:not(.dropdown-clear-active)').length) { - $dropdown.parent() - .find('.dropdown-clear-active') - .removeClass('is-active'); - } + if ($dropdown.hasClass('js-issuable-form-dropdown')) { + return; + } - if ($dropdown.hasClass('js-issuable-form-dropdown')) { - return; - } + if ($dropdown.hasClass('js-filter-bulk-update')) { + _this.enableBulkLabelDropdown(); + _this.setDropdownData($dropdown, isMarking, label.id); + return; + } - if ($dropdown.hasClass('js-filter-bulk-update')) { - _this.enableBulkLabelDropdown(); - _this.setDropdownData($dropdown, isMarking, label.id); - return; - } + if ($dropdown.closest('.add-issues-modal').length) { + boardsModel = gl.issueBoards.ModalStore.store.filter; + } - if ($dropdown.closest('.add-issues-modal').length) { - boardsModel = gl.issueBoards.ModalStore.store.filter; + if (boardsModel) { + if (label.isAny) { + boardsModel['label_name'] = []; + } else if ($el.hasClass('is-active')) { + boardsModel['label_name'].push(label.title); } - if (boardsModel) { - if (label.isAny) { - boardsModel['label_name'] = []; - } else if ($el.hasClass('is-active')) { - boardsModel['label_name'].push(label.title); - } - - e.preventDefault(); - return; + e.preventDefault(); + return; + } + else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + if (!$dropdown.hasClass('js-multiselect')) { + selectedLabel = label.title; + return Issuable.filterResults($dropdown.closest('form')); } - else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { - if (!$dropdown.hasClass('js-multiselect')) { - selectedLabel = label.title; - return Issuable.filterResults($dropdown.closest('form')); - } + } + else if ($dropdown.hasClass('js-filter-submit')) { + return $dropdown.closest('form').submit(); + } + else if ($dropdown.hasClass('js-issue-board-sidebar')) { + if ($el.hasClass('is-active')) { + gl.issueBoards.BoardsStore.detail.issue.labels.push(new ListLabel({ + id: label.id, + title: label.title, + color: label.color[0], + textColor: '#fff' + })); } - else if ($dropdown.hasClass('js-filter-submit')) { - return $dropdown.closest('form').submit(); + else { + var labels = gl.issueBoards.BoardsStore.detail.issue.labels; + labels = labels.filter(function (selectedLabel) { + return selectedLabel.id !== label.id; + }); + gl.issueBoards.BoardsStore.detail.issue.labels = labels; } - else if ($dropdown.hasClass('js-issue-board-sidebar')) { - if ($el.hasClass('is-active')) { - gl.issueBoards.BoardsStore.detail.issue.labels.push(new ListLabel({ - id: label.id, - title: label.title, - color: label.color[0], - textColor: '#fff' - })); - } - else { - var labels = gl.issueBoards.BoardsStore.detail.issue.labels; - labels = labels.filter(function (selectedLabel) { - return selectedLabel.id !== label.id; - }); - gl.issueBoards.BoardsStore.detail.issue.labels = labels; - } - $loading.fadeIn(); + $loading.fadeIn(); + + gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update')) + .then(fadeOutLoader) + .catch(fadeOutLoader); + } + else { + if ($dropdown.hasClass('js-multiselect')) { - gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update')) - .then(fadeOutLoader) - .catch(fadeOutLoader); } else { - if ($dropdown.hasClass('js-multiselect')) { - - } - else { - return saveLabelData(); - } + return saveLabelData(); } - }, - }); - - // Set dropdown data - _this.setOriginalDropdownData($dropdownContainer, $dropdown); + } + }, }); - this.bindEvents(); - } - LabelsSelect.prototype.bindEvents = function() { - return $('body').on('change', '.selected_issue', this.onSelectCheckboxIssue); - }; - - LabelsSelect.prototype.onSelectCheckboxIssue = function() { - if ($('.selected_issue:checked').length) { - return; + // Set dropdown data + _this.setOriginalDropdownData($dropdownContainer, $dropdown); + }); + this.bindEvents(); + } + + bindEvents() { + return $('body').on('change', '.selected_issue', this.onSelectCheckboxIssue); + } + // eslint-disable-next-line class-methods-use-this + onSelectCheckboxIssue() { + if ($('.selected_issue:checked').length) { + return; + } + return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text('Label'); + } + // eslint-disable-next-line class-methods-use-this + enableBulkLabelDropdown() { + IssuableBulkUpdateActions.willUpdateLabels = true; + } + // eslint-disable-next-line class-methods-use-this + setDropdownData($dropdown, isMarking, value) { + var i, markedIds, unmarkedIds, indeterminateIds; + + markedIds = $dropdown.data('marked') || []; + unmarkedIds = $dropdown.data('unmarked') || []; + indeterminateIds = $dropdown.data('indeterminate') || []; + + if (isMarking) { + markedIds.push(value); + + i = indeterminateIds.indexOf(value); + if (i > -1) { + indeterminateIds.splice(i, 1); } - return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text('Label'); - }; - LabelsSelect.prototype.enableBulkLabelDropdown = function() { - IssuableBulkUpdateActions.willUpdateLabels = true; - }; - - LabelsSelect.prototype.setDropdownData = function($dropdown, isMarking, value) { - var i, markedIds, unmarkedIds, indeterminateIds; - - markedIds = $dropdown.data('marked') || []; - unmarkedIds = $dropdown.data('unmarked') || []; - indeterminateIds = $dropdown.data('indeterminate') || []; - - if (isMarking) { - markedIds.push(value); - - i = indeterminateIds.indexOf(value); - if (i > -1) { - indeterminateIds.splice(i, 1); - } - - i = unmarkedIds.indexOf(value); - if (i > -1) { - unmarkedIds.splice(i, 1); - } - } else { - // If marked item (not common) is unmarked - i = markedIds.indexOf(value); - if (i > -1) { - markedIds.splice(i, 1); - } - - // If an indeterminate item is being unmarked - if (IssuableBulkUpdateActions.getOriginalIndeterminateIds().indexOf(value) > -1) { - unmarkedIds.push(value); - } - - // If a marked item is being unmarked - // (a marked item could also be a label that is present in all selection) - if (IssuableBulkUpdateActions.getOriginalCommonIds().indexOf(value) > -1) { - unmarkedIds.push(value); - } + i = unmarkedIds.indexOf(value); + if (i > -1) { + unmarkedIds.splice(i, 1); + } + } else { + // If marked item (not common) is unmarked + i = markedIds.indexOf(value); + if (i > -1) { + markedIds.splice(i, 1); } - $dropdown.data('marked', markedIds); - $dropdown.data('unmarked', unmarkedIds); - $dropdown.data('indeterminate', indeterminateIds); - }; + // If an indeterminate item is being unmarked + if (IssuableBulkUpdateActions.getOriginalIndeterminateIds().indexOf(value) > -1) { + unmarkedIds.push(value); + } - LabelsSelect.prototype.setOriginalDropdownData = function($container, $dropdown) { - var labels = []; - $container.find('[name="label_name[]"]').map(function() { - return labels.push(this.value); - }); - $dropdown.data('marked', labels); - }; + // If a marked item is being unmarked + // (a marked item could also be a label that is present in all selection) + if (IssuableBulkUpdateActions.getOriginalCommonIds().indexOf(value) > -1) { + unmarkedIds.push(value); + } + } - return LabelsSelect; - })(); -}).call(window); + $dropdown.data('marked', markedIds); + $dropdown.data('unmarked', unmarkedIds); + $dropdown.data('indeterminate', indeterminateIds); + } + // eslint-disable-next-line class-methods-use-this + setOriginalDropdownData($container, $dropdown) { + const labels = []; + $container.find('[name="label_name[]"]').map(function() { + return labels.push(this.value); + }); + $dropdown.data('marked', labels); + } +} diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js index 3d64b121fa7..dbbf1637a47 100644 --- a/app/assets/javascripts/lazy_loader.js +++ b/app/assets/javascripts/lazy_loader.js @@ -1,5 +1,3 @@ -/* eslint-disable one-export, one-var, one-var-declaration-per-line */ - import _ from 'underscore'; export const placeholderImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; @@ -21,7 +19,10 @@ export default class LazyLoader { } searchLazyImages() { this.lazyImages = [].slice.call(document.querySelectorAll('.lazy')); - this.checkElementsInView(); + + if (this.lazyImages.length) { + this.checkElementsInView(); + } } startContentObserver() { const contentNode = document.querySelector(this.observerNode) || document.querySelector('body'); @@ -45,15 +46,13 @@ export default class LazyLoader { checkElementsInView() { const scrollTop = pageYOffset; const visHeight = scrollTop + innerHeight + SCROLL_THRESHOLD; - let imgBoundRect, imgTop, imgBound; // Loading Images which are in the current viewport or close to them this.lazyImages = this.lazyImages.filter((selectedImage) => { if (selectedImage.getAttribute('data-src')) { - imgBoundRect = selectedImage.getBoundingClientRect(); - - imgTop = scrollTop + imgBoundRect.top; - imgBound = imgTop + imgBoundRect.height; + const imgBoundRect = selectedImage.getBoundingClientRect(); + const imgTop = scrollTop + imgBoundRect.top; + const imgBound = imgTop + imgBoundRect.height; if (scrollTop < imgBound && visHeight > imgTop) { LazyLoader.loadImage(selectedImage); diff --git a/app/assets/javascripts/lib/utils/axios_utils.js b/app/assets/javascripts/lib/utils/axios_utils.js new file mode 100644 index 00000000000..45bff245827 --- /dev/null +++ b/app/assets/javascripts/lib/utils/axios_utils.js @@ -0,0 +1,6 @@ +import axios from 'axios'; +import csrf from './csrf'; + +export default function setAxiosCsrfToken() { + axios.defaults.headers.common[csrf.headerKey] = csrf.token; +} diff --git a/app/assets/javascripts/logo.js b/app/assets/javascripts/logo.js index 729baa2e1a7..3688a57937e 100644 --- a/app/assets/javascripts/logo.js +++ b/app/assets/javascripts/logo.js @@ -1,7 +1,5 @@ -/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback */ - -(function() { - window.addEventListener('beforeunload', function() { +export default function initLogoAnimation() { + window.addEventListener('beforeunload', () => { $('.tanuki-logo').addClass('animate'); }); -}).call(window); +} diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 38403fdaf6e..9117f033c9f 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -12,7 +12,6 @@ import svg4everybody from 'svg4everybody'; // libraries with import side-effects import 'mousetrap'; import 'mousetrap/plugins/pause/mousetrap-pause'; -import 'vendor/fuzzaldrin-plus'; // expose common libraries as globals (TODO: remove these) window.jQuery = jQuery; @@ -41,7 +40,6 @@ import './behaviors/'; import './activities'; import './admin'; import './aside'; -import './autosave'; import loadAwardsHandler from './awards_handler'; import bp from './breakpoints'; import './commits'; @@ -55,18 +53,12 @@ import './gl_dropdown'; import './gl_field_error'; import './gl_field_errors'; import './gl_form'; -import './header'; -import './importer_status'; -import './issuable_index'; -import './issuable_context'; -import './issuable_form'; -import './issue'; -import './issue_status_select'; -import './labels_select'; +import initTodoToggle from './header'; +import initImporterStatus from './importer_status'; import './layout_nav'; import LazyLoader from './lazy_loader'; import './line_highlighter'; -import './logo'; +import initLogoAnimation from './logo'; import './merge_request'; import './merge_request_tabs'; import './milestone'; @@ -139,6 +131,9 @@ $(function () { var fitSidebarForSize; initBreadcrumbs(); + initImporterStatus(); + initTodoToggle(); + initLogoAnimation(); // Set the default path for all cookies to GitLab's root directory Cookies.defaults.path = gon.relative_url_root || '/'; diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js index 5da2db063a4..1d496c64e53 100644 --- a/app/assets/javascripts/namespace_select.js +++ b/app/assets/javascripts/namespace_select.js @@ -1,85 +1,57 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, vars-on-top, one-var-declaration-per-line, comma-dangle, object-shorthand, no-else-return, prefer-template, quotes, prefer-arrow-callback, no-param-reassign, no-cond-assign, max-len */ +/* eslint-disable func-names, space-before-function-paren, no-var, comma-dangle, object-shorthand, no-else-return, prefer-template, quotes, prefer-arrow-callback, max-len */ import Api from './api'; +import './lib/utils/url_utility'; -(function() { - window.NamespaceSelect = (function() { - function NamespaceSelect(opts) { - this.onSelectItem = this.onSelectItem.bind(this); - var fieldName, showAny; - this.dropdown = opts.dropdown; - showAny = true; - fieldName = 'namespace_id'; - if (this.dropdown.attr('data-field-name')) { - fieldName = this.dropdown.data('fieldName'); - } - if (this.dropdown.attr('data-show-any')) { - showAny = this.dropdown.data('showAny'); - } - this.dropdown.glDropdown({ - filterable: true, - selectable: true, - filterRemote: true, - search: { - fields: ['path'] - }, - fieldName: fieldName, - toggleLabel: function(selected) { - if (selected.id == null) { - return selected.text; - } else { - return selected.kind + ": " + selected.full_path; - } - }, - data: function(term, dataCallback) { - return Api.namespaces(term, function(namespaces) { - var anyNamespace; - if (showAny) { - anyNamespace = { - text: 'Any namespace', - id: null - }; - namespaces.unshift(anyNamespace); - namespaces.splice(1, 0, 'divider'); - } - return dataCallback(namespaces); - }); - }, - text: function(namespace) { - if (namespace.id == null) { - return namespace.text; - } else { - return namespace.kind + ": " + namespace.full_path; - } - }, - renderRow: this.renderRow, - clicked: this.onSelectItem - }); - } - - NamespaceSelect.prototype.onSelectItem = function(options) { - const { e } = options; - return e.preventDefault(); - }; +export default class NamespaceSelect { + constructor(opts) { + const isFilter = opts.dropdown.dataset.isFilter === 'true'; + const fieldName = opts.dropdown.dataset.fieldName || 'namespace_id'; - return NamespaceSelect; - })(); - - window.NamespaceSelects = (function() { - function NamespaceSelects(opts) { - var ref; - if (opts == null) { - opts = {}; - } - this.$dropdowns = (ref = opts.$dropdowns) != null ? ref : $('.js-namespace-select'); - this.$dropdowns.each(function(i, dropdown) { - var $dropdown; - $dropdown = $(dropdown); - return new window.NamespaceSelect({ - dropdown: $dropdown + $(opts.dropdown).glDropdown({ + filterable: true, + selectable: true, + filterRemote: true, + search: { + fields: ['path'] + }, + fieldName: fieldName, + toggleLabel: function(selected) { + if (selected.id == null) { + return selected.text; + } else { + return selected.kind + ": " + selected.full_path; + } + }, + data: function(term, dataCallback) { + return Api.namespaces(term, function(namespaces) { + if (isFilter) { + const anyNamespace = { + text: 'Any namespace', + id: null + }; + namespaces.unshift(anyNamespace); + namespaces.splice(1, 0, 'divider'); + } + return dataCallback(namespaces); }); - }); - } - - return NamespaceSelects; - })(); -}).call(window); + }, + text: function(namespace) { + if (namespace.id == null) { + return namespace.text; + } else { + return namespace.kind + ": " + namespace.full_path; + } + }, + renderRow: this.renderRow, + clicked(options) { + if (!isFilter) { + const { e } = options; + e.preventDefault(); + } + }, + url(namespace) { + return gl.utils.mergeUrlParams({ [fieldName]: namespace.id }, window.location.href); + }, + }); + } +} diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 5a6868be444..e1ab28978e8 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -5,14 +5,14 @@ default-case, prefer-template, consistent-return, no-alert, no-return-assign, no-param-reassign, prefer-arrow-callback, no-else-return, comma-dangle, no-new, brace-style, no-lonely-if, vars-on-top, no-unused-vars, no-sequences, no-shadow, newline-per-chained-call, no-useless-escape, class-methods-use-this */ -/* global Autosave */ + /* global ResolveService */ /* global mrRefreshWidgetUrl */ import $ from 'jquery'; import _ from 'underscore'; import Cookies from 'js-cookie'; -import autosize from 'vendor/autosize'; +import Autosize from 'autosize'; import 'vendor/jquery.caret'; // required by jquery.atwho import 'vendor/jquery.atwho'; import AjaxCache from '~/lib/utils/ajax_cache'; @@ -20,12 +20,12 @@ import Flash from './flash'; import CommentTypeToggle from './comment_type_toggle'; import GLForm from './gl_form'; import loadAwardsHandler from './awards_handler'; -import './autosave'; +import Autosave from './autosave'; import TaskList from './task_list'; import { ajaxPost, isInViewport, getPagePath, scrollToElement, isMetaKey } from './lib/utils/common_utils'; import imageDiffHelper from './image_diff/helpers/index'; -window.autosize = autosize; +window.autosize = Autosize; function normalizeNewlines(str) { return str.replace(/\r\n/g, '\n'); @@ -413,8 +413,9 @@ export default class Notes { return; } this.note_ids.push(noteEntity.id); + form = $form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`); - row = form.closest('tr'); + row = (form.length || !noteEntity.discussion_line_code) ? form.closest('tr') : $(`#${noteEntity.discussion_line_code}`); if (noteEntity.on_image) { row = form; diff --git a/app/assets/javascripts/notes/components/issue_comment_form.vue b/app/assets/javascripts/notes/components/issue_comment_form.vue index 2ce52e4538a..db8f85759b2 100644 --- a/app/assets/javascripts/notes/components/issue_comment_form.vue +++ b/app/assets/javascripts/notes/components/issue_comment_form.vue @@ -1,10 +1,9 @@ <script> - /* global Autosave */ import { mapActions, mapGetters } from 'vuex'; import _ from 'underscore'; - import autosize from 'vendor/autosize'; + import Autosize from 'autosize'; import Flash from '../../flash'; - import '../../autosave'; + import Autosave from '../../autosave'; import TaskList from '../../task_list'; import * as constants from '../constants'; import eventHub from '../event_hub'; @@ -220,7 +219,7 @@ }, resizeTextarea() { this.$nextTick(() => { - autosize.update(this.$refs.textarea); + Autosize.update(this.$refs.textarea); }); }, }, diff --git a/app/assets/javascripts/notes/components/issue_note.vue b/app/assets/javascripts/notes/components/issue_note.vue index 0ddbd672bed..40318f9a600 100644 --- a/app/assets/javascripts/notes/components/issue_note.vue +++ b/app/assets/javascripts/notes/components/issue_note.vue @@ -122,7 +122,9 @@ // we need to do this to prevent noteForm inconsistent content warning // this is something we intentionally do so we need to recover the content this.note.note = noteText; - this.$refs.noteBody.$refs.noteForm.note = noteText; // TODO: This could be better + if (this.$refs.noteBody) { + this.$refs.noteBody.$refs.noteForm.note = noteText; // TODO: This could be better + } }, }, created() { diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js index 5843b97f225..a008171beda 100644 --- a/app/assets/javascripts/notes/mixins/autosave.js +++ b/app/assets/javascripts/notes/mixins/autosave.js @@ -1,5 +1,4 @@ -/* globals Autosave */ -import '../../autosave'; +import Autosave from '../../autosave'; export default { methods: { diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index 54227425d2a..547140b1a43 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -1,6 +1,6 @@ <script> - import getActionIcon from '../../../vue_shared/ci_action_icons'; import tooltip from '../../../vue_shared/directives/tooltip'; + import icon from '../../../vue_shared/components/icon.vue'; /** * Renders either a cancel, retry or play icon pointing to the given path. @@ -29,17 +29,18 @@ }, }, + components: { + icon, + }, + directives: { tooltip, }, computed: { - actionIconSvg() { - return getActionIcon(this.actionIcon); - }, - cssClass() { - return `js-${gl.text.dasherize(this.actionIcon)}`; + const actionIconDash = gl.text.dasherize(this.actionIcon); + return `${actionIconDash} js-icon-${actionIconDash}`; }, }, }; @@ -50,14 +51,9 @@ :data-method="actionMethod" :title="tooltipText" :href="link" - class="ci-action-icon-container" + class="ci-action-icon-container ci-action-icon-wrapper" + :class="cssClass" data-container="body"> - - <i - class="ci-action-icon-wrapper" - :class="cssClass" - v-html="actionIconSvg" - aria-hidden="true" - /> + <icon :name="actionIcon"/> </a> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue index 18fe1847eef..1c0944d45fc 100644 --- a/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue @@ -1,5 +1,5 @@ <script> - import getActionIcon from '../../../vue_shared/ci_action_icons'; + import icon from '../../../vue_shared/components/icon.vue'; import tooltip from '../../../vue_shared/directives/tooltip'; /** @@ -29,14 +29,12 @@ }, }, - directives: { - tooltip, + components: { + icon, }, - computed: { - actionIconSvg() { - return getActionIcon(this.actionIcon); - }, + directives: { + tooltip, }, }; </script> @@ -49,7 +47,7 @@ rel="nofollow" class="ci-action-icon-wrapper js-ci-status-icon" data-container="body" - v-html="actionIconSvg" aria-label="Job's action"> + <icon :name="actionIcon"/> </a> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue index 3e5d6d15909..7006d05e7b2 100644 --- a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue @@ -18,7 +18,7 @@ * "group": "success", * "details_path": "/root/ci-mock/builds/4256", * "action": { - * "icon": "icon_action_retry", + * "icon": "retry", * "title": "Retry", * "path": "/root/ci-mock/builds/4256/retry", * "method": "post" diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue index 3933509a6f4..5dea4555515 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue @@ -19,7 +19,7 @@ * "group": "success", * "details_path": "/root/ci-mock/builds/4256", * "action": { - * "icon": "icon_action_retry", + * "icon": "retry", * "title": "Retry", * "path": "/root/ci-mock/builds/4256/retry", * "method": "post" diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue index 1a7a5c2a415..ac9d9c901ca 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -14,7 +14,7 @@ */ import Flash from '../../flash'; -import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons'; +import icon from '../../vue_shared/components/icon.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import tooltip from '../../vue_shared/directives/tooltip'; @@ -45,6 +45,7 @@ export default { components: { loadingIcon, + icon, }, updated() { @@ -122,8 +123,8 @@ export default { return `ci-status-icon-${this.stage.status.group}`; }, - svgIcon() { - return borderlessStatusIconEntityMap[this.stage.status.icon]; + borderlessIcon() { + return `${this.stage.status.icon}_borderless`; }, }, }; @@ -145,9 +146,10 @@ export default { aria-expanded="false"> <span - v-html="svgIcon" aria-hidden="true" :aria-label="stage.title"> + <icon + :name="borderlessIcon"/> </span> <i diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue index b2b34cb83e1..6348a2e331d 100644 --- a/app/assets/javascripts/profile/account/components/delete_account_modal.vue +++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue @@ -98,7 +98,7 @@ Once you confirm %{deleteAccount}, it cannot be undone or recovered.`), @toggle="toggleOpen" @submit="onSubmit"> - <template slot="body" scope="props"> + <template slot="body" slot-scope="props"> <p v-html="props.text"></p> <form diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index 11f9754780d..19682b20a4a 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -1,5 +1,6 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, quotes, consistent-return, one-var, one-var-declaration-per-line, no-cond-assign, max-len, object-shorthand, no-param-reassign, comma-dangle, prefer-template, no-unused-vars, no-return-assign */ -/* global fuzzaldrinPlus */ + +import fuzzaldrinPlus from 'fuzzaldrin-plus'; (function() { this.ProjectFindFile = (function() { diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index fb01390f91c..bffc85e6315 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -2,13 +2,15 @@ import Api from './api'; import ProjectSelectComboButton from './project_select_combo_button'; -(function() { - this.ProjectSelect = (function() { +(function () { + this.ProjectSelect = (function () { function ProjectSelect() { $('.ajax-project-select').each(function(i, select) { var placeholder; + const simpleFilter = $(select).data('simple-filter') || false; this.groupId = $(select).data('group-id'); this.includeGroups = $(select).data('include-groups'); + this.allProjects = $(select).data('all-projects') || false; this.orderBy = $(select).data('order-by') || 'id'; this.withIssuesEnabled = $(select).data('with-issues-enabled'); this.withMergeRequestsEnabled = $(select).data('with-merge-requests-enabled'); @@ -21,10 +23,10 @@ import ProjectSelectComboButton from './project_select_combo_button'; $(select).select2({ placeholder: placeholder, minimumInputLength: 0, - query: (function(_this) { - return function(query) { + query: (function (_this) { + return function (query) { var finalCallback, projectsCallback; - finalCallback = function(projects) { + finalCallback = function (projects) { var data; data = { results: projects @@ -32,9 +34,9 @@ import ProjectSelectComboButton from './project_select_combo_button'; return query.callback(data); }; if (_this.includeGroups) { - projectsCallback = function(projects) { + projectsCallback = function (projects) { var groupsCallback; - groupsCallback = function(groups) { + groupsCallback = function (groups) { var data; data = groups.concat(projects); return finalCallback(data); @@ -50,23 +52,25 @@ import ProjectSelectComboButton from './project_select_combo_button'; return Api.projects(query.term, { order_by: _this.orderBy, with_issues_enabled: _this.withIssuesEnabled, - with_merge_requests_enabled: _this.withMergeRequestsEnabled + with_merge_requests_enabled: _this.withMergeRequestsEnabled, + membership: !_this.allProjects, }, projectsCallback); } }; })(this), id: function(project) { + if (simpleFilter) return project.id; return JSON.stringify({ name: project.name, url: project.web_url, }); }, - text: function(project) { + text: function (project) { return project.name_with_namespace || project.name; }, dropdownCssClass: "ajax-project-dropdown" }); - + if (simpleFilter) return select; return new ProjectSelectComboButton(select); }); } diff --git a/app/assets/javascripts/repo/components/new_branch_form.vue b/app/assets/javascripts/repo/components/new_branch_form.vue index eac43e692b0..ba7090e4a9d 100644 --- a/app/assets/javascripts/repo/components/new_branch_form.vue +++ b/app/assets/javascripts/repo/components/new_branch_form.vue @@ -1,18 +1,12 @@ <script> + import { mapState, mapActions } from 'vuex'; import flash, { hideFlash } from '../../flash'; import loadingIcon from '../../vue_shared/components/loading_icon.vue'; - import eventHub from '../event_hub'; export default { components: { loadingIcon, }, - props: { - currentBranch: { - type: String, - required: true, - }, - }, data() { return { branchName: '', @@ -20,11 +14,17 @@ }; }, computed: { + ...mapState([ + 'currentBranch', + ]), btnDisabled() { return this.loading || this.branchName === ''; }, }, methods: { + ...mapActions([ + 'createNewBranch', + ]), toggleDropdown() { this.$dropdown.dropdown('toggle'); }, @@ -38,19 +38,21 @@ hideFlash(flashEl, false); } - eventHub.$emit('createNewBranch', this.branchName); - }, - showErrorMessage(message) { - this.loading = false; - flash(message, 'alert', this.$el); - }, - createdNewBranch(newBranchName) { - this.loading = false; - this.branchName = ''; + this.createNewBranch(this.branchName) + .then(() => { + this.loading = false; + this.branchName = ''; - if (this.dropdownText) { - this.dropdownText.textContent = newBranchName; - } + if (this.dropdownText) { + this.dropdownText.textContent = this.currentBranch; + } + + this.toggleDropdown(); + }) + .catch(res => res.json().then((data) => { + this.loading = false; + flash(data.message, 'alert', this.$el); + })); }, }, created() { @@ -59,15 +61,6 @@ // text element is outside Vue app this.dropdownText = document.querySelector('.project-refs-form .dropdown-toggle-text'); - - eventHub.$on('createNewBranchSuccess', this.createdNewBranch); - eventHub.$on('createNewBranchError', this.showErrorMessage); - eventHub.$on('toggleNewBranchDropdown', this.toggleDropdown); - }, - destroyed() { - eventHub.$off('createNewBranchSuccess', this.createdNewBranch); - eventHub.$off('toggleNewBranchDropdown', this.toggleDropdown); - eventHub.$off('createNewBranchError', this.showErrorMessage); }, }; </script> diff --git a/app/assets/javascripts/repo/components/new_dropdown/index.vue b/app/assets/javascripts/repo/components/new_dropdown/index.vue index 3ccb50213ab..a5ee4f71281 100644 --- a/app/assets/javascripts/repo/components/new_dropdown/index.vue +++ b/app/assets/javascripts/repo/components/new_dropdown/index.vue @@ -1,20 +1,24 @@ <script> - import RepoStore from '../../stores/repo_store'; - import RepoHelper from '../../helpers/repo_helper'; - import eventHub from '../../event_hub'; + import { mapState } from 'vuex'; import newModal from './modal.vue'; + import upload from './upload.vue'; export default { components: { newModal, + upload, }, data() { return { openModal: false, modalType: '', - currentPath: RepoStore.path, }; }, + computed: { + ...mapState([ + 'path', + ]), + }, methods: { createNewItem(type) { this.modalType = type; @@ -23,17 +27,6 @@ toggleModalOpen() { this.openModal = !this.openModal; }, - createNewEntryInStore(name, type) { - RepoHelper.createNewEntry(name, type); - - this.toggleModalOpen(); - }, - }, - created() { - eventHub.$on('createNewEntry', this.createNewEntryInStore); - }, - beforeDestroy() { - eventHub.$off('createNewEntry', this.createNewEntryInStore); }, }; </script> @@ -65,6 +58,11 @@ </a> </li> <li> + <upload + :path="path" + /> + </li> + <li> <a href="#" role="button" @@ -79,7 +77,7 @@ <new-modal v-if="openModal" :type="modalType" - :current-path="currentPath" + :path="path" @toggle="toggleModalOpen" /> </div> diff --git a/app/assets/javascripts/repo/components/new_dropdown/modal.vue b/app/assets/javascripts/repo/components/new_dropdown/modal.vue index 5ef629e0dde..ac1f613bb71 100644 --- a/app/assets/javascripts/repo/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/repo/components/new_dropdown/modal.vue @@ -1,30 +1,38 @@ <script> + import { mapActions } from 'vuex'; import { __ } from '../../../locale'; import popupDialog from '../../../vue_shared/components/popup_dialog.vue'; - import eventHub from '../../event_hub'; export default { props: { - currentPath: { + type: { type: String, required: true, }, - type: { + path: { type: String, required: true, }, }, data() { return { - entryName: this.currentPath !== '' ? `${this.currentPath}/` : '', + entryName: this.path !== '' ? `${this.path}/` : '', }; }, components: { popupDialog, }, methods: { + ...mapActions([ + 'createTempEntry', + ]), createEntryInStore() { - eventHub.$emit('createNewEntry', this.entryName, this.type); + this.createTempEntry({ + name: this.entryName.replace(new RegExp(`^${this.path}/`), ''), + type: this.type, + }); + + this.toggleModalOpen(); }, toggleModalOpen() { this.$emit('toggle'); diff --git a/app/assets/javascripts/repo/components/new_dropdown/upload.vue b/app/assets/javascripts/repo/components/new_dropdown/upload.vue new file mode 100644 index 00000000000..14ad32f4ae0 --- /dev/null +++ b/app/assets/javascripts/repo/components/new_dropdown/upload.vue @@ -0,0 +1,68 @@ +<script> + import { mapActions } from 'vuex'; + + export default { + props: { + path: { + type: String, + required: true, + }, + }, + methods: { + ...mapActions([ + 'createTempEntry', + ]), + createFile(target, file, isText) { + const { name } = file; + let { result } = target; + + if (!isText) { + result = result.split('base64,')[1]; + } + + this.createTempEntry({ + name, + type: 'blob', + content: result, + base64: !isText, + }); + }, + readFile(file) { + const reader = new FileReader(); + const isText = file.type.match(/text.*/) !== null; + + reader.addEventListener('load', e => this.createFile(e.target, file, isText), { once: true }); + + if (isText) { + reader.readAsText(file); + } else { + reader.readAsDataURL(file); + } + }, + openFile() { + Array.from(this.$refs.fileUpload.files).forEach(file => this.readFile(file)); + }, + }, + mounted() { + this.$refs.fileUpload.addEventListener('change', this.openFile); + }, + beforeDestroy() { + this.$refs.fileUpload.removeEventListener('change', this.openFile); + }, + }; +</script> + +<template> + <label + role="button" + class="menu-item" + > + {{ __('Upload file') }} + <input + id="file-upload" + type="file" + class="hidden" + ref="fileUpload" + /> + </label> +</template> diff --git a/app/assets/javascripts/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue index 788976a9804..98117802016 100644 --- a/app/assets/javascripts/repo/components/repo.vue +++ b/app/assets/javascripts/repo/components/repo.vue @@ -1,102 +1,59 @@ <script> +import { mapState, mapGetters } from 'vuex'; import RepoSidebar from './repo_sidebar.vue'; import RepoCommitSection from './repo_commit_section.vue'; import RepoTabs from './repo_tabs.vue'; import RepoFileButtons from './repo_file_buttons.vue'; import RepoPreview from './repo_preview.vue'; -import RepoMixin from '../mixins/repo_mixin'; -import PopupDialog from '../../vue_shared/components/popup_dialog.vue'; -import Store from '../stores/repo_store'; -import Helper from '../helpers/repo_helper'; -import Service from '../services/repo_service'; -import MonacoLoaderHelper from '../helpers/monaco_loader_helper'; -import eventHub from '../event_hub'; +import repoEditor from './repo_editor.vue'; export default { - data() { - return Store; + computed: { + ...mapState([ + 'currentBlobView', + ]), + ...mapGetters([ + 'isCollapsed', + 'changedFiles', + ]), }, - mixins: [RepoMixin], components: { RepoSidebar, RepoTabs, RepoFileButtons, - 'repo-editor': MonacoLoaderHelper.repoEditorLoader, + repoEditor, RepoCommitSection, - PopupDialog, RepoPreview, }, - created() { - eventHub.$on('createNewBranch', this.createNewBranch); - }, mounted() { - Helper.getContent().catch(Helper.loadingError); - }, - destroyed() { - eventHub.$off('createNewBranch', this.createNewBranch); - }, - methods: { - getCurrentLocation() { - return location.href; - }, - toggleDialogOpen(toggle) { - this.dialog.open = toggle; - }, - - dialogSubmitted(status) { - this.toggleDialogOpen(false); - this.dialog.status = status; - - // remove tmp files - Helper.removeAllTmpFiles('openedFiles'); - Helper.removeAllTmpFiles('files'); - }, - toggleBlobView: Store.toggleBlobView, - createNewBranch(branch) { - Service.createBranch({ - branch, - ref: Store.currentBranch, - }).then((res) => { - const newBranchName = res.data.name; - const newUrl = this.getCurrentLocation().replace(Store.currentBranch, newBranchName); - - Store.currentBranch = newBranchName; - - history.pushState({ key: Helper.key }, '', newUrl); + const returnValue = 'Are you sure you want to lose unsaved changes?'; + window.onbeforeunload = (e) => { + if (!this.changedFiles.length) return undefined; - eventHub.$emit('createNewBranchSuccess', newBranchName); - eventHub.$emit('toggleNewBranchDropdown'); - }).catch((err) => { - eventHub.$emit('createNewBranchError', err.response.data.message); + Object.assign(e, { + returnValue, }); - }, + return returnValue; + }; }, }; </script> <template> <div class="repository-view"> - <div class="tree-content-holder" :class="{'tree-content-holder-mini' : isMini}"> + <div class="tree-content-holder" :class="{'tree-content-holder-mini' : isCollapsed}"> <repo-sidebar/> - <div v-if="isMini" - class="panel-right" - :class="{'edit-mode': editMode}"> + <div + v-if="isCollapsed" + class="panel-right" + > <repo-tabs/> <component :is="currentBlobView" - class="blob-viewer-container"/> + /> <repo-file-buttons/> </div> </div> - <repo-commit-section/> - <popup-dialog - v-show="dialog.open" - :primary-button-label="__('Discard changes')" - kind="warning" - :title="__('Are you sure?')" - :text="__('Are you sure you want to discard your changes?')" - @toggle="toggleDialogOpen" - @submit="dialogSubmitted" - /> + <repo-commit-section v-if="changedFiles.length" /> </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_commit_section.vue b/app/assets/javascripts/repo/components/repo_commit_section.vue index 0d6259a37a8..377e3d65348 100644 --- a/app/assets/javascripts/repo/components/repo_commit_section.vue +++ b/app/assets/javascripts/repo/components/repo_commit_section.vue @@ -1,141 +1,100 @@ <script> -import Flash from '../../flash'; -import Store from '../stores/repo_store'; -import RepoMixin from '../mixins/repo_mixin'; -import Service from '../services/repo_service'; +import { mapGetters, mapState, mapActions } from 'vuex'; import PopupDialog from '../../vue_shared/components/popup_dialog.vue'; -import { visitUrl } from '../../lib/utils/url_utility'; +import { n__ } from '../../locale'; export default { - mixins: [RepoMixin], - - data() { - return Store; - }, - components: { PopupDialog, }, - + data() { + return { + showNewBranchDialog: false, + submitCommitsLoading: false, + startNewMR: false, + commitMessage: '', + }; + }, computed: { - showCommitable() { - return this.isCommitable && this.changedFiles.length; - }, - - branchPaths() { - return this.changedFiles.map(f => f.path); - }, - - cantCommitYet() { + ...mapState([ + 'currentBranch', + ]), + ...mapGetters([ + 'changedFiles', + ]), + commitButtonDisabled() { return !this.commitMessage || this.submitCommitsLoading; }, - - filePluralize() { - return this.changedFiles.length > 1 ? 'files' : 'file'; + commitButtonText() { + return n__('Commit %d file', 'Commit %d files', this.changedFiles.length); }, }, - methods: { - commitToNewBranch(status) { - if (status) { - this.showNewBranchDialog = false; - this.tryCommit(null, true, true); - } else { - // reset the state - } - }, + ...mapActions([ + 'checkCommitStatus', + 'commitChanges', + 'getTreeData', + ]), + makeCommit(newBranch = false) { + const createNewBranch = newBranch || this.startNewMR; - makeCommit(newBranch) { - // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions - const commitMessage = this.commitMessage; - const actions = this.changedFiles.map(f => ({ - action: f.tempFile ? 'create' : 'update', - file_path: f.path, - content: f.newContent, - })); - const branch = newBranch ? `${this.currentBranch}-${this.currentShortHash}` : this.currentBranch; const payload = { - branch, - commit_message: commitMessage, - actions, + branch: createNewBranch ? `${this.currentBranch}-${new Date().getTime().toString()}` : this.currentBranch, + commit_message: this.commitMessage, + actions: this.changedFiles.map(f => ({ + action: f.tempFile ? 'create' : 'update', + file_path: f.path, + content: f.content, + encoding: f.base64 ? 'base64' : 'text', + })), + start_branch: createNewBranch ? this.currentBranch : undefined, }; - if (newBranch) { - payload.start_branch = this.currentBranch; - } - Service.commitFiles(payload) + + this.showNewBranchDialog = false; + this.submitCommitsLoading = true; + + this.commitChanges({ payload, newMr: this.startNewMR }) .then(() => { - this.resetCommitState(); - if (this.startNewMR) { - this.redirectToNewMr(branch); - } else { - this.redirectToBranch(branch); - } + this.submitCommitsLoading = false; + this.getTreeData(); }) .catch(() => { - Flash('An error occurred while committing your changes'); + this.submitCommitsLoading = false; }); }, - - tryCommit(e, skipBranchCheck = false, newBranch = false) { + tryCommit() { this.submitCommitsLoading = true; - if (skipBranchCheck) { - this.makeCommit(newBranch); - } else { - Store.setBranchHash() - .then(() => { - if (Store.branchChanged) { - Store.showNewBranchDialog = true; - return; - } - this.makeCommit(newBranch); - }) - .catch(() => { - this.submitCommitsLoading = false; - Flash('An error occurred while committing your changes'); - }); - } - }, - - redirectToNewMr(branch) { - visitUrl(this.newMrTemplateUrl.replace('{{source_branch}}', branch)); - }, - - redirectToBranch(branch) { - visitUrl(this.customBranchURL.replace('{{branch}}', branch)); - }, - - resetCommitState() { - this.submitCommitsLoading = false; - this.openedFiles = this.openedFiles.map((file) => { - const f = file; - f.changed = false; - return f; - }); - this.changedFiles = []; - this.commitMessage = ''; - this.editMode = false; - window.scrollTo(0, 0); + this.checkCommitStatus() + .then((branchChanged) => { + if (branchChanged) { + this.showNewBranchDialog = true; + } else { + this.makeCommit(); + } + }) + .catch(() => { + this.submitCommitsLoading = false; + }); }, }, }; </script> <template> -<div - v-if="showCommitable" - id="commit-area"> +<div id="commit-area"> <popup-dialog v-if="showNewBranchDialog" :primary-button-label="__('Create new branch')" kind="primary" :title="__('Branch has changed')" :text="__('This branch has changed since you started editing. Would you like to create a new branch?')" - @submit="commitToNewBranch" + @toggle="showNewBranchDialog = false" + @submit="makeCommit(true)" /> <form class="form-horizontal" - @submit.prevent="tryCommit"> + @submit.prevent="tryCommit()"> <fieldset> <div class="form-group"> <label class="col-md-4 control-label staged-files"> @@ -144,10 +103,10 @@ export default { <div class="col-md-6"> <ul class="list-unstyled changed-files"> <li - v-for="branchPath in branchPaths" - :key="branchPath"> + v-for="(file, index) in changedFiles" + :key="index"> <span class="help-block"> - {{branchPath}} + {{ file.path }} </span> </li> </ul> @@ -182,9 +141,8 @@ export default { </div> <div class="col-md-offset-4 col-md-6"> <button - ref="submitCommit" type="submit" - :disabled="cantCommitYet" + :disabled="commitButtonDisabled" class="btn btn-success"> <i v-if="submitCommitsLoading" @@ -193,7 +151,7 @@ export default { aria-label="loading"> </i> <span class="commit-summary"> - Commit {{changedFiles.length}} {{filePluralize}} + {{ commitButtonText }} </span> </button> </div> diff --git a/app/assets/javascripts/repo/components/repo_edit_button.vue b/app/assets/javascripts/repo/components/repo_edit_button.vue index e6e8b2e5205..6c1bb4b8566 100644 --- a/app/assets/javascripts/repo/components/repo_edit_button.vue +++ b/app/assets/javascripts/repo/components/repo_edit_button.vue @@ -1,50 +1,57 @@ <script> -import Store from '../stores/repo_store'; -import RepoMixin from '../mixins/repo_mixin'; +import { mapGetters, mapActions, mapState } from 'vuex'; +import popupDialog from '../../vue_shared/components/popup_dialog.vue'; export default { - data() { - return Store; + components: { + popupDialog, }, - mixins: [RepoMixin], computed: { + ...mapState([ + 'editMode', + 'discardPopupOpen', + ]), + ...mapGetters([ + 'canEditFile', + ]), buttonLabel() { return this.editMode ? this.__('Cancel edit') : this.__('Edit'); }, - - showButton() { - return this.isCommitable && - !this.activeFile.render_error && - !this.binary && - this.openedFiles.length; - }, }, methods: { - editCancelClicked() { - if (this.changedFiles.length) { - this.dialog.open = true; - return; - } - this.editMode = !this.editMode; - Store.toggleBlobView(); - }, + ...mapActions([ + 'toggleEditMode', + 'closeDiscardPopup', + ]), }, }; </script> <template> -<button - v-if="showButton" - class="btn btn-default" - type="button" - @click.prevent="editCancelClicked"> - <i - v-if="!editMode" - class="fa fa-pencil" - aria-hidden="true"> - </i> - <span> - {{buttonLabel}} - </span> -</button> + <div class="editable-mode"> + <button + v-if="canEditFile" + class="btn btn-default" + type="button" + @click.prevent="toggleEditMode()"> + <i + v-if="!editMode" + class="fa fa-pencil" + aria-hidden="true"> + </i> + <span> + {{buttonLabel}} + </span> + </button> + <popup-dialog + v-if="discardPopupOpen" + class="text-left" + :primary-button-label="__('Discard changes')" + kind="warning" + :title="__('Are you sure?')" + :text="__('Are you sure you want to discard your changes?')" + @toggle="closeDiscardPopup" + @submit="toggleEditMode(true)" + /> + </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_editor.vue b/app/assets/javascripts/repo/components/repo_editor.vue index df4caba51d8..1c864b176b1 100644 --- a/app/assets/javascripts/repo/components/repo_editor.vue +++ b/app/assets/javascripts/repo/components/repo_editor.vue @@ -1,124 +1,107 @@ <script> /* global monaco */ -import Store from '../stores/repo_store'; -import Service from '../services/repo_service'; -import Helper from '../helpers/repo_helper'; - -const RepoEditor = { - data() { - return Store; - }, +import { mapGetters, mapActions } from 'vuex'; +import flash from '../../flash'; +import monacoLoader from '../monaco_loader'; +export default { destroyed() { - if (Helper.monacoInstance) { - Helper.monacoInstance.destroy(); + if (this.monacoInstance) { + this.monacoInstance.destroy(); } }, - mounted() { - Service.getRaw(this.activeFile) - .then((rawResponse) => { - Store.blobRaw = rawResponse.data; - Store.activeFile.plain = rawResponse.data; - - const monacoInstance = Helper.monaco.editor.create(this.$el, { - model: null, - readOnly: false, - contextmenu: true, - scrollBeyondLastLine: false, - }); + if (this.monaco) { + this.initMonaco(); + } else { + monacoLoader(['vs/editor/editor.main'], () => { + this.monaco = monaco; + + this.initMonaco(); + }); + } + }, + methods: { + ...mapActions([ + 'getRawFileData', + 'changeFileContent', + ]), + initMonaco() { + if (this.shouldHideEditor) return; + + if (this.monacoInstance) { + this.monacoInstance.setModel(null); + } - Helper.monacoInstance = monacoInstance; + this.getRawFileData(this.activeFile) + .then(() => { + if (!this.monacoInstance) { + this.monacoInstance = this.monaco.editor.create(this.$el, { + model: null, + readOnly: false, + contextmenu: true, + scrollBeyondLastLine: false, + }); - this.addMonacoEvents(); + this.languages = this.monaco.languages.getLanguages(); - this.setupEditor(); - }) - .catch(Helper.loadingError); - }, + this.addMonacoEvents(); + } - methods: { + this.setupEditor(); + }) + .catch(() => flash('Error setting up monaco. Please try again.')); + }, setupEditor() { - this.showHide(); + if (!this.activeFile) return; + const content = this.activeFile.content !== '' ? this.activeFile.content : this.activeFile.raw; - Helper.setMonacoModelFromLanguage(); - }, + const foundLang = this.languages.find(lang => + lang.extensions && lang.extensions.indexOf(this.activeFileExtension) === 0, + ); + const newModel = this.monaco.editor.createModel( + content, foundLang ? foundLang.id : 'plaintext', + ); - showHide() { - if (!this.openedFiles.length || (this.binary && !this.activeFile.raw)) { - this.$el.style.display = 'none'; - } else { - this.$el.style.display = 'inline-block'; - } + this.monacoInstance.setModel(newModel); }, - addMonacoEvents() { - Helper.monacoInstance.onMouseUp(this.onMonacoEditorMouseUp); - Helper.monacoInstance.onKeyUp(this.onMonacoEditorKeysPressed.bind(this)); - }, - - onMonacoEditorKeysPressed() { - Store.setActiveFileContents(Helper.monacoInstance.getValue()); - }, - - onMonacoEditorMouseUp(e) { - if (!e.target.position) return; - const lineNumber = e.target.position.lineNumber; - if (e.target.element.classList.contains('line-numbers')) { - location.hash = `L${lineNumber}`; - Store.setActiveLine(lineNumber); - } + this.monacoInstance.onKeyUp(() => { + this.changeFileContent({ + file: this.activeFile, + content: this.monacoInstance.getValue(), + }); + }); }, }, - watch: { - dialog: { - handler(obj) { - const newObj = obj; - if (newObj.status) { - newObj.status = false; - this.openedFiles = this.openedFiles.map((file) => { - const f = file; - if (f.active) { - this.blobRaw = f.plain; - } - f.changed = false; - delete f.newContent; - - return f; - }); - this.editMode = false; - Store.toggleBlobView(); - } - }, - deep: true, - }, - - blobRaw() { - if (Helper.monacoInstance) { - this.setupEditor(); - } - }, - - activeLine() { - if (Helper.monacoInstance) { - Helper.monacoInstance.setPosition({ - lineNumber: this.activeLine, - column: 1, - }); + activeFile(oldVal, newVal) { + if (newVal && !newVal.active) { + this.initMonaco(); } }, }, computed: { + ...mapGetters([ + 'activeFile', + 'activeFileExtension', + ]), shouldHideEditor() { - return !this.openedFiles.length || (this.binary && !this.activeFile.raw); + return this.activeFile.binary && !this.activeFile.raw; }, }, }; - -export default RepoEditor; </script> <template> -<div id="ide" v-if='!shouldHideEditor'></div> + <div + id="ide" + class="blob-viewer-container blob-editor-container" + > + <div + v-if="shouldHideEditor" + v-html="activeFile.html" + > + </div> + </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_file.vue b/app/assets/javascripts/repo/components/repo_file.vue index 8c86e87ed3a..7a23154b340 100644 --- a/app/assets/javascripts/repo/components/repo_file.vue +++ b/app/assets/javascripts/repo/components/repo_file.vue @@ -1,11 +1,9 @@ <script> + import { mapActions, mapGetters } from 'vuex'; import timeAgoMixin from '../../vue_shared/mixins/timeago'; - import eventHub from '../event_hub'; - import repoMixin from '../mixins/repo_mixin'; export default { mixins: [ - repoMixin, timeAgoMixin, ], props: { @@ -15,13 +13,15 @@ }, }, computed: { + ...mapGetters([ + 'isCollapsed', + ]), fileIcon() { - const classObj = { + return { 'fa-spinner fa-spin': this.file.loading, [this.file.icon]: !this.file.loading, 'fa-folder-open': !this.file.loading && this.file.opened, }; - return classObj; }, levelIndentation() { return { @@ -33,9 +33,9 @@ }, }, methods: { - linkClicked(file) { - eventHub.$emit('fileNameClicked', file); - }, + ...mapActions([ + 'clickedTreeRow', + ]), }, }; </script> @@ -43,7 +43,7 @@ <template> <tr class="file" - @click.prevent="linkClicked(file)"> + @click.prevent="clickedTreeRow(file)"> <td> <i class="fa fa-fw file-icon" @@ -71,7 +71,7 @@ </template> </td> - <template v-if="!isMini"> + <template v-if="!isCollapsed"> <td class="hidden-sm hidden-xs"> <a @click.stop diff --git a/app/assets/javascripts/repo/components/repo_file_buttons.vue b/app/assets/javascripts/repo/components/repo_file_buttons.vue index c98f641c853..dd948ee84fb 100644 --- a/app/assets/javascripts/repo/components/repo_file_buttons.vue +++ b/app/assets/javascripts/repo/components/repo_file_buttons.vue @@ -1,37 +1,22 @@ <script> -import Store from '../stores/repo_store'; -import Helper from '../helpers/repo_helper'; -import RepoMixin from '../mixins/repo_mixin'; - -const RepoFileButtons = { - data() { - return Store; - }, - - mixins: [RepoMixin], +import { mapGetters } from 'vuex'; +export default { computed: { + ...mapGetters([ + 'activeFile', + ]), showButtons() { - return this.activeFile.raw_path || - this.activeFile.blame_path || - this.activeFile.commits_path || + return this.activeFile.rawPath || + this.activeFile.blamePath || + this.activeFile.commitsPath || this.activeFile.permalink; }, rawDownloadButtonLabel() { - return this.binary ? 'Download' : 'Raw'; - }, - - canPreview() { - return Helper.isRenderable(); + return this.activeFile.binary ? 'Download' : 'Raw'; }, }, - - methods: { - rawPreviewToggle: Store.toggleRawPreview, - }, }; - -export default RepoFileButtons; </script> <template> @@ -40,11 +25,11 @@ export default RepoFileButtons; class="repo-file-buttons" > <a - :href="activeFile.raw_path" + :href="activeFile.rawPath" target="_blank" class="btn btn-default raw" rel="noopener noreferrer"> - {{rawDownloadButtonLabel}} + {{ rawDownloadButtonLabel }} </a> <div @@ -52,12 +37,12 @@ export default RepoFileButtons; role="group" aria-label="File actions"> <a - :href="activeFile.blame_path" + :href="activeFile.blamePath" class="btn btn-default blame"> Blame </a> <a - :href="activeFile.commits_path" + :href="activeFile.commitsPath" class="btn btn-default history"> History </a> @@ -67,13 +52,5 @@ export default RepoFileButtons; Permalink </a> </div> - - <a - v-if="canPreview" - href="#" - @click.prevent="rawPreviewToggle" - class="btn btn-default preview"> - {{activeFileLabel}} - </a> </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_loading_file.vue b/app/assets/javascripts/repo/components/repo_loading_file.vue index 832b45b2b29..1e6c405f292 100644 --- a/app/assets/javascripts/repo/components/repo_loading_file.vue +++ b/app/assets/javascripts/repo/components/repo_loading_file.vue @@ -1,10 +1,12 @@ <script> - import repoMixin from '../mixins/repo_mixin'; + import { mapGetters } from 'vuex'; export default { - mixins: [ - repoMixin, - ], + computed: { + ...mapGetters([ + 'isCollapsed', + ]), + }, methods: { lineOfCode(n) { return `skeleton-line-${n}`; @@ -28,7 +30,7 @@ </div> </div> </td> - <template v-if="!isMini"> + <template v-if="!isCollapsed"> <td class="hidden-sm hidden-xs"> <div class="animation-container"> diff --git a/app/assets/javascripts/repo/components/repo_prev_directory.vue b/app/assets/javascripts/repo/components/repo_prev_directory.vue index c4bf6dcdec2..a2b305bbd05 100644 --- a/app/assets/javascripts/repo/components/repo_prev_directory.vue +++ b/app/assets/javascripts/repo/components/repo_prev_directory.vue @@ -1,26 +1,22 @@ <script> - import eventHub from '../event_hub'; - import repoMixin from '../mixins/repo_mixin'; + import { mapGetters, mapState, mapActions } from 'vuex'; export default { - mixins: [ - repoMixin, - ], - props: { - prevUrl: { - type: String, - required: true, - }, - }, computed: { + ...mapState([ + 'parentTreeUrl', + ]), + ...mapGetters([ + 'isCollapsed', + ]), colSpanCondition() { - return this.isMini ? undefined : 3; + return this.isCollapsed ? undefined : 3; }, }, methods: { - linkClicked(file) { - eventHub.$emit('goToPreviousDirectoryClicked', file); - }, + ...mapActions([ + 'getTreeData', + ]), }, }; </script> @@ -30,9 +26,9 @@ <td :colspan="colSpanCondition" class="table-cell" - @click.prevent="linkClicked(prevUrl)" + @click.prevent="getTreeData({ endpoint: parentTreeUrl })" > - <a :href="prevUrl">...</a> + <a :href="parentTreeUrl">...</a> </td> </tr> </template> diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/repo/components/repo_preview.vue index b5be771d539..d1883299bd9 100644 --- a/app/assets/javascripts/repo/components/repo_preview.vue +++ b/app/assets/javascripts/repo/components/repo_preview.vue @@ -1,26 +1,20 @@ <script> /* global LineHighlighter */ - -import Store from '../stores/repo_store'; +import { mapGetters } from 'vuex'; export default { - data() { - return Store; - }, computed: { - html() { - return this.activeFile.html; + ...mapGetters([ + 'activeFile', + ]), + renderErrorTooLarge() { + return this.activeFile.renderError === 'too_large'; }, }, methods: { highlightFile() { $(this.$el).find('.file-content').syntaxHighlight(); }, - highlightLine() { - if (Store.activeLine > -1) { - this.lineHighlighter.highlightHash(`#L${Store.activeLine}`); - } - }, }, mounted() { this.highlightFile(); @@ -29,38 +23,39 @@ export default { scrollFileHolder: true, }); }, - watch: { - html() { - this.$nextTick(() => { - this.highlightFile(); - this.highlightLine(); - }); - }, - activeLine() { - this.highlightLine(); - }, + updated() { + this.$nextTick(() => { + this.highlightFile(); + }); }, }; </script> <template> -<div> +<div class="blob-viewer-container"> <div - v-if="!activeFile.render_error" + v-if="!activeFile.renderError" v-html="activeFile.html"> </div> <div - v-else-if="activeFile.tooLarge" + v-else-if="activeFile.tempFile" + class="vertical-center render-error"> + <p class="text-center"> + The source could not be displayed for this temporary file. + </p> + </div> + <div + v-else-if="renderErrorTooLarge" class="vertical-center render-error"> <p class="text-center"> - The source could not be displayed because it is too large. You can <a :href="activeFile.raw_path">download</a> it instead. + The source could not be displayed because it is too large. You can <a :href="activeFile.rawPath" download>download</a> it instead. </p> </div> <div v-else class="vertical-center render-error"> <p class="text-center"> - The source could not be displayed because a rendering error occurred. You can <a :href="activeFile.raw_path">download</a> it instead. + The source could not be displayed because a rendering error occurred. You can <a :href="activeFile.rawPath" download>download</a> it instead. </p> </div> </div> diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue index 09dc9ee25d7..63c0d70f5c0 100644 --- a/app/assets/javascripts/repo/components/repo_sidebar.vue +++ b/app/assets/javascripts/repo/components/repo_sidebar.vue @@ -1,120 +1,55 @@ <script> -import _ from 'underscore'; -import Service from '../services/repo_service'; -import Helper from '../helpers/repo_helper'; -import Store from '../stores/repo_store'; -import eventHub from '../event_hub'; +import { mapState, mapGetters, mapActions } from 'vuex'; import RepoPreviousDirectory from './repo_prev_directory.vue'; import RepoFile from './repo_file.vue'; import RepoLoadingFile from './repo_loading_file.vue'; -import RepoMixin from '../mixins/repo_mixin'; export default { - mixins: [RepoMixin], components: { 'repo-previous-directory': RepoPreviousDirectory, 'repo-file': RepoFile, 'repo-loading-file': RepoLoadingFile, }, created() { - window.addEventListener('popstate', this.checkHistory); + window.addEventListener('popstate', this.popHistoryState); }, destroyed() { - eventHub.$off('fileNameClicked', this.fileClicked); - eventHub.$off('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked); - window.removeEventListener('popstate', this.checkHistory); + window.removeEventListener('popstate', this.popHistoryState); }, mounted() { - eventHub.$on('fileNameClicked', this.fileClicked); - eventHub.$on('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked); - }, - data() { - return Store; + this.getTreeData(); }, computed: { - flattendFiles() { - const mapFiles = arr => (!arr.files.length ? [] : _.map(arr.files, a => [a, mapFiles(a)])); - - return _.chain(this.files) - .map(arr => [arr, mapFiles(arr)]) - .flatten() - .value(); - }, + ...mapState([ + 'loading', + 'isRoot', + ]), + ...mapState({ + projectName(state) { + return state.project.name; + }, + }), + ...mapGetters([ + 'treeList', + 'isCollapsed', + ]), }, methods: { - checkHistory() { - let selectedFile = this.files.find(file => location.pathname.indexOf(file.url) > -1); - if (!selectedFile) { - // Maybe it is not in the current tree but in the opened tabs - selectedFile = Helper.getFileFromPath(location.pathname); - } - - let lineNumber = null; - if (location.hash.indexOf('#L') > -1) lineNumber = Number(location.hash.substr(2)); - - if (selectedFile) { - if (selectedFile.url !== this.activeFile.url) { - this.fileClicked(selectedFile, lineNumber); - } else { - Store.setActiveLine(lineNumber); - } - } else { - // Not opened at all lets open new tab - this.fileClicked({ - url: location.href, - }, lineNumber); - } - }, - - fileClicked(clickedFile, lineNumber) { - const file = clickedFile; - - if (file.loading) return; - - if (file.type === 'tree' && file.opened) { - Helper.setDirectoryToClosed(file); - Store.setActiveLine(lineNumber); - } else if (file.type === 'submodule') { - file.loading = true; - - gl.utils.visitUrl(file.url); - } else { - const openFile = Helper.getFileFromPath(file.url); - - if (openFile) { - Store.setActiveFiles(openFile); - Store.setActiveLine(lineNumber); - } else { - file.loading = true; - Service.url = file.url; - Helper.getContent(file) - .then(() => { - file.loading = false; - Helper.scrollTabsRight(); - Store.setActiveLine(lineNumber); - }) - .catch(Helper.loadingError); - } - } - }, - - goToPreviousDirectoryClicked(prevURL) { - Service.url = prevURL; - Helper.getContent(null, true) - .then(() => Helper.scrollTabsRight()) - .catch(Helper.loadingError); - }, + ...mapActions([ + 'getTreeData', + 'popHistoryState', + ]), }, }; </script> <template> -<div id="sidebar" :class="{'sidebar-mini' : isMini}"> +<div id="sidebar" :class="{'sidebar-mini' : isCollapsed}"> <table class="table"> <thead> <tr> <th - v-if="isMini" + v-if="isCollapsed" class="repo-file-options title" > <strong class="clgray"> @@ -136,17 +71,16 @@ export default { </thead> <tbody> <repo-previous-directory - v-if="!isRoot && !loading.tree" - :prev-url="prevURL" + v-if="!isRoot && treeList.length" /> <repo-loading-file - v-if="!flattendFiles.length && loading.tree" + v-if="!treeList.length && loading" v-for="n in 5" :key="n" /> <repo-file - v-for="file in flattendFiles" - :key="file.id" + v-for="(file, index) in treeList" + :key="index" :file="file" /> </tbody> diff --git a/app/assets/javascripts/repo/components/repo_tab.vue b/app/assets/javascripts/repo/components/repo_tab.vue index 405d7b4cf86..da0714c368c 100644 --- a/app/assets/javascripts/repo/components/repo_tab.vue +++ b/app/assets/javascripts/repo/components/repo_tab.vue @@ -1,7 +1,7 @@ <script> -import Store from '../stores/repo_store'; +import { mapActions } from 'vuex'; -const RepoTab = { +export default { props: { tab: { type: Object, @@ -11,7 +11,7 @@ const RepoTab = { computed: { closeLabel() { - if (this.tab.changed) { + if (this.tab.changed || this.tab.tempFile) { return `${this.tab.name} changed`; } return `Close ${this.tab.name}`; @@ -26,29 +26,23 @@ const RepoTab = { }, methods: { - tabClicked(file) { - Store.setActiveFiles(file); - }, - closeTab(file) { - if (file.changed || file.tempFile) return; - - Store.removeFromOpenedFiles(file); - }, + ...mapActions([ + 'setFileActive', + 'closeFile', + ]), }, }; - -export default RepoTab; </script> <template> <li :class="{ active : tab.active }" - @click="tabClicked(tab)" + @click="setFileActive(tab)" > <button type="button" class="close-btn" - @click.stop.prevent="closeTab(tab)" + @click.stop.prevent="closeFile({ file: tab })" :aria-label="closeLabel"> <i class="fa" @@ -61,7 +55,7 @@ export default RepoTab; href="#" class="repo-tab" :title="tab.url" - @click.prevent="tabClicked(tab)"> + @click.prevent.stop="setFileActive(tab)"> {{tab.name}} </a> </li> diff --git a/app/assets/javascripts/repo/components/repo_tabs.vue b/app/assets/javascripts/repo/components/repo_tabs.vue index b57cd0960de..59beae53e8d 100644 --- a/app/assets/javascripts/repo/components/repo_tabs.vue +++ b/app/assets/javascripts/repo/components/repo_tabs.vue @@ -1,15 +1,15 @@ <script> - import Store from '../stores/repo_store'; + import { mapState } from 'vuex'; import RepoTab from './repo_tab.vue'; - import RepoMixin from '../mixins/repo_mixin'; export default { - mixins: [RepoMixin], components: { 'repo-tab': RepoTab, }, - data() { - return Store; + computed: { + ...mapState([ + 'openFiles', + ]), }, }; </script> @@ -20,7 +20,7 @@ class="list-unstyled" > <repo-tab - v-for="tab in openedFiles" + v-for="tab in openFiles" :key="tab.id" :tab="tab" /> diff --git a/app/assets/javascripts/repo/event_hub.js b/app/assets/javascripts/repo/event_hub.js deleted file mode 100644 index 0948c2e5352..00000000000 --- a/app/assets/javascripts/repo/event_hub.js +++ /dev/null @@ -1,3 +0,0 @@ -import Vue from 'vue'; - -export default new Vue(); diff --git a/app/assets/javascripts/repo/helpers/monaco_loader_helper.js b/app/assets/javascripts/repo/helpers/monaco_loader_helper.js deleted file mode 100644 index f8729bbf585..00000000000 --- a/app/assets/javascripts/repo/helpers/monaco_loader_helper.js +++ /dev/null @@ -1,25 +0,0 @@ -/* global monaco */ -import RepoEditor from '../components/repo_editor.vue'; -import Store from '../stores/repo_store'; -import Helper from '../helpers/repo_helper'; -import monacoLoader from '../monaco_loader'; - -function repoEditorLoader() { - Store.monacoLoading = true; - return new Promise((resolve, reject) => { - monacoLoader(['vs/editor/editor.main'], () => { - Helper.monaco = monaco; - Store.monacoLoading = false; - resolve(RepoEditor); - }, () => { - Store.monacoLoading = false; - reject(); - }); - }); -} - -const MonacoLoaderHelper = { - repoEditorLoader, -}; - -export default MonacoLoaderHelper; diff --git a/app/assets/javascripts/repo/helpers/repo_helper.js b/app/assets/javascripts/repo/helpers/repo_helper.js deleted file mode 100644 index fb26f3b7380..00000000000 --- a/app/assets/javascripts/repo/helpers/repo_helper.js +++ /dev/null @@ -1,317 +0,0 @@ -import Service from '../services/repo_service'; -import Store from '../stores/repo_store'; -import Flash from '../../flash'; - -const RepoHelper = { - monacoInstance: null, - - getDefaultActiveFile() { - return { - id: '', - active: true, - binary: false, - extension: '', - html: '', - mime_type: '', - name: '', - plain: '', - size: 0, - url: '', - raw: false, - newContent: '', - changed: false, - loading: false, - }; - }, - - key: '', - - Time: window.performance - && window.performance.now - ? window.performance - : Date, - - getFileExtension(fileName) { - return fileName.split('.').pop(); - }, - - getLanguageIDForFile(file, langs) { - const ext = RepoHelper.getFileExtension(file.name); - const foundLang = RepoHelper.findLanguage(ext, langs); - - return foundLang ? foundLang.id : 'plaintext'; - }, - - setMonacoModelFromLanguage() { - RepoHelper.monacoInstance.setModel(null); - const languages = RepoHelper.monaco.languages.getLanguages(); - const languageID = RepoHelper.getLanguageIDForFile(Store.activeFile, languages); - const newModel = RepoHelper.monaco.editor.createModel(Store.blobRaw, languageID); - RepoHelper.monacoInstance.setModel(newModel); - }, - - findLanguage(ext, langs) { - return langs.find(lang => lang.extensions && lang.extensions.indexOf(`.${ext}`) > -1); - }, - - setDirectoryOpen(tree, title) { - if (!tree) return; - - Object.assign(tree, { - opened: true, - }); - - RepoHelper.updateHistoryEntry(tree.url, title); - Store.path = tree.path; - }, - - setDirectoryToClosed(entry) { - Object.assign(entry, { - opened: false, - files: [], - }); - }, - - isRenderable() { - const okExts = ['md', 'svg']; - return okExts.indexOf(Store.activeFile.extension) > -1; - }, - - setBinaryDataAsBase64(file) { - Service.getBase64Content(file.raw_path) - .then((response) => { - Store.blobRaw = response; - file.base64 = response; // eslint-disable-line no-param-reassign - }) - .catch(RepoHelper.loadingError); - }, - - getContent(treeOrFile, emptyFiles = false) { - let file = treeOrFile; - - if (!Store.files.length) { - Store.loading.tree = true; - } - - return Service.getContent() - .then((response) => { - const data = response.data; - if (response.headers && response.headers['page-title']) data.pageTitle = decodeURI(response.headers['page-title']); - if (data.path && !Store.isInitialRoot) { - Store.isRoot = data.path === '/'; - Store.isInitialRoot = Store.isRoot; - } - - if (file && file.type === 'blob') { - if (!file) file = data; - Store.binary = data.binary; - - if (data.binary) { - // file might be undefined - RepoHelper.setBinaryDataAsBase64(data); - Store.setViewToPreview(); - } else if (!Store.isPreviewView() && !data.render_error) { - Service.getRaw(data) - .then((rawResponse) => { - Store.blobRaw = rawResponse.data; - data.plain = rawResponse.data; - RepoHelper.setFile(data, file); - }).catch(RepoHelper.loadingError); - } - - if (Store.isPreviewView()) { - RepoHelper.setFile(data, file); - } - } else { - Store.loading.tree = false; - RepoHelper.setDirectoryOpen(file, data.pageTitle || data.name); - - if (emptyFiles) { - Store.files = []; - } - - this.addToDirectory(file, data); - - Store.prevURL = Service.blobURLtoParentTree(Service.url); - } - }).catch(RepoHelper.loadingError); - }, - - addToDirectory(file, data) { - const tree = file || Store; - - // TODO: Figure out why `popstate` is being trigger in the specs - if (!tree.files) return; - - const files = tree.files.concat(this.dataToListOfFiles(data, file ? file.level + 1 : 0)); - - tree.files = files; - }, - - setFile(data, file) { - const newFile = data; - newFile.url = file.url || Service.url; // Grab the URL from service, happens on page refresh. - - if (newFile.render_error === 'too_large' || newFile.render_error === 'collapsed') { - newFile.tooLarge = true; - } - newFile.newContent = ''; - - Store.addToOpenedFiles(newFile); - Store.setActiveFiles(newFile); - }, - - serializeRepoEntity(type, entity, level = 0) { - const { - id, - url, - name, - icon, - last_commit, - tree_url, - path, - tempFile, - active, - opened, - } = entity; - - return { - id, - type, - name, - url, - tree_url, - path, - level, - tempFile, - icon: `fa-${icon}`, - files: [], - loading: false, - opened, - active, - // eslint-disable-next-line camelcase - lastCommit: last_commit ? { - url: `${Store.projectUrl}/commit/${last_commit.id}`, - message: last_commit.message, - updatedAt: last_commit.committed_date, - } : {}, - }; - }, - - scrollTabsRight() { - const tabs = document.getElementById('tabs'); - if (!tabs) return; - tabs.scrollLeft = tabs.scrollWidth; - }, - - dataToListOfFiles(data, level) { - const { blobs, trees, submodules } = data; - return [ - ...trees.map(tree => RepoHelper.serializeRepoEntity('tree', tree, level)), - ...submodules.map(submodule => RepoHelper.serializeRepoEntity('submodule', submodule, level)), - ...blobs.map(blob => RepoHelper.serializeRepoEntity('blob', blob, level)), - ]; - }, - - genKey() { - return RepoHelper.Time.now().toFixed(3); - }, - - updateHistoryEntry(url, title) { - const history = window.history; - - RepoHelper.key = RepoHelper.genKey(); - - if (document.location.pathname !== url) { - history.pushState({ key: RepoHelper.key }, '', url); - } - - if (title) { - document.title = title; - } - }, - - findOpenedFileFromActive() { - return Store.openedFiles.find(openedFile => Store.activeFile.id === openedFile.id); - }, - - getFileFromPath(path) { - return Store.openedFiles.find(file => file.url === path); - }, - - loadingError() { - Flash('Unable to load this content at this time.'); - }, - openEditMode() { - Store.editMode = true; - Store.currentBlobView = 'repo-editor'; - }, - updateStorePath(path) { - Store.path = path; - }, - findOrCreateEntry(type, tree, name) { - let exists = true; - let foundEntry = tree.files.find(dir => dir.type === type && dir.name === name); - - if (!foundEntry) { - foundEntry = RepoHelper.serializeRepoEntity(type, { - id: name, - name, - path: tree.path ? `${tree.path}/${name}` : name, - icon: type === 'tree' ? 'folder' : 'file-text-o', - tempFile: true, - opened: true, - active: true, - }, tree.level !== undefined ? tree.level + 1 : 0); - - exists = false; - tree.files.push(foundEntry); - } - - return { - entry: foundEntry, - exists, - }; - }, - removeAllTmpFiles(storeFilesKey) { - Store[storeFilesKey] = Store[storeFilesKey].filter(f => !f.tempFile); - }, - createNewEntry(name, type) { - const originalPath = Store.path; - let entryName = name; - - if (entryName.indexOf(`${originalPath}/`) !== 0) { - this.updateStorePath(''); - } else { - entryName = entryName.replace(`${originalPath}/`, ''); - } - - if (entryName === '') return; - - const fileName = type === 'tree' ? '.gitkeep' : entryName; - let tree = Store; - - if (type === 'tree') { - const dirNames = entryName.split('/'); - - dirNames.forEach((dirName) => { - if (dirName === '') return; - - tree = this.findOrCreateEntry('tree', tree, dirName).entry; - }); - } - - if ((type === 'tree' && tree.tempFile) || type === 'blob') { - const file = this.findOrCreateEntry('blob', tree, fileName); - - if (!file.exists) { - this.setFile(file.entry, file.entry); - this.openEditMode(); - } - } - - this.updateStorePath(originalPath); - }, -}; - -export default RepoHelper; diff --git a/app/assets/javascripts/repo/index.js b/app/assets/javascripts/repo/index.js index 72fc5a70648..b6801af7fcb 100644 --- a/app/assets/javascripts/repo/index.js +++ b/app/assets/javascripts/repo/index.js @@ -1,55 +1,50 @@ -import $ from 'jquery'; import Vue from 'vue'; +import { mapActions } from 'vuex'; import { convertPermissionToBoolean } from '../lib/utils/common_utils'; -import Service from './services/repo_service'; -import Store from './stores/repo_store'; import Repo from './components/repo.vue'; import RepoEditButton from './components/repo_edit_button.vue'; import newBranchForm from './components/new_branch_form.vue'; import newDropdown from './components/new_dropdown/index.vue'; +import store from './stores'; import Translate from '../vue_shared/translate'; -function initDropdowns() { - $('.js-tree-ref-target-holder').hide(); -} - -function addEventsForNonVueEls() { - window.onbeforeunload = function confirmUnload(e) { - const hasChanged = Store.openedFiles - .some(file => file.changed); - if (!hasChanged) return undefined; - const event = e || window.event; - if (event) event.returnValue = 'Are you sure you want to lose unsaved changes?'; - // For Safari - return 'Are you sure you want to lose unsaved changes?'; - }; -} - -function setInitialStore(data) { - Store.service = Service; - Store.service.url = data.url; - Store.service.refsUrl = data.refsUrl; - Store.path = data.currentPath; - Store.projectId = data.projectId; - Store.projectName = data.projectName; - Store.projectUrl = data.projectUrl; - Store.canCommit = data.canCommit; - Store.onTopOfBranch = data.onTopOfBranch; - Store.newMrTemplateUrl = decodeURIComponent(data.newMrTemplateUrl); - Store.customBranchURL = decodeURIComponent(data.blobUrl); - Store.isRoot = convertPermissionToBoolean(data.root); - Store.isInitialRoot = convertPermissionToBoolean(data.root); - Store.currentBranch = $('button.dropdown-menu-toggle').attr('data-ref'); - Store.checkIsCommitable(); - Store.setBranchHash(); -} - function initRepo(el) { + if (!el) return null; + return new Vue({ el, + store, components: { repo: Repo, }, + methods: { + ...mapActions([ + 'setInitialData', + ]), + }, + created() { + const data = el.dataset; + + this.setInitialData({ + project: { + id: data.projectId, + name: data.projectName, + url: data.projectUrl, + }, + endpoints: { + rootEndpoint: data.url, + newMergeRequestUrl: data.newMergeRequestUrl, + rootUrl: data.rootUrl, + }, + canCommit: convertPermissionToBoolean(data.canCommit), + onTopOfBranch: convertPermissionToBoolean(data.onTopOfBranch), + currentRef: data.ref, + path: data.currentPath, + currentBranch: data.currentBranch, + isRoot: convertPermissionToBoolean(data.root), + isInitialRoot: convertPermissionToBoolean(data.root), + }); + }, render(createElement) { return createElement('repo'); }, @@ -59,15 +54,20 @@ function initRepo(el) { function initRepoEditButton(el) { return new Vue({ el, + store, components: { repoEditButton: RepoEditButton, }, + render(createElement) { + return createElement('repo-edit-button'); + }, }); } function initNewDropdown(el) { return new Vue({ el, + store, components: { newDropdown, }, @@ -87,32 +87,20 @@ function initNewBranchForm() { components: { newBranchForm, }, + store, render(createElement) { - return createElement('new-branch-form', { - props: { - currentBranch: Store.currentBranch, - }, - }); + return createElement('new-branch-form'); }, }); } -function initRepoBundle() { - const repo = document.getElementById('repo'); - const editButton = document.querySelector('.editable-mode'); - const newDropdownHolder = document.querySelector('.js-new-dropdown'); - setInitialStore(repo.dataset); - addEventsForNonVueEls(); - initDropdowns(); - - Vue.use(Translate); - - initRepo(repo); - initRepoEditButton(editButton); - initNewBranchForm(); - initNewDropdown(newDropdownHolder); -} +const repo = document.getElementById('repo'); +const editButton = document.querySelector('.editable-mode'); +const newDropdownHolder = document.querySelector('.js-new-dropdown'); -$(initRepoBundle); +Vue.use(Translate); -export default initRepoBundle; +initRepo(repo); +initRepoEditButton(editButton); +initNewBranchForm(); +initNewDropdown(newDropdownHolder); diff --git a/app/assets/javascripts/repo/mixins/repo_mixin.js b/app/assets/javascripts/repo/mixins/repo_mixin.js deleted file mode 100644 index efeda426b96..00000000000 --- a/app/assets/javascripts/repo/mixins/repo_mixin.js +++ /dev/null @@ -1,17 +0,0 @@ -import Store from '../stores/repo_store'; - -const RepoMixin = { - computed: { - isMini() { - return !!Store.openedFiles.length; - }, - - changedFiles() { - const changedFileList = this.openedFiles - .filter(file => file.changed || file.tempFile); - return changedFileList; - }, - }, -}; - -export default RepoMixin; diff --git a/app/assets/javascripts/repo/services/index.js b/app/assets/javascripts/repo/services/index.js new file mode 100644 index 00000000000..dc222ccac01 --- /dev/null +++ b/app/assets/javascripts/repo/services/index.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; +import Api from '../../api'; + +Vue.use(VueResource); + +export default { + getTreeData(endpoint) { + return Vue.http.get(endpoint, { params: { format: 'json' } }); + }, + getFileData(endpoint) { + return Vue.http.get(endpoint, { params: { format: 'json' } }); + }, + getRawFileData(file) { + if (file.tempFile) { + return Promise.resolve(file.content); + } + + return Vue.http.get(file.rawPath, { params: { format: 'json' } }) + .then(res => res.text()); + }, + getBranchData(projectId, currentBranch) { + return Api.branchSingle(projectId, currentBranch); + }, + createBranch(projectId, payload) { + const url = Api.buildUrl(Api.createBranchPath).replace(':id', projectId); + + return Vue.http.post(url, payload); + }, + commit(projectId, payload) { + return Api.commitMultiple(projectId, payload); + }, +}; diff --git a/app/assets/javascripts/repo/services/repo_service.js b/app/assets/javascripts/repo/services/repo_service.js deleted file mode 100644 index c9fa5cc8bf8..00000000000 --- a/app/assets/javascripts/repo/services/repo_service.js +++ /dev/null @@ -1,101 +0,0 @@ -import axios from 'axios'; -import csrf from '../../lib/utils/csrf'; -import Store from '../stores/repo_store'; -import Api from '../../api'; -import Helper from '../helpers/repo_helper'; - -axios.defaults.headers.common[csrf.headerKey] = csrf.token; - -const RepoService = { - url: '', - options: { - params: { - format: 'json', - }, - }, - createBranchPath: '/api/:version/projects/:id/repository/branches', - richExtensionRegExp: /md/, - - getRaw(file) { - if (file.tempFile) { - return Promise.resolve({ - data: '', - }); - } - - return axios.get(file.raw_path, { - // Stop Axios from parsing a JSON file into a JS object - transformResponse: [res => res], - }); - }, - - buildParams(url = this.url) { - // shallow clone object without reference - const params = Object.assign({}, this.options.params); - - if (this.urlIsRichBlob(url)) params.viewer = 'rich'; - - return params; - }, - - urlIsRichBlob(url = this.url) { - const extension = Helper.getFileExtension(url); - - return this.richExtensionRegExp.test(extension); - }, - - getContent(url = this.url) { - const params = this.buildParams(url); - - return axios.get(url, { - params, - }); - }, - - getBase64Content(url = this.url) { - const request = axios.get(url, { - responseType: 'arraybuffer', - }); - - return request.then(response => this.bufferToBase64(response.data)); - }, - - bufferToBase64(data) { - return new Buffer(data, 'binary').toString('base64'); - }, - - blobURLtoParentTree(url) { - const urlArray = url.split('/'); - urlArray.pop(); - const blobIndex = urlArray.lastIndexOf('blob'); - - if (blobIndex > -1) urlArray[blobIndex] = 'tree'; - - return urlArray.join('/'); - }, - - getBranch() { - return Api.branchSingle(Store.projectId, Store.currentBranch); - }, - - commitFiles(payload) { - return Api.commitMultiple(Store.projectId, payload) - .then(this.commitFlash); - }, - - createBranch(payload) { - const url = Api.buildUrl(this.createBranchPath) - .replace(':id', Store.projectId); - return axios.post(url, payload); - }, - - commitFlash(data) { - if (data.short_id && data.stats) { - window.Flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice'); - } else { - window.Flash(data.message); - } - }, -}; - -export default RepoService; diff --git a/app/assets/javascripts/repo/stores/actions.js b/app/assets/javascripts/repo/stores/actions.js new file mode 100644 index 00000000000..ca2f2a5ce7a --- /dev/null +++ b/app/assets/javascripts/repo/stores/actions.js @@ -0,0 +1,129 @@ +import Vue from 'vue'; +import flash from '../../flash'; +import service from '../services'; +import * as types from './mutation_types'; + +export const redirectToUrl = url => gl.utils.visitUrl(url); + +export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); + +export const closeDiscardPopup = ({ commit }) => commit(types.TOGGLE_DISCARD_POPUP, false); + +export const discardAllChanges = ({ commit, getters, dispatch }) => { + const changedFiles = getters.changedFiles; + + changedFiles.forEach((file) => { + commit(types.DISCARD_FILE_CHANGES, file); + + if (file.tempFile) { + dispatch('closeFile', { file, force: true }); + } + }); +}; + +export const closeAllFiles = ({ state, dispatch }) => { + state.openFiles.forEach(file => dispatch('closeFile', { file })); +}; + +export const toggleEditMode = ({ state, commit, getters, dispatch }, force = false) => { + const changedFiles = getters.changedFiles; + + if (changedFiles.length && !force) { + commit(types.TOGGLE_DISCARD_POPUP, true); + } else { + commit(types.TOGGLE_EDIT_MODE); + commit(types.TOGGLE_DISCARD_POPUP, false); + dispatch('toggleBlobView'); + + if (!state.editMode) { + dispatch('discardAllChanges'); + } + } +}; + +export const toggleBlobView = ({ commit, state }) => { + if (state.editMode) { + commit(types.SET_EDIT_MODE); + } else { + commit(types.SET_PREVIEW_MODE); + } +}; + +export const checkCommitStatus = ({ state }) => service.getBranchData( + state.project.id, + state.currentBranch, +) + .then((data) => { + const { id } = data.commit; + + if (state.currentRef !== id) { + return true; + } + + return false; + }) + .catch(() => flash('Error checking branch data. Please try again.')); + +export const commitChanges = ({ commit, state, dispatch }, { payload, newMr }) => + service.commit(state.project.id, payload) + .then((data) => { + const { branch } = payload; + if (!data.short_id) { + flash(data.message); + return; + } + + flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice'); + + if (newMr) { + redirectToUrl(`${state.endpoints.newMergeRequestUrl}${branch}`); + } else { + commit(types.SET_COMMIT_REF, data.id); + dispatch('discardAllChanges'); + dispatch('closeAllFiles'); + dispatch('toggleEditMode'); + + window.scrollTo(0, 0); + } + }) + .catch(() => flash('Error committing changes. Please try again.')); + +export const createTempEntry = ({ state, dispatch }, { name, type, content = '', base64 = false }) => { + if (type === 'tree') { + dispatch('createTempTree', name); + } else if (type === 'blob') { + dispatch('createTempFile', { + tree: state, + name, + base64, + content, + }); + } +}; + +export const popHistoryState = ({ state, dispatch, getters }) => { + const treeList = getters.treeList; + const tree = treeList.find(file => file.url === state.previousUrl); + + if (!tree) return; + + if (tree.type === 'tree') { + dispatch('toggleTreeOpen', { endpoint: tree.url, tree }); + } +}; + +export const scrollToTab = () => { + Vue.nextTick(() => { + const tabs = document.getElementById('tabs'); + + if (tabs) { + const tabEl = tabs.querySelector('.active .repo-tab'); + + tabEl.focus(); + } + }); +}; + +export * from './actions/tree'; +export * from './actions/file'; +export * from './actions/branch'; diff --git a/app/assets/javascripts/repo/stores/actions/branch.js b/app/assets/javascripts/repo/stores/actions/branch.js new file mode 100644 index 00000000000..b81a70dfd1e --- /dev/null +++ b/app/assets/javascripts/repo/stores/actions/branch.js @@ -0,0 +1,20 @@ +import service from '../../services'; +import * as types from '../mutation_types'; +import { pushState } from '../utils'; + +// eslint-disable-next-line import/prefer-default-export +export const createNewBranch = ({ rootState, commit }, branch) => service.createBranch( + rootState.project.id, + { + branch, + ref: rootState.currentBranch, + }, +).then(res => res.json()) +.then((data) => { + const branchName = data.name; + const url = location.href.replace(rootState.currentBranch, branchName); + + pushState(url); + + commit(types.SET_CURRENT_BRANCH, branchName); +}); diff --git a/app/assets/javascripts/repo/stores/actions/file.js b/app/assets/javascripts/repo/stores/actions/file.js new file mode 100644 index 00000000000..afbe0b78a82 --- /dev/null +++ b/app/assets/javascripts/repo/stores/actions/file.js @@ -0,0 +1,108 @@ +import { normalizeHeaders } from '../../../lib/utils/common_utils'; +import flash from '../../../flash'; +import service from '../../services'; +import * as types from '../mutation_types'; +import { + findEntry, + pushState, + setPageTitle, + createTemp, + findIndexOfFile, +} from '../utils'; + +export const closeFile = ({ commit, state, dispatch }, { file, force = false }) => { + if ((file.changed || file.tempFile) && !force) return; + + const indexOfClosedFile = findIndexOfFile(state.openFiles, file); + const fileWasActive = file.active; + + commit(types.TOGGLE_FILE_OPEN, file); + commit(types.SET_FILE_ACTIVE, { file, active: false }); + + if (state.openFiles.length > 0 && fileWasActive) { + const nextIndexToOpen = indexOfClosedFile === 0 ? 0 : indexOfClosedFile - 1; + const nextFileToOpen = state.openFiles[nextIndexToOpen]; + + dispatch('setFileActive', nextFileToOpen); + } else if (!state.openFiles.length) { + pushState(file.parentTreeUrl); + } +}; + +export const setFileActive = ({ commit, state, getters, dispatch }, file) => { + const currentActiveFile = getters.activeFile; + + if (file.active) return; + + if (currentActiveFile) { + commit(types.SET_FILE_ACTIVE, { file: currentActiveFile, active: false }); + } + + commit(types.SET_FILE_ACTIVE, { file, active: true }); + dispatch('scrollToTab'); + + // reset hash for line highlighting + location.hash = ''; +}; + +export const getFileData = ({ state, commit, dispatch }, file) => { + commit(types.TOGGLE_LOADING, file); + + service.getFileData(file.url) + .then((res) => { + const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']); + + setPageTitle(pageTitle); + + return res.json(); + }) + .then((data) => { + commit(types.SET_FILE_DATA, { data, file }); + commit(types.TOGGLE_FILE_OPEN, file); + dispatch('setFileActive', file); + commit(types.TOGGLE_LOADING, file); + + pushState(file.url); + }) + .catch(() => { + commit(types.TOGGLE_LOADING, file); + flash('Error loading file data. Please try again.'); + }); +}; + +export const getRawFileData = ({ commit, dispatch }, file) => service.getRawFileData(file) + .then((raw) => { + commit(types.SET_FILE_RAW_DATA, { file, raw }); + }) + .catch(() => flash('Error loading file content. Please try again.')); + +export const changeFileContent = ({ commit }, { file, content }) => { + commit(types.UPDATE_FILE_CONTENT, { file, content }); +}; + +export const createTempFile = ({ state, commit, dispatch }, { tree, name, content = '', base64 = '' }) => { + const file = createTemp({ + name: name.replace(`${state.path}/`, ''), + path: tree.path, + type: 'blob', + level: tree.level !== undefined ? tree.level + 1 : 0, + changed: true, + content, + base64, + }); + + if (findEntry(tree, 'blob', file.name)) return flash(`The name "${file.name}" is already taken in this directory.`); + + commit(types.CREATE_TMP_FILE, { + parent: tree, + file, + }); + commit(types.TOGGLE_FILE_OPEN, file); + dispatch('setFileActive', file); + + if (!state.editMode && !file.base64) { + dispatch('toggleEditMode', true); + } + + return Promise.resolve(file); +}; diff --git a/app/assets/javascripts/repo/stores/actions/tree.js b/app/assets/javascripts/repo/stores/actions/tree.js new file mode 100644 index 00000000000..129743c66c2 --- /dev/null +++ b/app/assets/javascripts/repo/stores/actions/tree.js @@ -0,0 +1,110 @@ +import { normalizeHeaders } from '../../../lib/utils/common_utils'; +import flash from '../../../flash'; +import service from '../../services'; +import * as types from '../mutation_types'; +import { + pushState, + setPageTitle, + findEntry, + createTemp, +} from '../utils'; + +export const getTreeData = ( + { commit, state }, + { endpoint = state.endpoints.rootEndpoint, tree = state } = {}, +) => { + commit(types.TOGGLE_LOADING, tree); + + service.getTreeData(endpoint) + .then((res) => { + const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']); + + setPageTitle(pageTitle); + + return res.json(); + }) + .then((data) => { + if (!state.isInitialRoot) { + commit(types.SET_ROOT, data.path === '/'); + } + + commit(types.SET_DIRECTORY_DATA, { data, tree }); + commit(types.SET_PARENT_TREE_URL, data.parent_tree_url); + commit(types.TOGGLE_LOADING, tree); + + pushState(endpoint); + }) + .catch(() => { + flash('Error loading tree data. Please try again.'); + commit(types.TOGGLE_LOADING, tree); + }); +}; + +export const toggleTreeOpen = ({ commit, dispatch }, { endpoint, tree }) => { + if (tree.opened) { + // send empty data to clear the tree + const data = { trees: [], blobs: [], submodules: [] }; + + pushState(tree.parentTreeUrl); + + commit(types.SET_PREVIOUS_URL, tree.parentTreeUrl); + commit(types.SET_DIRECTORY_DATA, { data, tree }); + } else { + commit(types.SET_PREVIOUS_URL, endpoint); + dispatch('getTreeData', { endpoint, tree }); + } + + commit(types.TOGGLE_TREE_OPEN, tree); +}; + +export const clickedTreeRow = ({ commit, dispatch }, row) => { + if (row.type === 'tree') { + dispatch('toggleTreeOpen', { + endpoint: row.url, + tree: row, + }); + } else if (row.type === 'submodule') { + commit(types.TOGGLE_LOADING, row); + + gl.utils.visitUrl(row.url); + } else if (row.type === 'blob' && row.opened) { + dispatch('setFileActive', row); + } else { + dispatch('getFileData', row); + } +}; + +export const createTempTree = ({ state, commit, dispatch }, name) => { + let tree = state; + const dirNames = name.replace(new RegExp(`^${state.path}/`), '').split('/'); + + dirNames.forEach((dirName) => { + const foundEntry = findEntry(tree, 'tree', dirName); + + if (!foundEntry) { + const tmpEntry = createTemp({ + name: dirName, + path: tree.path, + type: 'tree', + level: tree.level !== undefined ? tree.level + 1 : 0, + }); + + commit(types.CREATE_TMP_TREE, { + parent: tree, + tmpEntry, + }); + commit(types.TOGGLE_TREE_OPEN, tmpEntry); + + tree = tmpEntry; + } else { + tree = foundEntry; + } + }); + + if (tree.tempFile) { + dispatch('createTempFile', { + tree, + name: '.gitkeep', + }); + } +}; diff --git a/app/assets/javascripts/repo/stores/getters.js b/app/assets/javascripts/repo/stores/getters.js new file mode 100644 index 00000000000..1ed05ac6e35 --- /dev/null +++ b/app/assets/javascripts/repo/stores/getters.js @@ -0,0 +1,36 @@ +import _ from 'underscore'; + +/* + Takes the multi-dimensional tree and returns a flattened array. + This allows for the table to recursively render the table rows but keeps the data + structure nested to make it easier to add new files/directories. +*/ +export const treeList = (state) => { + const mapTree = arr => (!arr.tree.length ? [] : _.map(arr.tree, a => [a, mapTree(a)])); + + return _.chain(state.tree) + .map(arr => [arr, mapTree(arr)]) + .flatten() + .value(); +}; + +export const changedFiles = state => state.openFiles.filter(file => file.changed); + +export const activeFile = state => state.openFiles.find(file => file.active); + +export const activeFileExtension = (state) => { + const file = activeFile(state); + return file ? `.${file.path.split('.').pop()}` : ''; +}; + +export const isCollapsed = state => !!state.openFiles.length; + +export const canEditFile = (state) => { + const currentActiveFile = activeFile(state); + const openedFiles = state.openFiles; + + return state.canCommit && + state.onTopOfBranch && + openedFiles.length && + (currentActiveFile && !currentActiveFile.renderError && !currentActiveFile.binary); +}; diff --git a/app/assets/javascripts/repo/stores/index.js b/app/assets/javascripts/repo/stores/index.js new file mode 100644 index 00000000000..6ac9bfd8189 --- /dev/null +++ b/app/assets/javascripts/repo/stores/index.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import state from './state'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + state: state(), + actions, + mutations, + getters, +}); diff --git a/app/assets/javascripts/repo/stores/mutation_types.js b/app/assets/javascripts/repo/stores/mutation_types.js new file mode 100644 index 00000000000..4722a7dd0df --- /dev/null +++ b/app/assets/javascripts/repo/stores/mutation_types.js @@ -0,0 +1,28 @@ +export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; +export const TOGGLE_LOADING = 'TOGGLE_LOADING'; +export const SET_COMMIT_REF = 'SET_COMMIT_REF'; +export const SET_PARENT_TREE_URL = 'SET_PARENT_TREE_URL'; +export const SET_ROOT = 'SET_ROOT'; +export const SET_PREVIOUS_URL = 'SET_PREVIOUS_URL'; + +// Tree mutation types +export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA'; +export const TOGGLE_TREE_OPEN = 'TOGGLE_TREE_OPEN'; +export const CREATE_TMP_TREE = 'CREATE_TMP_TREE'; + +// File mutation types +export const SET_FILE_DATA = 'SET_FILE_DATA'; +export const TOGGLE_FILE_OPEN = 'TOGGLE_FILE_OPEN'; +export const SET_FILE_ACTIVE = 'SET_FILE_ACTIVE'; +export const SET_FILE_RAW_DATA = 'SET_FILE_RAW_DATA'; +export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT'; +export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; +export const CREATE_TMP_FILE = 'CREATE_TMP_FILE'; + +// Viewer mutation types +export const SET_PREVIEW_MODE = 'SET_PREVIEW_MODE'; +export const SET_EDIT_MODE = 'SET_EDIT_MODE'; +export const TOGGLE_EDIT_MODE = 'TOGGLE_EDIT_MODE'; +export const TOGGLE_DISCARD_POPUP = 'TOGGLE_DISCARD_POPUP'; + +export const SET_CURRENT_BRANCH = 'SET_CURRENT_BRANCH'; diff --git a/app/assets/javascripts/repo/stores/mutations.js b/app/assets/javascripts/repo/stores/mutations.js new file mode 100644 index 00000000000..2f9b038322b --- /dev/null +++ b/app/assets/javascripts/repo/stores/mutations.js @@ -0,0 +1,54 @@ +import * as types from './mutation_types'; +import fileMutations from './mutations/file'; +import treeMutations from './mutations/tree'; +import branchMutations from './mutations/branch'; + +export default { + [types.SET_INITIAL_DATA](state, data) { + Object.assign(state, data); + }, + [types.SET_PREVIEW_MODE](state) { + Object.assign(state, { + currentBlobView: 'repo-preview', + }); + }, + [types.SET_EDIT_MODE](state) { + Object.assign(state, { + currentBlobView: 'repo-editor', + }); + }, + [types.TOGGLE_LOADING](state, entry) { + Object.assign(entry, { + loading: !entry.loading, + }); + }, + [types.TOGGLE_EDIT_MODE](state) { + Object.assign(state, { + editMode: !state.editMode, + }); + }, + [types.TOGGLE_DISCARD_POPUP](state, discardPopupOpen) { + Object.assign(state, { + discardPopupOpen, + }); + }, + [types.SET_COMMIT_REF](state, ref) { + Object.assign(state, { + currentRef: ref, + }); + }, + [types.SET_ROOT](state, isRoot) { + Object.assign(state, { + isRoot, + isInitialRoot: isRoot, + }); + }, + [types.SET_PREVIOUS_URL](state, previousUrl) { + Object.assign(state, { + previousUrl, + }); + }, + ...fileMutations, + ...treeMutations, + ...branchMutations, +}; diff --git a/app/assets/javascripts/repo/stores/mutations/branch.js b/app/assets/javascripts/repo/stores/mutations/branch.js new file mode 100644 index 00000000000..d8229e8a620 --- /dev/null +++ b/app/assets/javascripts/repo/stores/mutations/branch.js @@ -0,0 +1,9 @@ +import * as types from '../mutation_types'; + +export default { + [types.SET_CURRENT_BRANCH](state, currentBranch) { + Object.assign(state, { + currentBranch, + }); + }, +}; diff --git a/app/assets/javascripts/repo/stores/mutations/file.js b/app/assets/javascripts/repo/stores/mutations/file.js new file mode 100644 index 00000000000..f9ba80b9dc2 --- /dev/null +++ b/app/assets/javascripts/repo/stores/mutations/file.js @@ -0,0 +1,54 @@ +import * as types from '../mutation_types'; +import { findIndexOfFile } from '../utils'; + +export default { + [types.SET_FILE_ACTIVE](state, { file, active }) { + Object.assign(file, { + active, + }); + }, + [types.TOGGLE_FILE_OPEN](state, file) { + Object.assign(file, { + opened: !file.opened, + }); + + if (file.opened) { + state.openFiles.push(file); + } else { + state.openFiles.splice(findIndexOfFile(state.openFiles, file), 1); + } + }, + [types.SET_FILE_DATA](state, { data, file }) { + Object.assign(file, { + blamePath: data.blame_path, + commitsPath: data.commits_path, + permalink: data.permalink, + rawPath: data.raw_path, + binary: data.binary, + html: data.html, + renderError: data.render_error, + }); + }, + [types.SET_FILE_RAW_DATA](state, { file, raw }) { + Object.assign(file, { + raw, + }); + }, + [types.UPDATE_FILE_CONTENT](state, { file, content }) { + const changed = content !== file.raw; + + Object.assign(file, { + content, + changed, + }); + }, + [types.DISCARD_FILE_CHANGES](state, file) { + Object.assign(file, { + content: '', + changed: false, + }); + }, + [types.CREATE_TMP_FILE](state, { file, parent }) { + parent.tree.push(file); + }, +}; diff --git a/app/assets/javascripts/repo/stores/mutations/tree.js b/app/assets/javascripts/repo/stores/mutations/tree.js new file mode 100644 index 00000000000..52be2673107 --- /dev/null +++ b/app/assets/javascripts/repo/stores/mutations/tree.js @@ -0,0 +1,45 @@ +import * as types from '../mutation_types'; +import * as utils from '../utils'; + +export default { + [types.TOGGLE_TREE_OPEN](state, tree) { + Object.assign(tree, { + opened: !tree.opened, + }); + }, + [types.SET_DIRECTORY_DATA](state, { data, tree }) { + const level = tree.level !== undefined ? tree.level + 1 : 0; + const parentTreeUrl = data.parent_tree_url ? `${data.parent_tree_url}${data.path}` : state.endpoints.rootUrl; + + Object.assign(tree, { + tree: [ + ...data.trees.map(t => utils.decorateData({ + ...t, + type: 'tree', + parentTreeUrl, + level, + }, state.project.url)), + ...data.submodules.map(m => utils.decorateData({ + ...m, + type: 'submodule', + parentTreeUrl, + level, + }, state.project.url)), + ...data.blobs.map(b => utils.decorateData({ + ...b, + type: 'blob', + parentTreeUrl, + level, + }, state.project.url)), + ], + }); + }, + [types.SET_PARENT_TREE_URL](state, url) { + Object.assign(state, { + parentTreeUrl: url, + }); + }, + [types.CREATE_TMP_TREE](state, { parent, tmpEntry }) { + parent.tree.push(tmpEntry); + }, +}; diff --git a/app/assets/javascripts/repo/stores/repo_store.js b/app/assets/javascripts/repo/stores/repo_store.js deleted file mode 100644 index 38df1e3e0d2..00000000000 --- a/app/assets/javascripts/repo/stores/repo_store.js +++ /dev/null @@ -1,189 +0,0 @@ -import Helper from '../helpers/repo_helper'; -import Service from '../services/repo_service'; - -const RepoStore = { - monacoLoading: false, - service: '', - canCommit: false, - onTopOfBranch: false, - editMode: false, - isRoot: null, - isInitialRoot: null, - prevURL: '', - projectId: '', - projectName: '', - projectUrl: '', - branchUrl: '', - blobRaw: '', - currentBlobView: 'repo-preview', - openedFiles: [], - submitCommitsLoading: false, - dialog: { - open: false, - title: '', - status: false, - }, - showNewBranchDialog: false, - activeFile: Helper.getDefaultActiveFile(), - activeFileIndex: 0, - activeLine: -1, - activeFileLabel: 'Raw', - files: [], - isCommitable: false, - binary: false, - currentBranch: '', - startNewMR: false, - currentHash: '', - currentShortHash: '', - customBranchURL: '', - newMrTemplateUrl: '', - branchChanged: false, - commitMessage: '', - path: '', - loading: { - tree: false, - blob: false, - }, - - setBranchHash() { - return Service.getBranch() - .then((data) => { - if (RepoStore.currentHash !== '' && data.commit.id !== RepoStore.currentHash) { - RepoStore.branchChanged = true; - } - RepoStore.currentHash = data.commit.id; - RepoStore.currentShortHash = data.commit.short_id; - }); - }, - - // mutations - checkIsCommitable() { - RepoStore.isCommitable = RepoStore.onTopOfBranch && RepoStore.canCommit; - }, - - toggleRawPreview() { - RepoStore.activeFile.raw = !RepoStore.activeFile.raw; - RepoStore.activeFileLabel = RepoStore.activeFile.raw ? 'Display rendered file' : 'Display source'; - }, - - setActiveFiles(file) { - if (RepoStore.isActiveFile(file)) return; - RepoStore.openedFiles = RepoStore.openedFiles - .map((openedFile, i) => RepoStore.setFileActivity(file, openedFile, i)); - - RepoStore.setActiveToRaw(); - - if (file.binary) { - RepoStore.blobRaw = file.base64; - } else if (file.newContent || file.plain) { - RepoStore.blobRaw = file.newContent || file.plain; - } else { - Service.getRaw(file) - .then((rawResponse) => { - RepoStore.blobRaw = rawResponse.data; - Helper.findOpenedFileFromActive().plain = rawResponse.data; - }).catch(Helper.loadingError); - } - - if (!file.loading && !file.tempFile) { - Helper.updateHistoryEntry(file.url, file.pageTitle || file.name); - } - RepoStore.binary = file.binary; - RepoStore.setActiveLine(-1); - }, - - setFileActivity(file, openedFile, i) { - const activeFile = openedFile; - activeFile.active = file.id === activeFile.id; - - if (activeFile.active) RepoStore.setActiveFile(activeFile, i); - - return activeFile; - }, - - setActiveFile(activeFile, i) { - RepoStore.activeFile = Object.assign({}, Helper.getDefaultActiveFile(), activeFile); - RepoStore.activeFileIndex = i; - }, - - setActiveLine(activeLine) { - if (!isNaN(activeLine)) RepoStore.activeLine = activeLine; - }, - - setActiveToRaw() { - RepoStore.activeFile.raw = false; - // can't get vue to listen to raw for some reason so RepoStore for now. - RepoStore.activeFileLabel = 'Display source'; - }, - - removeFromOpenedFiles(file) { - if (file.type === 'tree') return; - let foundIndex; - RepoStore.openedFiles = RepoStore.openedFiles.filter((openedFile, i) => { - if (openedFile.path === file.path) foundIndex = i; - return openedFile.path !== file.path; - }); - - // remove the file from the sidebar if it is a tempFile - if (file.tempFile) { - RepoStore.files = RepoStore.files.filter(f => !(f.tempFile && f.path === file.path)); - } - - // now activate the right tab based on what you closed. - if (RepoStore.openedFiles.length === 0) { - RepoStore.activeFile = {}; - return; - } - - if (RepoStore.openedFiles.length === 1 || foundIndex === 0) { - RepoStore.setActiveFiles(RepoStore.openedFiles[0]); - return; - } - - if (foundIndex && foundIndex > 0) { - RepoStore.setActiveFiles(RepoStore.openedFiles[foundIndex - 1]); - } - }, - - addToOpenedFiles(file) { - const openFile = file; - - const openedFilesAlreadyExists = RepoStore.openedFiles - .some(openedFile => openedFile.path === openFile.path); - - if (openedFilesAlreadyExists) return; - - openFile.changed = false; - openFile.active = true; - RepoStore.openedFiles.push(openFile); - }, - - setActiveFileContents(contents) { - if (!RepoStore.editMode) return; - const currentFile = RepoStore.openedFiles[RepoStore.activeFileIndex]; - RepoStore.activeFile.newContent = contents; - RepoStore.activeFile.changed = RepoStore.activeFile.plain !== RepoStore.activeFile.newContent; - currentFile.changed = RepoStore.activeFile.changed; - currentFile.newContent = contents; - }, - - toggleBlobView() { - RepoStore.currentBlobView = RepoStore.isPreviewView() ? 'repo-editor' : 'repo-preview'; - }, - - setViewToPreview() { - RepoStore.currentBlobView = 'repo-preview'; - }, - - // getters - - isActiveFile(file) { - return file && file.id === RepoStore.activeFile.id; - }, - - isPreviewView() { - return RepoStore.currentBlobView === 'repo-preview'; - }, -}; - -export default RepoStore; diff --git a/app/assets/javascripts/repo/stores/state.js b/app/assets/javascripts/repo/stores/state.js new file mode 100644 index 00000000000..aab74754f02 --- /dev/null +++ b/app/assets/javascripts/repo/stores/state.js @@ -0,0 +1,23 @@ +export default () => ({ + canCommit: false, + currentBranch: '', + currentBlobView: 'repo-preview', + currentRef: '', + discardPopupOpen: false, + editMode: false, + endpoints: {}, + isRoot: false, + isInitialRoot: false, + loading: false, + onTopOfBranch: false, + openFiles: [], + path: '', + project: { + id: 0, + name: '', + url: '', + }, + parentTreeUrl: '', + previousUrl: '', + tree: [], +}); diff --git a/app/assets/javascripts/repo/stores/utils.js b/app/assets/javascripts/repo/stores/utils.js new file mode 100644 index 00000000000..797c2b1e5b9 --- /dev/null +++ b/app/assets/javascripts/repo/stores/utils.js @@ -0,0 +1,108 @@ +export const dataStructure = () => ({ + id: '', + type: '', + name: '', + url: '', + path: '', + level: 0, + tempFile: false, + icon: '', + tree: [], + loading: false, + opened: false, + active: false, + changed: false, + lastCommit: {}, + tree_url: '', + blamePath: '', + commitsPath: '', + permalink: '', + rawPath: '', + binary: false, + html: '', + raw: '', + content: '', + parentTreeUrl: '', + renderError: false, + base64: false, +}); + +export const decorateData = (entity, projectUrl = '') => { + const { + id, + type, + url, + name, + icon, + last_commit, + tree_url, + path, + renderError, + content = '', + tempFile = false, + active = false, + opened = false, + changed = false, + parentTreeUrl = '', + level = 0, + base64 = false, + } = entity; + + return { + ...dataStructure(), + id, + type, + name, + url, + tree_url, + path, + level, + tempFile, + icon: `fa-${icon}`, + opened, + active, + parentTreeUrl, + changed, + renderError, + content, + base64, + // eslint-disable-next-line camelcase + lastCommit: last_commit ? { + url: `${projectUrl}/commit/${last_commit.id}`, + message: last_commit.message, + updatedAt: last_commit.committed_date, + } : {}, + }; +}; + +export const findEntry = (state, type, name) => state.tree.find( + f => f.type === type && f.name === name, +); +export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path); + +export const setPageTitle = (title) => { + document.title = title; +}; + +export const pushState = (url) => { + history.pushState({ url }, '', url); +}; + +export const createTemp = ({ name, path, type, level, changed, content, base64 }) => { + const treePath = path ? `${path}/${name}` : name; + + return decorateData({ + id: new Date().getTime().toString(), + name, + type, + tempFile: true, + path: treePath, + icon: type === 'tree' ? 'folder' : 'file-text-o', + changed, + content, + parentTreeUrl: '', + level, + base64, + renderError: base64, + }); +}; diff --git a/app/assets/javascripts/settings_panels.js b/app/assets/javascripts/settings_panels.js index 8635ccece6e..d34a21b37e1 100644 --- a/app/assets/javascripts/settings_panels.js +++ b/app/assets/javascripts/settings_panels.js @@ -1,34 +1,26 @@ -function expandSectionParent($section, $content) { - $section.addClass('expanded'); - $content.off('animationend.expandSectionParent'); -} - function expandSection($section) { $section.find('.js-settings-toggle').text('Collapse'); - - const $content = $section.find('.settings-content'); - $content.addClass('expanded').off('scroll.expandSection').scrollTop(0); - - if ($content.hasClass('no-animate')) { - expandSectionParent($section, $content); - } else { - $content.on('animationend.expandSectionParent', () => expandSectionParent($section, $content)); + $section.find('.settings-content').off('scroll.expandSection').scrollTop(0); + $section.addClass('expanded'); + if (!$section.hasClass('no-animate')) { + $section.addClass('animating') + .one('animationend.animateSection', () => $section.removeClass('animating')); } } function closeSection($section) { $section.find('.js-settings-toggle').text('Expand'); - - const $content = $section.find('.settings-content'); - $content.removeClass('expanded').on('scroll.expandSection', () => expandSection($section)); - + $section.find('.settings-content').on('scroll.expandSection', () => expandSection($section)); $section.removeClass('expanded'); + if (!$section.hasClass('no-animate')) { + $section.addClass('animating') + .one('animationend.animateSection', () => $section.removeClass('animating')); + } } function toggleSection($section) { - const $content = $section.find('.settings-content'); - $content.removeClass('no-animate'); - if ($content.hasClass('expanded')) { + $section.removeClass('no-animate'); + if ($section.hasClass('expanded')) { closeSection($section); } else { expandSection($section); @@ -39,10 +31,19 @@ export default function initSettingsPanels() { $('.settings').each((i, elm) => { const $section = $(elm); $section.on('click.toggleSection', '.js-settings-toggle', () => toggleSection($section)); - $section.find('.settings-content:not(.expanded)').on('scroll.expandSection', () => expandSection($section)); + + if (!$section.hasClass('expanded')) { + $section.find('.settings-content').on('scroll.expandSection', () => { + $section.removeClass('no-animate'); + expandSection($section); + }); + } }); if (location.hash) { - expandSection($(location.hash)); + const $target = $(location.hash); + if ($target.length && $target.hasClass('.settings')) { + expandSection($target); + } } } diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue new file mode 100644 index 00000000000..b8510a6ce3a --- /dev/null +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -0,0 +1,125 @@ +<script> +import { __, n__, sprintf } from '../../../locale'; +import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; +import userAvatarImage from '../../../vue_shared/components/user_avatar/user_avatar_image.vue'; + +export default { + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + participants: { + type: Array, + required: false, + default: () => [], + }, + numberOfLessParticipants: { + type: Number, + required: false, + default: 7, + }, + }, + data() { + return { + isShowingMoreParticipants: false, + }; + }, + components: { + loadingIcon, + userAvatarImage, + }, + computed: { + lessParticipants() { + return this.participants.slice(0, this.numberOfLessParticipants); + }, + visibleParticipants() { + return this.isShowingMoreParticipants ? this.participants : this.lessParticipants; + }, + hasMoreParticipants() { + return this.participants.length > this.numberOfLessParticipants; + }, + toggleLabel() { + let label = ''; + if (this.isShowingMoreParticipants) { + label = __('- show less'); + } else { + label = sprintf(__('+ %{moreCount} more'), { + moreCount: this.participants.length - this.numberOfLessParticipants, + }); + } + + return label; + }, + participantLabel() { + return sprintf( + n__('%{count} participant', '%{count} participants', this.participants.length), + { count: this.loading ? '' : this.participantCount }, + ); + }, + participantCount() { + return this.participants.length; + }, + }, + methods: { + toggleMoreParticipants() { + this.isShowingMoreParticipants = !this.isShowingMoreParticipants; + }, + }, +}; +</script> + +<template> + <div> + <div class="sidebar-collapsed-icon"> + <i + class="fa fa-users" + aria-hidden="true"> + </i> + <loading-icon + v-if="loading" + class="js-participants-collapsed-loading-icon" /> + <span + v-else + class="js-participants-collapsed-count"> + {{ participantCount }} + </span> + </div> + <div class="title hide-collapsed"> + <loading-icon + v-if="loading" + :inline="true" + class="js-participants-expanded-loading-icon" /> + {{ participantLabel }} + </div> + <div class="participants-list hide-collapsed"> + <div + v-for="participant in visibleParticipants" + :key="participant.id" + class="participants-author js-participants-author"> + <a + class="author_link" + :href="participant.web_url"> + <user-avatar-image + :lazy="true" + :img-src="participant.avatar_url" + css-classes="avatar-inline" + :size="24" + :tooltip-text="participant.name" + tooltip-placement="bottom" /> + </a> + </div> + </div> + <div + v-if="hasMoreParticipants" + class="participants-more hide-collapsed"> + <button + type="button" + class="btn-transparent btn-blank js-toggle-participants-button" + @click="toggleMoreParticipants"> + {{ toggleLabel }} + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue new file mode 100644 index 00000000000..c1296b28db7 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue @@ -0,0 +1,26 @@ +<script> +import Store from '../../stores/sidebar_store'; +import Mediator from '../../sidebar_mediator'; +import participants from './participants.vue'; + +export default { + data() { + return { + mediator: new Mediator(), + store: new Store(), + }; + }, + components: { + participants, + }, +}; +</script> + +<template> + <div class="block participants"> + <participants + :loading="store.isFetching.participants" + :participants="store.participants" + :number-of-less-participants="7" /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue new file mode 100644 index 00000000000..4ad3d469f25 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue @@ -0,0 +1,45 @@ +<script> +import Store from '../../stores/sidebar_store'; +import Mediator from '../../sidebar_mediator'; +import eventHub from '../../event_hub'; +import Flash from '../../../flash'; +import subscriptions from './subscriptions.vue'; + +export default { + data() { + return { + mediator: new Mediator(), + store: new Store(), + }; + }, + + components: { + subscriptions, + }, + + methods: { + onToggleSubscription() { + this.mediator.toggleSubscription() + .catch(() => { + Flash('Error occurred when toggling the notification subscription'); + }); + }, + }, + + created() { + eventHub.$on('toggleSubscription', this.onToggleSubscription); + }, + + beforeDestroy() { + eventHub.$off('toggleSubscription', this.onToggleSubscription); + }, +}; +</script> + +<template> + <div class="block subscriptions"> + <subscriptions + :loading="store.isFetching.subscriptions" + :subscribed="store.subscribed" /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue new file mode 100644 index 00000000000..a3a8213d63a --- /dev/null +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -0,0 +1,60 @@ +<script> +import { __ } from '../../../locale'; +import eventHub from '../../event_hub'; +import loadingButton from '../../../vue_shared/components/loading_button.vue'; + +export default { + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + subscribed: { + type: Boolean, + required: false, + }, + }, + components: { + loadingButton, + }, + computed: { + buttonLabel() { + let label; + if (this.subscribed === false) { + label = __('Subscribe'); + } else if (this.subscribed === true) { + label = __('Unsubscribe'); + } + + return label; + }, + }, + methods: { + toggleSubscription() { + eventHub.$emit('toggleSubscription'); + }, + }, +}; +</script> + +<template> + <div> + <div class="sidebar-collapsed-icon"> + <i + class="fa fa-rss" + aria-hidden="true"> + </i> + </div> + <span class="issuable-header-text hide-collapsed pull-left"> + {{ __('Notifications') }} + </span> + <loading-button + ref="loadingButton" + class="btn btn-default pull-right hide-collapsed js-issuable-subscribe-button" + :loading="loading" + :label="buttonLabel" + @click="toggleSubscription" + /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js index 604648407a4..37c97225bfd 100644 --- a/app/assets/javascripts/sidebar/services/sidebar_service.js +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -7,6 +7,7 @@ export default class SidebarService { constructor(endpointMap) { if (!SidebarService.singleton) { this.endpoint = endpointMap.endpoint; + this.toggleSubscriptionEndpoint = endpointMap.toggleSubscriptionEndpoint; this.moveIssueEndpoint = endpointMap.moveIssueEndpoint; this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint; @@ -36,6 +37,10 @@ export default class SidebarService { }); } + toggleSubscription() { + return Vue.http.post(this.toggleSubscriptionEndpoint); + } + moveIssue(moveToProjectId) { return Vue.http.post(this.moveIssueEndpoint, { move_to_project_id: moveToProjectId, diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js index 09b9d75c02d..2650bb725d4 100644 --- a/app/assets/javascripts/sidebar/sidebar_bundle.js +++ b/app/assets/javascripts/sidebar/sidebar_bundle.js @@ -4,6 +4,8 @@ import SidebarAssignees from './components/assignees/sidebar_assignees'; import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue'; import SidebarMoveIssue from './lib/sidebar_move_issue'; import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue'; +import sidebarParticipants from './components/participants/sidebar_participants.vue'; +import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue'; import Translate from '../vue_shared/translate'; import Mediator from './sidebar_mediator'; @@ -49,6 +51,36 @@ function mountLockComponent(mediator) { }).$mount(el); } +function mountParticipantsComponent() { + const el = document.querySelector('.js-sidebar-participants-entry-point'); + + if (!el) return; + + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + sidebarParticipants, + }, + render: createElement => createElement('sidebar-participants', {}), + }); +} + +function mountSubscriptionsComponent() { + const el = document.querySelector('.js-sidebar-subscriptions-entry-point'); + + if (!el) return; + + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + sidebarSubscriptions, + }, + render: createElement => createElement('sidebar-subscriptions', {}), + }); +} + function domContentLoaded() { const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML); const mediator = new Mediator(sidebarOptions); @@ -63,6 +95,8 @@ function domContentLoaded() { mountConfidentialComponent(mediator); mountLockComponent(mediator); + mountParticipantsComponent(); + mountSubscriptionsComponent(); new SidebarMoveIssue( mediator, diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index ede3a0de144..2bda5a47791 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -8,6 +8,7 @@ export default class SidebarMediator { this.store = new Store(options); this.service = new Service({ endpoint: options.endpoint, + toggleSubscriptionEndpoint: options.toggleSubscriptionEndpoint, moveIssueEndpoint: options.moveIssueEndpoint, projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint, }); @@ -39,10 +40,25 @@ export default class SidebarMediator { .then((data) => { this.store.setAssigneeData(data); this.store.setTimeTrackingData(data); + this.store.setParticipantsData(data); + this.store.setSubscriptionsData(data); }) .catch(() => new Flash('Error occurred when fetching sidebar data')); } + toggleSubscription() { + this.store.setFetchingState('subscriptions', true); + return this.service.toggleSubscription() + .then(() => { + this.store.setSubscribedState(!this.store.subscribed); + this.store.setFetchingState('subscriptions', false); + }) + .catch((err) => { + this.store.setFetchingState('subscriptions', false); + throw err; + }); + } + fetchAutocompleteProjects(searchTerm) { return this.service.getProjectsAutocomplete(searchTerm) .then(response => response.json()) diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index d5d04103f3f..3150221b685 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -12,10 +12,14 @@ export default class SidebarStore { this.assignees = []; this.isFetching = { assignees: true, + participants: true, + subscriptions: true, }; this.autocompleteProjects = []; this.moveToProjectId = 0; this.isLockDialogOpen = false; + this.participants = []; + this.subscribed = null; SidebarStore.singleton = this; } @@ -37,6 +41,20 @@ export default class SidebarStore { this.humanTotalTimeSpent = data.human_total_time_spent; } + setParticipantsData(data) { + this.isFetching.participants = false; + this.participants = data.participants || []; + } + + setSubscriptionsData(data) { + this.isFetching.subscriptions = false; + this.subscribed = data.subscribed || false; + } + + setFetchingState(key, value) { + this.isFetching[key] = value; + } + addAssignee(assignee) { if (!this.findAssignee(assignee)) { this.assignees.push(assignee); @@ -61,6 +79,10 @@ export default class SidebarStore { this.autocompleteProjects = projects; } + setSubscribedState(subscribed) { + this.subscribed = subscribed; + } + setMoveToProjectId(moveToProjectId) { this.moveToProjectId = moveToProjectId; } diff --git a/app/assets/javascripts/test_utils/index.js b/app/assets/javascripts/test_utils/index.js index 8875590f0f2..a55a338eea8 100644 --- a/app/assets/javascripts/test_utils/index.js +++ b/app/assets/javascripts/test_utils/index.js @@ -1,6 +1,8 @@ import 'core-js/es6/map'; import 'core-js/es6/set'; import simulateDrag from './simulate_drag'; +import simulateInput from './simulate_input'; // Export to global space for rspec to use window.simulateDrag = simulateDrag; +window.simulateInput = simulateInput; diff --git a/app/assets/javascripts/test_utils/simulate_input.js b/app/assets/javascripts/test_utils/simulate_input.js new file mode 100644 index 00000000000..90c1b7cb57e --- /dev/null +++ b/app/assets/javascripts/test_utils/simulate_input.js @@ -0,0 +1,23 @@ +function triggerEvents(input) { + input.dispatchEvent(new Event('keydown')); + input.dispatchEvent(new Event('keypress')); + input.dispatchEvent(new Event('input')); + input.dispatchEvent(new Event('keyup')); +} + +export default function simulateInput(target, text) { + const input = document.querySelector(target); + if (!input || !input.matches('textarea, input')) { + return false; + } + + if (text.length > 0) { + Array.prototype.forEach.call(text, (char) => { + input.value += char; + triggerEvents(input); + }); + } else { + triggerEvents(input); + } + return true; +} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js index c79b5c720eb..029832bdd27 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js @@ -1,6 +1,6 @@ import PipelineStage from '../../pipelines/components/stage.vue'; import ciIcon from '../../vue_shared/components/ci_icon.vue'; -import { statusIconEntityMap } from '../../vue_shared/ci_status_icons'; +import icon from '../../vue_shared/components/icon.vue'; export default { name: 'MRWidgetPipeline', @@ -10,6 +10,7 @@ export default { components: { 'pipeline-stage': PipelineStage, ciIcon, + icon, }, computed: { hasPipeline() { @@ -20,9 +21,6 @@ export default { return hasCI && !ciStatus; }, - svg() { - return statusIconEntityMap.icon_status_failed; - }, stageText() { return this.mr.pipeline.details.stages.length > 1 ? 'stages' : 'stage'; }, @@ -38,8 +36,10 @@ export default { <template v-if="hasCIError"> <div class="ci-status-icon ci-status-icon-failed ci-error js-ci-error append-right-10"> <span - v-html="svg" - aria-hidden="true"></span> + aria-hidden="true"> + <icon + name="status_failed"/> + </span> </div> <div class="media-body"> Could not connect to the CI server. Please check your settings and try again diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js index 79c3d335679..99f5c305df5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -11,7 +11,7 @@ export default class MRWidgetService { this.removeWIPResource = Vue.resource(endpoints.removeWIPPath); this.removeSourceBranchResource = Vue.resource(endpoints.sourceBranchPath); this.deploymentsResource = Vue.resource(endpoints.ciEnvironmentsStatusPath); - this.pollResource = Vue.resource(`${endpoints.statusPath}?basic=true`); + this.pollResource = Vue.resource(`${endpoints.statusPath}?serializer=basic`); this.mergeActionsContentResource = Vue.resource(endpoints.mergeActionsContentPath); } diff --git a/app/assets/javascripts/vue_shared/ci_action_icons.js b/app/assets/javascripts/vue_shared/ci_action_icons.js deleted file mode 100644 index b21f0ab49fd..00000000000 --- a/app/assets/javascripts/vue_shared/ci_action_icons.js +++ /dev/null @@ -1,21 +0,0 @@ -import cancelSVG from 'icons/_icon_action_cancel.svg'; -import retrySVG from 'icons/_icon_action_retry.svg'; -import playSVG from 'icons/_icon_action_play.svg'; -import stopSVG from 'icons/_icon_action_stop.svg'; - -/** - * For the provided action returns the respective SVG - * - * @param {String} action - * @return {SVG|String} - */ -export default function getActionIcon(action) { - const icons = { - icon_action_cancel: cancelSVG, - icon_action_play: playSVG, - icon_action_retry: retrySVG, - icon_action_stop: stopSVG, - }; - - return icons[action] || ''; -} diff --git a/app/assets/javascripts/vue_shared/ci_status_icons.js b/app/assets/javascripts/vue_shared/ci_status_icons.js deleted file mode 100644 index d9d0cad38e4..00000000000 --- a/app/assets/javascripts/vue_shared/ci_status_icons.js +++ /dev/null @@ -1,43 +0,0 @@ -import BORDERLESS_CANCELED_SVG from 'icons/_icon_status_canceled_borderless.svg'; -import BORDERLESS_CREATED_SVG from 'icons/_icon_status_created_borderless.svg'; -import BORDERLESS_FAILED_SVG from 'icons/_icon_status_failed_borderless.svg'; -import BORDERLESS_MANUAL_SVG from 'icons/_icon_status_manual_borderless.svg'; -import BORDERLESS_PENDING_SVG from 'icons/_icon_status_pending_borderless.svg'; -import BORDERLESS_RUNNING_SVG from 'icons/_icon_status_running_borderless.svg'; -import BORDERLESS_SKIPPED_SVG from 'icons/_icon_status_skipped_borderless.svg'; -import BORDERLESS_SUCCESS_SVG from 'icons/_icon_status_success_borderless.svg'; -import BORDERLESS_WARNING_SVG from 'icons/_icon_status_warning_borderless.svg'; - -import CANCELED_SVG from 'icons/_icon_status_canceled.svg'; -import CREATED_SVG from 'icons/_icon_status_created.svg'; -import FAILED_SVG from 'icons/_icon_status_failed.svg'; -import MANUAL_SVG from 'icons/_icon_status_manual.svg'; -import PENDING_SVG from 'icons/_icon_status_pending.svg'; -import RUNNING_SVG from 'icons/_icon_status_running.svg'; -import SKIPPED_SVG from 'icons/_icon_status_skipped.svg'; -import SUCCESS_SVG from 'icons/_icon_status_success.svg'; -import WARNING_SVG from 'icons/_icon_status_warning.svg'; - -export const borderlessStatusIconEntityMap = { - icon_status_canceled: BORDERLESS_CANCELED_SVG, - icon_status_created: BORDERLESS_CREATED_SVG, - icon_status_failed: BORDERLESS_FAILED_SVG, - icon_status_manual: BORDERLESS_MANUAL_SVG, - icon_status_pending: BORDERLESS_PENDING_SVG, - icon_status_running: BORDERLESS_RUNNING_SVG, - icon_status_skipped: BORDERLESS_SKIPPED_SVG, - icon_status_success: BORDERLESS_SUCCESS_SVG, - icon_status_warning: BORDERLESS_WARNING_SVG, -}; - -export const statusIconEntityMap = { - icon_status_canceled: CANCELED_SVG, - icon_status_created: CREATED_SVG, - icon_status_failed: FAILED_SVG, - icon_status_manual: MANUAL_SVG, - icon_status_pending: PENDING_SVG, - icon_status_running: RUNNING_SVG, - icon_status_skipped: SKIPPED_SVG, - icon_status_success: SUCCESS_SVG, - icon_status_warning: WARNING_SVG, -}; diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue index 5b6c6e8d0b9..fc795936abf 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -43,7 +43,6 @@ computed: { cssClass() { const className = this.status.group; - return className ? `ci-status ci-${className}` : 'ci-status'; }, }, diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index ec88119e16c..2a018f38366 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -1,5 +1,5 @@ <script> - import { statusIconEntityMap } from '../ci_status_icons'; + import icon from '../../vue_shared/components/icon.vue'; /** * Renders CI icon based on API response shared between all places where it is used. @@ -30,11 +30,11 @@ }, }, - computed: { - statusIconSvg() { - return statusIconEntityMap[this.status.icon]; - }, + components: { + icon, + }, + computed: { cssClass() { const status = this.status.group; return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`; @@ -44,7 +44,8 @@ </script> <template> <span - :class="cssClass" - v-html="statusIconSvg"> + :class="cssClass"> + <icon + :name="status.icon"/> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue new file mode 100644 index 00000000000..2e5f9f1088f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/icon.vue @@ -0,0 +1,52 @@ +<script> + +/* This is a re-usable vue component for rendering a svg sprite + icon + + Sample configuration: + + <icon + :img-src="userAvatarSrc" + :img-alt="tooltipText" + :tooltip-text="tooltipText" + tooltip-placement="top" + /> + +*/ + export default { + props: { + name: { + type: String, + required: true, + }, + + size: { + type: Number, + required: false, + default: 0, + }, + + cssClasses: { + type: String, + required: false, + default: '', + }, + }, + + computed: { + spriteHref() { + return `${gon.sprite_icons}#${this.name}`; + }, + iconSizeClass() { + return this.size ? `s${this.size}` : ''; + }, + }, + }; +</script> +<template> + <svg + :class="[iconSizeClass, cssClasses]"> + <use + v-bind="{'xlink:href':spriteHref}"/> + </svg> +</template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue index 95898d54cf7..dc32e783258 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue @@ -12,12 +12,14 @@ :img-alt="tooltipText" :img-size="20" :tooltip-text="tooltipText" - tooltip-placement="top" + :tooltip-placement="top" + :username="username" /> */ import userAvatarImage from './user_avatar_image.vue'; +import tooltip from '../../directives/tooltip'; export default { name: 'UserAvatarLink', @@ -60,6 +62,22 @@ export default { required: false, default: 'top', }, + username: { + type: String, + required: false, + default: '', + }, + }, + computed: { + shouldShowUsername() { + return this.username.length > 0; + }, + avatarTooltipText() { + return this.shouldShowUsername ? '' : this.tooltipText; + }, + }, + directives: { + tooltip, }, }; </script> @@ -73,8 +91,13 @@ export default { :img-alt="imgAlt" :css-classes="imgCssClasses" :size="imgSize" - :tooltip-text="tooltipText" + :tooltip-text="avatarTooltipText" + :tooltip-placement="tooltipPlacement" + /><span + v-if="shouldShowUsername" + v-tooltip + :title="tooltipText" :tooltip-placement="tooltipPlacement" - /> + >{{username}}</span> </a> </template> diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 7b1ef003bb2..c334f39f416 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -56,4 +56,4 @@ @import "framework/icons"; @import "framework/snippets"; @import "framework/memory_graph"; -@import "framework/responsive-tables"; +@import "framework/responsive_tables"; diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 81439c0d2fe..1b944831082 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -23,11 +23,16 @@ @include webkit-prefix(animation-duration, 2s); } - &.spin { + &.spin-cw { transform-origin: center; animation: spin 4s linear infinite; } + &.spin-ccw { + transform-origin: center; + animation: spin 4s linear infinite reverse; + } + &.flipOutX, &.flipOutY, &.bounceIn, diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index dbd990f84c1..def986180fc 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -40,6 +40,10 @@ &.top-block { border-top: none; + + .container-fluid { + background-color: inherit; + } } &.middle-block { @@ -98,10 +102,6 @@ background-color: $white-light; border-top: none; } - - &.top-block .container-fluid { - background-color: inherit; - } } .sub-header-block { @@ -209,7 +209,6 @@ padding: 24px 0 0; .nav-links { - justify-content: center; width: 100%; float: none; @@ -217,6 +216,14 @@ float: none; } } + + li:first-child { + margin-left: auto; + } + + li:last-child { + margin-right: auto; + } } .group-info { diff --git a/app/assets/stylesheets/framework/callout.scss b/app/assets/stylesheets/framework/callout.scss index e0e46dd73af..1bd94c0acba 100644 --- a/app/assets/stylesheets/framework/callout.scss +++ b/app/assets/stylesheets/framework/callout.scss @@ -12,15 +12,15 @@ border-left: 3px solid $border-color; color: $text-color; background: $gray-light; -} -.bs-callout h4 { - margin-top: 0; - margin-bottom: 5px; -} + h4 { + margin-top: 0; + margin-bottom: 5px; + } -.bs-callout p:last-child { - margin-bottom: 0; + p:last-child { + margin-bottom: 0; + } } /* Variations */ diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 96f9dda26c4..ea3007f5e08 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -5,32 +5,6 @@ .cgreen { color: $common-green; } .cdark { color: $common-gray-dark; } -/** COMMON CLASSES **/ -.prepend-top-0 { margin-top: 0; } -.prepend-top-5 { margin-top: 5px; } -.prepend-top-10 { margin-top: 10px; } -.prepend-top-default { margin-top: $gl-padding !important; } -.prepend-top-20 { margin-top: 20px; } -.prepend-left-4 { margin-left: 4px; } -.prepend-left-5 { margin-left: 5px; } -.prepend-left-10 { margin-left: 10px; } -.prepend-left-default { margin-left: $gl-padding; } -.prepend-left-20 { margin-left: 20px; } -.append-right-5 { margin-right: 5px; } -.append-right-8 { margin-right: 8px; } -.append-right-10 { margin-right: 10px; } -.append-right-default { margin-right: $gl-padding; } -.append-right-20 { margin-right: 20px; } -.append-bottom-0 { margin-bottom: 0; } -.append-bottom-5 { margin-bottom: 5px; } -.append-bottom-10 { margin-bottom: 10px; } -.append-bottom-15 { margin-bottom: 15px; } -.append-bottom-20 { margin-bottom: 20px; } -.append-bottom-default { margin-bottom: $gl-padding; } -.inline { display: inline-block; } -.center { text-align: center; } -.vertical-align-middle { vertical-align: middle; } - .underlined-link { text-decoration: underline; } .hint { font-style: italic; color: $hint-color; } .light { color: $common-gray; } @@ -79,6 +53,14 @@ hr { .str-truncated { @include str-truncated; + + &-60 { + @include str-truncated(60%); + } + + &-100 { + @include str-truncated(100%); + } } .block-truncated { @@ -104,10 +86,17 @@ hr { font-size: 14px; } -table a code { - position: relative; - top: -2px; - margin-right: 3px; +table { + a code { + position: relative; + top: -2px; + margin-right: 3px; + } + + td.permission-x { + background: $table-permission-x-bg !important; + text-align: center; + } } .loading { @@ -292,13 +281,6 @@ img.emoji { margin-bottom: 10px; } -table { - td.permission-x { - background: $table-permission-x-bg !important; - text-align: center; - } -} - .btn-sign-in { text-shadow: none; @@ -364,10 +346,11 @@ table { .dropzone .dz-preview .dz-progress { border-color: $border-color !important; -} -.dropzone .dz-preview .dz-progress .dz-upload { - background: $gl-success !important; + .dz-upload { + background: $gl-success !important; + } + } .dz-message { @@ -428,16 +411,6 @@ table { border-radius: $border-radius-default; } -.str-truncated { - &-60 { - @include str-truncated(60%); - } - - &-100 { - @include str-truncated(100%); - } -} - .tooltip { .tooltip-inner { word-wrap: break-word; @@ -448,3 +421,30 @@ table { pointer-events: none; opacity: .5; } + +/** COMMON CLASSES **/ +.prepend-top-0 { margin-top: 0; } +.prepend-top-5 { margin-top: 5px; } +.prepend-top-10 { margin-top: 10px; } +.prepend-top-15 { margin-top: 15px; } +.prepend-top-default { margin-top: $gl-padding !important; } +.prepend-top-20 { margin-top: 20px; } +.prepend-left-4 { margin-left: 4px; } +.prepend-left-5 { margin-left: 5px; } +.prepend-left-10 { margin-left: 10px; } +.prepend-left-default { margin-left: $gl-padding; } +.prepend-left-20 { margin-left: 20px; } +.append-right-5 { margin-right: 5px; } +.append-right-8 { margin-right: 8px; } +.append-right-10 { margin-right: 10px; } +.append-right-default { margin-right: $gl-padding; } +.append-right-20 { margin-right: 20px; } +.append-bottom-0 { margin-bottom: 0; } +.append-bottom-5 { margin-bottom: 5px; } +.append-bottom-10 { margin-bottom: 10px; } +.append-bottom-15 { margin-bottom: 15px; } +.append-bottom-20 { margin-bottom: 20px; } +.append-bottom-default { margin-bottom: $gl-padding; } +.inline { display: inline-block; } +.center { text-align: center; } +.vertical-align-middle { vertical-align: middle; } diff --git a/app/assets/stylesheets/framework/contextual-sidebar.scss b/app/assets/stylesheets/framework/contextual-sidebar.scss index fa5d3833f3e..320f458630a 100644 --- a/app/assets/stylesheets/framework/contextual-sidebar.scss +++ b/app/assets/stylesheets/framework/contextual-sidebar.scss @@ -141,15 +141,15 @@ svg { fill: $gl-text-color-secondary; } - } - .nav-item-name { - flex: 1; - } + .nav-item-name { + flex: 1; + } - li.active { - > a { - font-weight: $gl-font-weight-bold; + &.active { + > a { + font-weight: $gl-font-weight-bold; + } } } @@ -484,10 +484,7 @@ height: calc(100vh - #{$header-height}); @media (min-width: $screen-sm-min) { - height: 475px; // Needed for PhantomJS - // scss-lint:disable DuplicateProperty height: calc(100vh - 180px); - // scss-lint:enable DuplicateProperty } } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index a9d804e735d..08c603edd23 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -727,11 +727,11 @@ .pika-single.animate-picker.is-bound { @include set-visible; -} -.pika-single.animate-picker.is-bound.is-hidden { - @include set-invisible; - overflow: hidden; + &.is-hidden { + @include set-invisible; + overflow: hidden; + } } @mixin dropdown-item-hover { @@ -776,12 +776,15 @@ a, button, .menu-item { + margin-bottom: 0; border-radius: 0; box-shadow: none; padding: 8px 16px; text-align: left; white-space: normal; width: 100%; + font-weight: $gl-font-weight-normal; + line-height: normal; &.dropdown-menu-user-link { white-space: nowrap; @@ -935,9 +938,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu { border-right: 0; } } -} -.projects-dropdown-container { .projects-list-frequent-container, .projects-list-search-container, { padding: 8px 0; @@ -948,11 +949,6 @@ header.header-content .dropdown-menu.projects-dropdown-menu { .projects-list-frequent-container li.section-empty, .projects-list-search-container li.section-empty { padding: 0 15px; - } - - .section-header, - .projects-list-frequent-container li.section-empty, - .projects-list-search-container li.section-empty { color: $gl-text-color-secondary; font-size: $gl-font-size; } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 5833ef939e9..6382551fcc9 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -165,22 +165,36 @@ &:last-child { border-right: none; } - } - td.blame-commit { - padding: 5px 10px; - min-width: 400px; - max-width: 400px; - background: $gray-light; - border-left: 3px solid; + &.blame-commit { + padding: 5px 10px; + min-width: 400px; + max-width: 400px; + background: $gray-light; + border-left: 3px solid; + + .commit-row-title { + display: flex; + } + + .item-title { + flex: 1; + margin-right: 0.5em; + } + } + + &.line-numbers { + float: none; + border-left: 1px solid $blame-line-numbers-border; - .commit-row-title { - display: flex; + i { + float: none; + margin-right: 0; + } } - .item-title { - flex: 1; - margin-right: 0.5em; + &.lines { + padding: 0; } } @@ -195,20 +209,6 @@ border-left-color: mix($blame-gray, $blame-cyan, $i / 4.0 * 100%); } } - - td.line-numbers { - float: none; - border-left: 1px solid $blame-line-numbers-border; - - i { - float: none; - margin-right: 0; - } - } - - td.lines { - padding: 0; - } } &.logs { diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 0d80a85d521..a7333925f80 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -268,12 +268,6 @@ .filtered-search-box-input-container { flex: 1; position: relative; - // Fix PhantomJS not supporting `flex: 1;` properly. - // This is important because it can change the expected `e.target` when clicking things in tests. - // See https://gitlab.com/gitlab-org/gitlab-ce/blob/b54acba8b732688c59fe2f38510c469dc86ee499/spec/features/issues/filtered_search/visual_tokens_spec.rb#L61 - // - With `width: 100%`: `e.target` = `.tokens-container`, https://i.imgur.com/jGq7wbx.png - // - Without `width: 100%`: `e.target` = `.filtered-search`, https://i.imgur.com/cNI2CyT.png - width: 100%; min-width: 0; } @@ -469,10 +463,10 @@ word-break: break-all; } } -} -.filter-dropdown-item.droplab-item-active .btn { - @extend %filter-dropdown-item-btn-hover; + &.droplab-item-active .btn { + @extend %filter-dropdown-item-btn-hover; + } } .filter-dropdown-loading { diff --git a/app/assets/stylesheets/framework/gitlab-theme.scss b/app/assets/stylesheets/framework/gitlab-theme.scss index 52b87de7a3d..dc591c06c88 100644 --- a/app/assets/stylesheets/framework/gitlab-theme.scss +++ b/app/assets/stylesheets/framework/gitlab-theme.scss @@ -216,12 +216,9 @@ body { color: $theme-gray-900; } - &.active > a { + &.active > a, + &.active > a:hover { color: $white-light; - - &:hover { - color: $white-light; - } } } } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index d79444fad79..5d777f0d468 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -239,10 +239,8 @@ fill: currentColor; } - &.header-user-dropdown-toggle { - .header-user-avatar { - border-color: $white-light; - } + &.header-user-dropdown-toggle .header-user-avatar { + border-color: $white-light; } } } @@ -354,7 +352,77 @@ .header-user .dropdown-menu-nav, .header-new .dropdown-menu-nav { - margin-top: $dropdown-vertical-offset; + margin-top: 4px; +} + +.search { + margin: 4px 8px 0; + + form { + height: 32px; + border: 0; + border-radius: $border-radius-default; + transition: border-color ease-in-out 0.15s, background-color ease-in-out 0.15s; + + &:hover { + box-shadow: none; + } + } + + .search-input { + color: $white-light; + background: none; + transition: color ease-in-out 0.15s; + } + + .search-input::placeholder { + transition: color ease-in-out 0.15s; + } + + .location-badge { + font-size: 12px; + margin: -4px 4px -4px -4px; + line-height: 25px; + padding: 4px 8px; + border-radius: 2px 0 0 2px; + height: 32px; + transition: border-color ease-in-out 0.15s; + } + + &.search-active { + form { + background-color: rgba($indigo-200, .3); + box-shadow: none; + + .search-input { + color: $gl-text-color; + transition: color ease-in-out 0.15s; + } + + .search-input::placeholder { + color: $gl-text-color-tertiary; + } + + .search-input-wrap { + .search-icon, + .clear-icon { + color: $gl-text-color-tertiary; + transition: color ease-in-out 0.15s; + } + } + } + + .location-badge { + background-color: $nav-badge-bg; + border-color: $border-color; + } + + .search-input-wrap { + .clear-icon { + color: $white-light; + } + } + } } .breadcrumbs { diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 69d19ea2962..cb324ccc440 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -30,10 +30,10 @@ body { .container { padding-top: 0; z-index: 5; -} -.container .content { - margin: 0; + .content { + margin: 0; + } } .navless-container { @@ -82,26 +82,26 @@ body { transition: background-color 0.15s, border-color 0.15s; background-color: $orange-500; border-color: $orange-500; - } - .alert-warning + .alert-warning { - background-color: $orange-600; - border-color: $orange-600; - } + &:only-of-type { + background-color: $orange-500; + border-color: $orange-500; + } - .alert-warning + .alert-warning + .alert-warning { - background-color: $orange-700; - border-color: $orange-700; - } + + .alert-warning { + background-color: $orange-600; + border-color: $orange-600; - .alert-warning + .alert-warning + .alert-warning + .alert-warning { - background-color: $orange-800; - border-color: $orange-800; - } + + .alert-warning { + background-color: $orange-700; + border-color: $orange-700; - .alert-warning:only-of-type { - background-color: $orange-500; - border-color: $orange-500; + + .alert-warning { + background-color: $orange-800; + border-color: $orange-800; + } + } + } } } diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index d43f998cb82..511608c618c 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -299,40 +299,40 @@ ul.indent-list { } } -.group-list-tree .avatar-container.content-loading { - position: relative; +.group-list-tree { + .avatar-container.content-loading { + position: relative; - > a, - > a .avatar { - height: 100%; - border-radius: 50%; - } + > a, + > a .avatar { + height: 100%; + border-radius: 50%; + } - > a { - padding: 2px; - } + > a { + padding: 2px; - > a .avatar { - border: 2px solid $white-normal; + .avatar { + border: 2px solid $white-normal; - &.identicon { - line-height: 30px; + &.identicon { + line-height: 30px; + } + } } - } - &::after { - content: ""; - position: absolute; - height: 100%; - width: 100%; - background-color: transparent; - border: 2px outset $kdb-border; - border-radius: 50%; - animation: spin-avatar 3s infinite linear; + &::after { + content: ""; + position: absolute; + height: 100%; + width: 100%; + background-color: transparent; + border: 2px outset $kdb-border; + border-radius: 50%; + animation: spin-avatar 3s infinite linear; + } } -} -.group-list-tree { .folder-toggle-wrap { float: left; line-height: $list-text-height; diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index e3920b5d3d9..0a5a16c09b0 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -173,21 +173,8 @@ ul > li { white-space: nowrap; } -} - -@media(max-width: $screen-xs-max) { - .atwho-view-ul { - width: 350px; - } - - .atwho-view ul li { - overflow: hidden; - text-overflow: ellipsis; - } -} -// TODO: fallback to global style -.atwho-view { + // TODO: fallback to global style .atwho-view-ul { padding: 8px 1px; @@ -220,3 +207,14 @@ } } } + +@media(max-width: $screen-xs-max) { + .atwho-view-ul { + width: 350px; + } + + .atwho-view ul li { + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/app/assets/stylesheets/framework/responsive-tables.scss b/app/assets/stylesheets/framework/responsive_tables.scss index 8e653c443cf..8b7afdbe1a5 100644 --- a/app/assets/stylesheets/framework/responsive-tables.scss +++ b/app/assets/stylesheets/framework/responsive_tables.scss @@ -3,57 +3,77 @@ max-width: #{$max + '%'}; } +.gl-responsive-table-row-layout { + width: 100%; + + @media (min-width: $screen-md-min) { + display: flex; + align-items: center; + + & > &:not(:first-child) { + margin-top: $gl-padding; + } + } +} + .gl-responsive-table-row { + @extend .gl-responsive-table-row-layout; margin-top: 10px; border: 1px solid $border-color; @media (min-width: $screen-md-min) { - padding: 15px 0; margin: 0; - display: flex; - align-items: center; + padding: $gl-padding 0; border: none; - border-bottom: 1px solid $white-normal; + + &:not(:last-child) { + border-bottom: 1px solid $white-normal; + } } +} - .table-section { - white-space: nowrap; +.gl-responsive-table-row-col-span { + flex-wrap: wrap; +} + +.table-section { + white-space: nowrap; - $section-widths: 10 15 20 25 30 40; - @each $width in $section-widths { - &.section-#{$width} { - flex: 0 0 #{$width + '%'}; + $section-widths: 10 15 20 25 30 40 100; + @each $width in $section-widths { + &.section-#{$width} { + flex: 0 0 #{$width + '%'}; - @media (min-width: $screen-md-min) { - max-width: #{$width + '%'}; - } + @media (min-width: $screen-md-min) { + max-width: #{$width + '%'}; } } + } - &:not(.table-button-footer) { - @media (max-width: $screen-sm-max) { - display: flex; - align-self: stretch; - padding: 10px; - align-items: center; - min-height: 62px; + @media (max-width: $screen-sm-max) { + display: flex; + align-self: stretch; + padding: 10px; + align-items: center; + min-height: 62px; - &:not(:first-of-type) { - border-top: 1px solid $white-normal; - } - } + &:not(:first-child) { + border-top: 1px solid $white-normal; } + } - &.section-wrap { - white-space: normal; + &.section-wrap { + white-space: normal; - @media (max-width: $screen-sm-max) { - flex-wrap: wrap; - } + @media (max-width: $screen-sm-max) { + flex-wrap: wrap; } } -} + &.section-align-top { + align-self: flex-start; + } +} .table-button-footer { @media (min-width: $screen-md-min) { @@ -61,12 +81,13 @@ } @media (max-width: $screen-sm-max) { - background-color: $gray-normal; + display: block; align-self: stretch; + min-height: 0; + background-color: $gray-normal; border-top: 1px solid $border-color; .table-action-buttons { - padding: 10px 5px; display: flex; .btn { @@ -77,7 +98,14 @@ > .external-url, > .btn { flex: 1 1 28px; - margin: 0 5px; + + &:not(:first-child) { + margin-left: 5px; + } + + &:not(:last-child) { + margin-right: 5px; + } } .dropdown-new { diff --git a/app/assets/stylesheets/framework/secondary-navigation-elements.scss b/app/assets/stylesheets/framework/secondary-navigation-elements.scss index 3fd2549b143..9e1f77e5726 100644 --- a/app/assets/stylesheets/framework/secondary-navigation-elements.scss +++ b/app/assets/stylesheets/framework/secondary-navigation-elements.scss @@ -340,11 +340,64 @@ } } -.project-item-select-holder.btn-group { - display: flex; - max-width: 350px; - overflow: hidden; - float: right; +.page-with-layout-nav { + .right-sidebar { + top: ($header-height + 1) * 2; + } + + &.page-with-sub-nav { + .right-sidebar { + top: ($header-height + 1) * 3; + + &.affix { + top: $header-height; + } + } + } +} + +.with-performance-bar .page-with-layout-nav { + .right-sidebar { + top: ($header-height + 1) * 2 + $performance-bar-height; + } + + &.page-with-sub-nav { + .right-sidebar { + top: ($header-height + 1) * 3 + $performance-bar-height; + + &.affix { + top: $header-height + $performance-bar-height; + } + } + } +} + +@media (max-width: $screen-xs-max) { + .top-area { + flex-flow: row wrap; + + .nav-controls { + $controls-margin: $btn-xs-side-margin - 2px; + flex: 0 0 100%; + + &.controls-flex { + display: flex; + flex-flow: row wrap; + align-items: center; + justify-content: center; + padding: 0 0 $gl-padding-top; + } + + .controls-item, + .controls-item-full, + .controls-item:last-child { + flex: 1 1 35%; + display: block; + width: 100%; + margin: $controls-margin; + } + } + } .new-project-item-link { white-space: nowrap; diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index 621eec4f158..aa35cd9bea4 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -60,22 +60,12 @@ border-radius: $border-radius-base; border: 1px solid $dropdown-border-color; min-width: 175px; - color: $gl-text-color; - z-index: 999; + color: $gl-grayish-blue; } -.select2-drop-mask { - z-index: 998; -} - -.select2-drop.select2-drop-above.select2-drop-active { - border-top: 1px solid $dropdown-border-color; - margin-top: -6px; -} - -.select2-results li.select2-result-with-children > .select2-result-label { - font-weight: $gl-font-weight-bold; - color: $gl-text-color; +.select2-results .select2-result-label, +.select2-more-results { + padding: 10px 15px; } .select2-container-active { @@ -144,58 +134,46 @@ .select2-drop-auto-width & { padding: 15px 15px 5px; } -} -.select2-search input { - padding: 2px 25px 2px 5px; - background: $white-light image-url('select2.png'); - background-repeat: no-repeat; - background-position: right 0 bottom 6px; - border: 1px solid $input-border; - border-radius: $border-radius-default; - transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; - - &:focus { - border-color: $input-border-focus; + input { + padding: 2px 25px 2px 5px; + background: $white-light image-url('select2.png'); + background-repeat: no-repeat; + background-position: right 0 bottom 6px; + border: 1px solid $input-border; + border-radius: $border-radius-default; + transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; + + &:focus { + border-color: $input-border-focus; + } + + &.select2-active { + background-color: $white-light; + background-image: image-url('select2-spinner.gif') !important; + background-repeat: no-repeat; + background-position: right 5px center !important; + background-size: 16px 16px !important; + } } } -.select2-search input.select2-active { - background-color: $white-light; - background-image: image-url('select2-spinner.gif') !important; - background-repeat: no-repeat; - background-position: right 5px center !important; - background-size: 16px 16px !important; +.select2-results .select2-no-results, +.select2-results .select2-searching, +.select2-results .select2-ajax-error, +.select2-results .select2-selection-limit { + background: $gray-light; + display: list-item; + padding: 10px 15px; } .select2-results { margin: 0; - padding: #{$gl-padding / 2} 0; - - .select2-no-results, - .select2-searching, - .select2-ajax-error, - .select2-selection-limit { - background: transparent; - padding: #{$gl-padding / 2} $gl-padding; - } - - .select2-result-label, - .select2-more-results { - padding: #{$gl-padding / 2} $gl-padding; - } + padding: 10px 0; - .select2-highlighted { - background: transparent; + li.select2-result-with-children > .select2-result-label { + font-weight: $gl-font-weight-bold; color: $gl-text-color; - - .select2-result-label { - background: $dropdown-item-hover-bg; - } - } - - .select2-result { - padding: 0 1px; } } @@ -212,6 +190,8 @@ } .select2-highlighted { + background: $gl-link-color !important; + .group-result { .group-path { color: $white-light; diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss index 65b140cd7f8..c3d8f0c61a2 100644 --- a/app/assets/stylesheets/highlight/white.scss +++ b/app/assets/stylesheets/highlight/white.scss @@ -217,13 +217,31 @@ $white-gc-bg: #eaf2f5; .cp { color: $white-cp; font-weight: $gl-font-weight-bold; } .c1 { color: $white-c1; font-style: italic; } .cs { color: $white-cs; font-weight: $gl-font-weight-bold; font-style: italic; } - .gd { color: $white-gd; background-color: $white-gd-bg; } - .gd .x { color: $white-gd-x; background-color: $white-gd-x-bg; } + + .gd { + color: $white-gd; + background-color: $white-gd-bg; + + .x { + color: $white-gd-x; + background-color: $white-gd-x-bg; + } + } + .ge { font-style: italic; } .gr { color: $white-gr; } .gh { color: $white-gh; } - .gi { color: $white-gi; background-color: $white-gi-bg; } - .gi .x { color: $white-gi-x; background-color: $white-gi-x-bg; } + + .gi { + color: $white-gi; + background-color: $white-gi-bg; + + .x { + color: $white-gi-x; + background-color: $white-gi-x-bg; + } + } + .go { color: $white-go; } .gp { color: $white-gp; } .gs { font-weight: $gl-font-weight-bold; } diff --git a/app/assets/stylesheets/mailers/highlighted_diff_email.scss b/app/assets/stylesheets/mailers/highlighted_diff_email.scss index fbe538ad1d7..658ac26fca9 100644 --- a/app/assets/stylesheets/mailers/highlighted_diff_email.scss +++ b/app/assets/stylesheets/mailers/highlighted_diff_email.scss @@ -158,13 +158,31 @@ span.highlight_word { .cp { color: $highlighted-cp; font-weight: $gl-font-weight-bold; } .c1 { color: $highlighted-c1; font-style: italic; } .cs { color: $highlighted-cs; font-weight: $gl-font-weight-bold; font-style: italic; } -.gd { color: $highlighted-gd; background-color: $highlighted-gd-bg; } -.gd .x { color: $highlighted-gd; background-color: $highlighted-gd-x-bg; } + +.gd { + color: $highlighted-gd; + background-color: $highlighted-gd-bg; + + .x { + color: $highlighted-gd; + background-color: $highlighted-gd-x-bg; + } +} + .ge { font-style: italic; } .gr { color: $highlighted-gr; } .gh { color: $highlighted-gh; } -.gi { color: $highlighted-gi; background-color: $highlighted-gi-bg; } -.gi .x { color: $highlighted-gi; background-color: $highlighted-gi-x-bg; } + +.gi { + color: $highlighted-gi; + background-color: $highlighted-gi-bg; + + .x { + color: $highlighted-gi; + background-color: $highlighted-gi-x-bg; + } +} + .go { color: $highlighted-go; } .gp { color: $highlighted-gp; } .gs { font-weight: $gl-font-weight-bold; } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 91296b354a7..3683afa07de 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -72,7 +72,7 @@ } .boards-list { - height: calc(100vh - 152px); + height: calc(100vh - 105px); width: 100%; padding-top: 25px; padding-bottom: 25px; @@ -81,11 +81,12 @@ overflow-x: scroll; white-space: nowrap; - @media (min-width: $screen-sm-min) { - height: 475px; // Needed for PhantomJS - // scss-lint:disable DuplicateProperty - height: calc(100vh - 222px); - // scss-lint:enable DuplicateProperty + @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { + height: calc(100vh - 90px); + } + + @media (min-width: $screen-md-min) { + height: calc(100vh - 160px); min-height: 475px; } } diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 50ec5110bf1..46978be8ba0 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -68,18 +68,18 @@ &.affix { top: $header-height; - } - // with sidebar - &.affix.sidebar-expanded { - right: 306px; - left: 16px; - } + // with sidebar + &.sidebar-expanded { + right: 306px; + left: 16px; + } - // without sidebar - &.affix.sidebar-collapsed { - right: 16px; - left: 16px; + // without sidebar + &.sidebar-collapsed { + right: 16px; + left: 16px; + } } &.affix-top { @@ -333,8 +333,10 @@ svg { position: relative; - top: 2px; + top: 3px; margin-right: 3px; + width: 14px; + height: 14px; } } @@ -348,9 +350,10 @@ svg { position: relative; - top: 2px; + top: 3px; margin-right: 3px; - height: 13px; + height: 14px; + width: 14px; } a { @@ -369,7 +372,7 @@ .build-job { position: relative; - .fa-arrow-right { + .icon-arrow-right { position: absolute; left: 15px; top: 20px; @@ -379,7 +382,7 @@ &.active { font-weight: $gl-font-weight-bold; - .fa-arrow-right { + .icon-arrow-right { display: block; } } @@ -392,8 +395,7 @@ background-color: $row-hover; } - .fa-refresh { - font-size: 13px; + .icon-retry { margin-left: 3px; } } diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index 8d6f30e3b84..5c91579c69c 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -2,8 +2,4 @@ .clipboard-addon { background-color: $white-light; } - - .alert-block { - margin-bottom: 10px; - } } diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index 2a92673d9fa..82d9be29201 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -22,6 +22,11 @@ } } } + + svg { + width: 136px; + height: 136px; + } } .col-headers { @@ -155,11 +160,6 @@ } } - .landing svg { - width: 136px; - height: 136px; - } - .fa-spinner { font-size: 28px; position: relative; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 09f831dcb29..faa3d1fb4d5 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -380,6 +380,10 @@ } } } + + .line_content { + white-space: pre-wrap; + } } .file-content .diff-file { @@ -387,10 +391,6 @@ border: none; } -.diff-file .line_content { - white-space: pre-wrap; -} - .diff-wrap-lines .line_content { white-space: pre-wrap; } diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 3b5e411e2c5..b5b0f3d9dfa 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -133,12 +133,11 @@ } .folder-row { - padding: 15px 0; - border-bottom: 1px solid $white-normal; + border-left: none; + border-right: none; - @media (max-width: $screen-sm-max) { - border-top: 1px solid $white-normal; - margin-top: 10px; + @media (min-width: $screen-sm-max) { + border-top: none; } } @@ -256,23 +255,6 @@ width: 100%; padding: 0; padding-bottom: 100%; -} - -.prometheus-svg-container > svg { - position: absolute; - height: 100%; - width: 100%; - left: 0; - top: 0; - - text { - fill: $gl-text-color; - stroke-width: 0; - } - - .text-metric-bold { - font-weight: $gl-font-weight-bold; - } .label-axis-text { fill: $black; @@ -287,42 +269,51 @@ font-size: 12px; } - .legend-axis-text { - fill: $black; - } + > svg { + position: absolute; + height: 100%; + width: 100%; + left: 0; + top: 0; - .tick { - > line { - stroke: $gray-darker; + .label-axis-text, + .text-metric-usage { + fill: $black; + font-weight: $gl-font-weight-normal; + font-size: 12px; } - > text { - font-size: 12px; + .legend-axis-text { + fill: $black; } - } - .text-metric-title { - font-size: 12px; - } + .tick > text { + font-size: 12px; + } - .y-label-text, - .x-label-text { - fill: $gray-darkest; - } + .text-metric-title { + font-size: 12px; + } - .axis-tick { - stroke: $gray-darker; - } + .y-label-text, + .x-label-text { + fill: $gray-darkest; + } - @media (max-width: $screen-sm-max) { - .label-axis-text, - .text-metric-usage, - .legend-axis-text { - font-size: 8px; + .axis-tick { + stroke: $gray-darker; } - .tick > text { - font-size: 8px; + @media (max-width: $screen-sm-max) { + .label-axis-text, + .text-metric-usage, + .legend-axis-text { + font-size: 8px; + } + + .tick > text { + font-size: 8px; + } } } } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 48532503263..7059a4cfe85 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -127,7 +127,16 @@ } .right-sidebar { - a:not(.btn-retry), + position: absolute; + top: $header-height; + bottom: 0; + right: 0; + transition: width .3s; + background: $gray-light; + z-index: 200; + overflow: hidden; + + a, .btn-link { color: inherit; } @@ -228,17 +237,6 @@ .btn-clipboard:hover { color: $gl-text-color; } -} - -.right-sidebar { - position: absolute; - top: $header-height; - bottom: 0; - right: 0; - transition: width $right-sidebar-transition-duration; - background: $gray-light; - z-index: 200; - overflow: hidden; .issuable-sidebar { width: calc(100% + 100px); @@ -542,7 +540,9 @@ } .participants-list { - margin: -5px; + display: flex; + flex-wrap: wrap; + margin: -7px; } @@ -553,7 +553,7 @@ .participants-author { display: inline-block; - padding: 5px; + padding: 7px; &:nth-of-type(7n) { padding-right: 0; diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index cf5f933a762..92d49bd864a 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -109,6 +109,30 @@ border-top-right-radius: $border-radius-default; border-top-left-radius: $border-radius-default; + // Ldap configurations may need more tabs & the tab labels are user generated (arbitrarily long). + // These styles prevent this from breaking the layout, and only applied when providers are configured. + &.custom-provider-tabs { + flex-wrap: wrap; + + li { + min-width: 85px; + flex-basis: auto; + + // This styles tab elements that have wrapped to a second line. We cannot easily predict when this will happen. + // We are making somewhat of an assumption about the configuration here: that users do not have more than + // 3 LDAP servers configured (in addition to standard login) and they are not using especially long names for any + // of them. If either condition is false, this will work as expected. If both are true, there may be a missing border + // above one of the bottom row elements. If you know a better way, please implement it! + &:nth-child(n+5) { + border-top: 1px solid $border-color; + } + } + + a { + font-size: 16px; + } + } + li { flex: 1; text-align: center; @@ -154,32 +178,6 @@ } } - // Ldap configurations may need more tabs & the tab labels are user generated (arbitrarily long). - // These styles prevent this from breaking the layout, and only applied when providers are configured. - - .new-session-tabs.custom-provider-tabs { - flex-wrap: wrap; - - li { - min-width: 85px; - flex-basis: auto; - - // This styles tab elements that have wrapped to a second line. We cannot easily predict when this will happen. - // We are making somewhat of an assumption about the configuration here: that users do not have more than - // 3 LDAP servers configured (in addition to standard login) and they are not using especially long names for any - // of them. If either condition is false, this will work as expected. If both are true, there may be a missing border - // above one of the bottom row elements. If you know a better way, please implement it! - &:nth-child(n+5) { - border-top: 1px solid $border-color; - } - } - - a { - font-size: 16px; - } - } - - .form-control { &:active, &:focus { @@ -231,35 +229,35 @@ margin: 0; padding: 0; height: 100%; -} -// Fixes footer container to bottom of viewport -.devise-layout-html body { - // offset height of fixed header + 1 to avoid scroll - height: calc(100% - 51px); - margin: 0; - padding: 0; + // Fixes footer container to bottom of viewport + body { + // offset height of fixed header + 1 to avoid scroll + height: calc(100% - 51px); + margin: 0; + padding: 0; - .page-wrap { - min-height: 100%; - position: relative; - } + .page-wrap { + min-height: 100%; + position: relative; + } - .footer-container, - hr.footer-fixed { - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 40px; - background: $white-light; - } + .footer-container, + hr.footer-fixed { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 40px; + background: $white-light; + } - .navless-container { - padding: 65px 15px; // height of footer + bottom padding of email confirmation link + .navless-container { + padding: 65px 15px; // height of footer + bottom padding of email confirmation link - @media (max-width: $screen-xs-max) { - padding: 0 15px 65px; + @media (max-width: $screen-xs-max) { + padding: 0 15px 65px; + } } } } diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index 692acf74a58..18c48405ecd 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -49,9 +49,17 @@ width: auto; } } + + &.existing-title { + @media (min-width: $screen-sm-min) { + float: left; + } + } } .member-form-control { + @include new-style-dropdown; + @media (max-width: $screen-xs-max) { padding-bottom: 5px; margin-left: 0; @@ -64,12 +72,6 @@ line-height: 43px; } -.member.existing-title { - @media (min-width: $screen-sm-min) { - float: left; - } -} - .member-search-form { @include new-style-dropdown; @@ -281,7 +283,3 @@ } } } - -.member-form-control { - @include new-style-dropdown; -} diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index d9fb3b44d29..6e485ebad1b 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -156,6 +156,10 @@ &.media > *:first-child { margin-right: 10px; } + + .approve-btn { + margin-right: 5px; + } } .mr-widget-pipeline-graph { @@ -165,8 +169,9 @@ z-index: 300; } - .ci-action-icon-wrapper { - line-height: 16px; + .ci-action-icon-wrapper svg { + width: 16px; + height: 16px; } } @@ -190,6 +195,10 @@ overflow: hidden; word-break: break-all; + &.media > *:first-child { + margin-right: 10px; + } + &.label-truncated { position: relative; display: inline-block; @@ -207,14 +216,7 @@ background-color: $gray-light; } } - } - .mr-widget-help { - padding: 10px 16px 10px 48px; - font-style: italic; - } - - .mr-widget-body { h4 { float: left; font-weight: $gl-font-weight-bold; @@ -237,6 +239,10 @@ margin-right: 7px; } + .approve-btn { + margin-right: 5px; + } + label { font-weight: $gl-font-weight-normal; } @@ -336,6 +342,22 @@ } } + .mini-pipeline-graph-dropdown-menu .mini-pipeline-graph-dropdown-item { + display: flex; + align-items: center; + + .ci-status-text, + .ci-status-icon { + top: 0; + margin-right: 10px; + } + } + + .mr-widget-help { + padding: 10px 16px 10px 48px; + font-style: italic; + } + .ci-coverage { float: right; } @@ -350,12 +372,6 @@ } } -.mr-state-widget .mr-widget-body { - .approve-btn { - margin-right: 5px; - } -} - .mr-widget-body-controls { flex-wrap: wrap; } @@ -469,16 +485,16 @@ padding-bottom: 0; } } -} -.mr-info-list.mr-memory-usage { - p { - float: left; - } + &.mr-memory-usage { + p { + float: left; + } - .memory-graph-container { - float: left; - margin-left: 5px; + .memory-graph-container { + float: left; + margin-left: 5px; + } } } diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss index 32039936be7..ae8fa45a2d7 100644 --- a/app/assets/stylesheets/pages/milestone.scss +++ b/app/assets/stylesheets/pages/milestone.scss @@ -66,6 +66,15 @@ height: 6px; margin: 0; } + + .sidebar-collapsed-icon { + clear: both; + padding: 15px 5px 5px; + + .progress { + margin: 5px 0; + } + } } .collapsed-milestone-date { @@ -93,17 +102,6 @@ margin-right: 0; } - .milestone-progress { - .sidebar-collapsed-icon { - clear: both; - padding: 15px 5px 5px; - - .progress { - margin: 5px 0; - } - } - } - .right-sidebar-collapsed & { .reference { border-top: 1px solid $border-gray-normal; @@ -156,18 +154,16 @@ .status-box { margin-top: 0; - } - - .milestone-buttons { - margin-left: auto; - } - - .status-box { order: 1; } .milestone-buttons { + margin-left: auto; order: 2; + + .verbose { + display: none; + } } .header-text-content { @@ -175,10 +171,6 @@ width: 100%; } - .milestone-buttons .verbose { - display: none; - } - @media (min-width: $screen-xs-min) { .milestone-buttons .verbose { display: inline; diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index f0cad30f4f3..5127307c5e7 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -111,24 +111,9 @@ margin: auto; align-items: center; - .icon { - margin-right: $issuable-warning-icon-margin; - } -} - -.disabled-comment .issuable-note-warning { - border: none; - border-radius: $label-border-radius; - padding-top: $gl-vert-padding; - padding-bottom: $gl-vert-padding; - - .icon svg { - position: relative; - top: 2px; - margin-right: $btn-xs-side-margin; - width: $gl-font-size; - height: $gl-font-size; - fill: $orange-600; + + .md-area { + border-top-left-radius: 0; + border-top-right-radius: 0; } } @@ -155,11 +140,6 @@ } } -.issuable-note-warning + .md-area { - border-top-left-radius: 0; - border-top-right-radius: 0; -} - .discussion-form { background-color: $white-light; } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 3bd0e3ad535..ca363c6eac4 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -269,7 +269,7 @@ ul.notes { display: none; } - &.system-note-commit-list { + &.system-note-commit-list:not(.hide-shade) { max-height: 70px; overflow: hidden; display: block; @@ -291,16 +291,6 @@ ul.notes { bottom: 0; background: linear-gradient(rgba($white-light, 0.1) -100px, $white-light 100%); } - - &.hide-shade { - max-height: 100%; - overflow: auto; - - &::after { - display: none; - background: transparent; - } - } } } } @@ -322,57 +312,72 @@ ul.notes { } } -.diff-file .notes_holder { - font-family: $regular_font; +.diff-file { + .is-over { + .add-diff-note { + display: inline-block; + } + } - td { - border: 1px solid $white-normal; - border-left: none; + // Merge request notes in diffs + // Diff is inline + .notes_content .note-header .note-headline-light { + display: inline-block; + position: relative; + } - &.notes_line { - vertical-align: middle; - text-align: center; - padding: 10px 0; - background: $gray-light; - color: $text-color; - } + .notes_holder { + font-family: $regular_font; - &.notes_line2 { - text-align: center; - padding: 10px 0; - border-left: 1px solid $note-line2-border !important; - } + td { + border: 1px solid $white-normal; + border-left: none; - &.notes_content { - background-color: $gray-light; - border-width: 1px 0; - padding: 0; - vertical-align: top; - white-space: normal; + &.notes_line { + vertical-align: middle; + text-align: center; + padding: 10px 0; + background: $gray-light; + color: $text-color; + } - &.parallel { - border-width: 1px; + &.notes_line2 { + text-align: center; + padding: 10px 0; + border-left: 1px solid $note-line2-border !important; } - .discussion-notes { - &:not(:first-child) { - border-top: 1px solid $white-normal; - margin-top: 20px; + &.notes_content { + background-color: $gray-light; + border-width: 1px 0; + padding: 0; + vertical-align: top; + white-space: normal; + + &.parallel { + border-width: 1px; } - &:not(:last-child) { - border-bottom: 1px solid $white-normal; - margin-bottom: 20px; + .discussion-notes { + &:not(:first-child) { + border-top: 1px solid $white-normal; + margin-top: 20px; + } + + &:not(:last-child) { + border-bottom: 1px solid $white-normal; + margin-bottom: 20px; + } } - } - .notes { - background-color: $white-light; - } + .notes { + background-color: $white-light; + } - a code { - top: 0; - margin-right: 0; + a code { + top: 0; + margin-right: 0; + } } } } @@ -467,8 +472,9 @@ ul.notes { margin-left: 10px; color: $gray-darkest; - .btn-group > .discussion-next-btn { - margin-left: -1px; + @include notes-media('max', $screen-md-max) { + float: none; + margin-left: 0; } } @@ -479,8 +485,6 @@ ul.notes { flex-shrink: 0; display: inline-flex; align-items: center; - // For PhantomJS that does not support flex - float: right; margin-left: 10px; color: $gray-darkest; @@ -491,7 +495,6 @@ ul.notes { } .more-actions { - float: right; // phantomjs fallback display: flex; align-items: flex-end; @@ -512,13 +515,6 @@ ul.notes { min-width: 180px; } -.discussion-actions { - @include notes-media('max', $screen-md-max) { - float: none; - margin-left: 0; - } -} - .note-actions-item { margin-left: 12px; display: flex; @@ -675,14 +671,6 @@ ul.notes { } } -.diff-file { - .is-over { - .add-diff-note { - display: inline-block; - } - } -} - .disabled-comment { background-color: $gray-light; border-radius: $border-radius-base; @@ -724,20 +712,20 @@ ul.notes { svg path { fill: $gray-darkest; } - } - .btn.discussion-create-issue-btn { - margin-left: -4px; - border-radius: 0; - border-right: 0; + &.discussion-create-issue-btn { + margin-left: -4px; + border-radius: 0; + border-right: 0; - a { - padding: 0; - line-height: 0; + a { + padding: 0; + line-height: 0; - &:hover { - text-decoration: none; - border: 0; + &:hover { + text-decoration: none; + border: 0; + } } } } @@ -811,12 +799,3 @@ ul.notes { .line-resolve-text { vertical-align: middle; } - -// Merge request notes in diffs -.diff-file { - // Diff is inline - .notes_content .note-header .note-headline-light { - display: inline-block; - position: relative; - } -} diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 8fc7a5eec9b..2a8cbc61af7 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -31,7 +31,6 @@ } .pipeline-actions { - padding-right: 0; min-width: 170px; //Guarantees buttons don't break in several lines. .btn-default { @@ -176,6 +175,25 @@ } } + /** + * Play button with icon in dropdowns + */ + .no-btn { + border: none; + background: none; + outline: none; + width: 100%; + text-align: left; + + .icon-play { + position: relative; + top: 2px; + margin-right: 5px; + height: 13px; + width: 12px; + } + } + .duration, .finished-at { color: $gl-text-color-secondary; @@ -451,36 +469,46 @@ @extend .build-content:hover; } - // Action Icons in big pipeline-graph nodes - .ci-action-icon-container .ci-action-icon-wrapper { - height: 30px; - width: 30px; - background: $white-light; - border: 1px solid $border-color; - border-radius: 100%; - display: block; - - &:hover { - background-color: $stage-hover-bg; - border: 1px solid $dropdown-toggle-active-border-color; - } - - svg { - fill: $gl-text-color-secondary; - position: relative; - left: -1px; - top: -1px; - } - - &:hover svg { - fill: $gl-text-color; - } - } - .ci-action-icon-container { position: absolute; right: 5px; top: 5px; + + // Action Icons in big pipeline-graph nodes + &.ci-action-icon-wrapper { + height: 30px; + width: 30px; + background: $white-light; + border: 1px solid $border-color; + border-radius: 100%; + display: block; + + &:hover { + background-color: $stage-hover-bg; + border: 1px solid $dropdown-toggle-active-border-color; + + svg { + fill: $gl-text-color; + } + } + + svg { + fill: $gl-text-color-secondary; + position: relative; + left: 5px; + top: 2px; + width: 18px; + height: 18px; + } + + &.play { + svg { + width: #{$ci-action-icon-size - 8}; + height: #{$ci-action-icon-size - 8}; + left: 8px; + } + } + } } .ci-status-icon svg { @@ -721,17 +749,50 @@ button.mini-pipeline-graph-dropdown-toggle { svg { fill: $gl-text-color-secondary; - width: $ci-action-icon-size; - height: $ci-action-icon-size; - left: -6px; + width: #{$ci-action-icon-size - 6}; + height: #{$ci-action-icon-size - 6}; + left: -3px; position: relative; - top: -3px; + top: -2px; + + &.icon-action-stop, + &.icon-action-cancel { + width: 12px; + height: 12px; + top: 1px; + left: -1px; + } + + &.icon-action-play { + width: 11px; + height: 11px; + top: 1px; + left: 1px; + } + + &.icon-action-retry { + width: 16px; + height: 16px; + top: 0; + left: -3px; + } } &:hover svg, &:focus svg { fill: $gl-text-color; } + + &.icon-action-retry, + &.icon-action-play { + svg { + width: #{$ci-action-icon-size - 6}; + height: #{$ci-action-icon-size - 6}; + left: 8px; + } + } + + } // link to the build @@ -799,13 +860,10 @@ button.mini-pipeline-graph-dropdown-toggle { left: 100%; top: -10px; box-shadow: 0 1px 5px $black-transparent; -} - -/** - * Top arrow in the dropdown in the big pipeline graph - */ -.big-pipeline-graph-dropdown-menu { + /** + * Top arrow in the dropdown in the big pipeline graph + */ &::before, &::after { content: ''; @@ -867,22 +925,23 @@ button.mini-pipeline-graph-dropdown-toggle { margin-top: 1px; border-bottom-color: $white-light; } -} -/** - * Center dropdown menu in mini graph - */ -.mini-pipeline-graph-dropdown-menu.dropdown-menu { - transform: translate(-80%, 0); - min-width: 150px; + /** + * Center dropdown menu in mini graph + */ + &.dropdown-menu { + transform: translate(-80%, 0); + min-width: 150px; - @media(min-width: $screen-md-min) { - transform: translate(-50%, 0); - right: auto; - left: 50%; - min-width: 240px; + @media(min-width: $screen-md-min) { + transform: translate(-50%, 0); + right: auto; + left: 50%; + min-width: 240px; + } } } + /** * Terminal */ @@ -906,25 +965,6 @@ button.mini-pipeline-graph-dropdown-toggle { } } -/** - * Play button with icon in dropdowns - */ -.ci-table .no-btn { - border: none; - background: none; - outline: none; - width: 100%; - text-align: left; - - .icon-play { - position: relative; - top: 2px; - margin-right: 5px; - height: 13px; - width: 12px; - } -} - .ci-header-container { min-height: 55px; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index bd385db9692..b0c3474e3d5 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -88,7 +88,8 @@ transition: background 2s ease-out; &:disabled { - opacity: 0.75; + opacity: 0.5; + pointer-events: none; } .highlight-changes & { @@ -778,35 +779,35 @@ a.deploy-project-label { .nav { padding-top: 12px; padding-bottom: 12px; - } - .nav > li { - display: inline-block; + > li { + display: inline-block; - &:not(:last-child) { - margin-right: $gl-padding; - } + &:not(:last-child) { + margin-right: $gl-padding; + } - &.right { - vertical-align: top; - margin-top: 0; + &.right { + vertical-align: top; + margin-top: 0; - @media (min-width: $screen-lg-min) { - float: right; + @media (min-width: $screen-lg-min) { + float: right; + } } - } - } - .nav > li > a { - padding: 0; - background-color: transparent; - font-size: 14px; - line-height: 29px; - color: $notes-light-color; + > a { + padding: 0; + background-color: transparent; + font-size: 14px; + line-height: 29px; + color: $notes-light-color; - &:hover, - &:focus { - color: $gl-text-color; + &:hover, + &:focus { + color: $gl-text-color; + } + } } } @@ -1160,13 +1161,6 @@ pre.light-well { } } -.project-repo-select { - &.disabled { - opacity: 0.5; - pointer-events: none; - } -} - .variables-table { table-layout: fixed; diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 6a363b1710e..1bb4e3cc345 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -1,12 +1,3 @@ -.monaco-loader { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: $black-transparent; -} - .modal.popup-dialog { display: block; background-color: $black-transparent; @@ -54,6 +45,7 @@ } .tree-content-holder { + display: -webkit-flex; display: flex; min-height: 300px; } @@ -63,7 +55,9 @@ } .panel-right { + display: -webkit-flex; display: flex; + -webkit-flex-direction: column; flex-direction: column; width: 80%; height: 100%; @@ -81,10 +75,6 @@ text-decoration: underline; } } - - .cursor { - display: none !important; - } } .blob-no-preview { @@ -94,21 +84,12 @@ } } - &.edit-mode { - .blob-viewer-container { - overflow: hidden; - } - - .monaco-editor.vs { - .cursor { - background: $black; - border-color: $black; - display: block !important; - } - } + &.blob-editor-container { + overflow: hidden; } .blob-viewer-container { + -webkit-flex: 1; flex: 1; overflow: auto; @@ -138,6 +119,7 @@ } #tabs { + position: relative; flex-shrink: 0; display: flex; width: 100%; @@ -166,6 +148,10 @@ vertical-align: middle; text-decoration: none; margin-right: 12px; + + &:focus { + outline: none; + } } .close-btn { @@ -312,23 +298,3 @@ width: 100%; } } - -@keyframes swipeRightAppear { - 0% { - transform: scaleX(0.00); - } - - 100% { - transform: scaleX(1.00); - } -} - -@keyframes swipeRightDissapear { - 0% { - transform: scaleX(1.00); - } - - 100% { - transform: scaleX(0.00); - } -} diff --git a/app/assets/stylesheets/pages/runners.scss b/app/assets/stylesheets/pages/runners.scss index 6cac37a4e28..5fb97b13470 100644 --- a/app/assets/stylesheets/pages/runners.scss +++ b/app/assets/stylesheets/pages/runners.scss @@ -50,3 +50,10 @@ font-size: 11px; } } + +@media (max-width: $screen-md-max) { + .runners-content { + width: 100%; + overflow: auto; + } +} diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index db0a04a5eb3..eed711b1b66 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -78,6 +78,10 @@ input[type="checkbox"]:hover { } .search-input-wrap { + // Fallback if flexbox is not supported + display: inline-block; + width: 100%; + .search-icon, .clear-icon { position: absolute; diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 41a6ba2023a..8b9b47a41bc 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -23,15 +23,14 @@ } .settings { - overflow: hidden; border-bottom: 1px solid $gray-darker; &:first-of-type { margin-top: 10px; } - &.expanded { - overflow: visible; + &.animating { + overflow: hidden; } } @@ -56,14 +55,18 @@ overflow-y: scroll; padding-right: 110px; animation: collapseMaxHeight 300ms ease-out; + // Keep the section from expanding when we scroll over it + pointer-events: none; - &.expanded { + .settings.expanded & { max-height: none; overflow-y: visible; animation: expandMaxHeight 300ms ease-in; + // Reset and allow clicks again when expanded + pointer-events: auto; } - &.no-animate { + .settings.no-animate & { animation: none; } @@ -238,11 +241,11 @@ margin-left: 5px; background: $badge-bg; } - } - /* Ensure we don't add border if there's only single li */ - li + li { - border-top: 1px solid $border-color; + /* Ensure we don't add border if there's only single li */ + + li { + border-top: 1px solid $border-color; + } } } } diff --git a/app/assets/stylesheets/pages/sherlock.scss b/app/assets/stylesheets/pages/sherlock.scss index bfe065dbbaf..2bf0bedb1f5 100644 --- a/app/assets/stylesheets/pages/sherlock.scss +++ b/app/assets/stylesheets/pages/sherlock.scss @@ -5,10 +5,10 @@ table .sherlock-code { .sherlock-code { pre { word-wrap: normal; - } - pre code { - white-space: pre; + code { + white-space: pre; + } } } @@ -21,13 +21,13 @@ table .sherlock-code { text-align: right; padding: 0 10px !important; } + + .slow { + color: $red-500; + font-weight: $gl-font-weight-bold; + } } .sherlock-file-sample pre { padding-top: 28px !important; } - -.sherlock-line-samples-table .slow { - color: $red-500; - font-weight: $gl-font-weight-bold; -} diff --git a/app/assets/stylesheets/pages/stat_graph.scss b/app/assets/stylesheets/pages/stat_graph.scss index dfa4d033fb8..cede147d559 100644 --- a/app/assets/stylesheets/pages/stat_graph.scss +++ b/app/assets/stylesheets/pages/stat_graph.scss @@ -40,16 +40,16 @@ @media (max-width: $screen-xs-max) { width: 100%; } - } - .person .spark { - display: block; - background: $stat-graph-common-bg; - width: 100%; - } + .spark { + display: block; + background: $stat-graph-common-bg; + width: 100%; + } - .person .area-contributor { - fill: $stat-graph-orange-fill; + .area-contributor { + fill: $stat-graph-orange-fill; + } } } diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss index b7d4e7bf582..e150f96f3fa 100644 --- a/app/assets/stylesheets/pages/wiki.scss +++ b/app/assets/stylesheets/pages/wiki.scss @@ -161,10 +161,10 @@ ul.wiki-pages-list.content-list { list-style: none; margin-left: 0; padding-left: 15px; - } - ul li { - padding: 5px 0; + li { + padding: 5px 0; + } } } diff --git a/app/assets/stylesheets/test.scss b/app/assets/stylesheets/test.scss index 06733b7f1a9..e65b49c36f3 100644 --- a/app/assets/stylesheets/test.scss +++ b/app/assets/stylesheets/test.scss @@ -4,11 +4,6 @@ -ms-transition: none !important; -webkit-transition: none !important; transition: none !important; - -o-transform: none !important; - -moz-transform: none !important; - -ms-transform: none !important; - -webkit-transform: none !important; - transform: none !important; -webkit-animation: none !important; -moz-animation: none !important; -o-animation: none !important; diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index fb6d8c0bb81..5be23c76a95 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -19,10 +19,12 @@ class Admin::ApplicationsController < Admin::ApplicationController end def create - @application = Doorkeeper::Application.new(application_params) + @application = Applications::CreateService.new(current_user, application_params).execute(request) - if @application.save - redirect_to_admin_page + if @application.persisted? + flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create]) + + redirect_to admin_application_url(@application) else render :new end @@ -41,13 +43,6 @@ class Admin::ApplicationsController < Admin::ApplicationController redirect_to admin_applications_url, status: 302, notice: 'Application was successfully destroyed.' end - protected - - def redirect_to_admin_page - flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create]) - redirect_to admin_application_url(@application) - end - private def set_application diff --git a/app/controllers/admin/impersonation_tokens_controller.rb b/app/controllers/admin/impersonation_tokens_controller.rb index 07c8bf714fc..7a2c7234a1e 100644 --- a/app/controllers/admin/impersonation_tokens_controller.rb +++ b/app/controllers/admin/impersonation_tokens_controller.rb @@ -44,7 +44,7 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController end def set_index_vars - @scopes = Gitlab::Auth::API_SCOPES + @scopes = Gitlab::Auth.available_scopes(current_user) @impersonation_token ||= finder.build @inactive_impersonation_tokens = finder(state: 'inactive').execute diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 391a0519195..3be7aee69bc 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -11,7 +11,7 @@ class ApplicationController < ActionController::Base include EnforcesTwoFactorAuthentication include WithPerformanceBar - before_action :authenticate_user_from_private_token! + before_action :authenticate_user_from_personal_access_token! before_action :authenticate_user_from_rss_token! before_action :authenticate_user! before_action :validate_user_service_ticket! @@ -100,13 +100,12 @@ class ApplicationController < ActionController::Base return try(:authenticated_user) end - # This filter handles both private tokens and personal access tokens - def authenticate_user_from_private_token! + def authenticate_user_from_personal_access_token! token = params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence return unless token.present? - user = User.find_by_authentication_token(token) || User.find_by_personal_access_token(token) + user = User.find_by_personal_access_token(token) sessionless_sign_in(user) end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 4079072a930..b1ed973d178 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -7,6 +7,54 @@ module IssuableActions before_action :authorize_admin_issuable!, only: :bulk_update end + def show + respond_to do |format| + format.html do + render show_view + end + format.json do + render json: serializer.represent(issuable, serializer: params[:serializer]) + end + end + end + + def update + @issuable = update_service.execute(issuable) + + respond_to do |format| + format.html do + recaptcha_check_with_fallback { render :edit } + end + + format.json do + render_entity_json + end + end + + rescue ActiveRecord::StaleObjectError + render_conflict_response + end + + def realtime_changes + Gitlab::PollingInterval.set_header(response, interval: 3_000) + + response = { + title: view_context.markdown_field(issuable, :title), + title_text: issuable.title, + description: view_context.markdown_field(issuable, :description), + description_text: issuable.description, + task_status: issuable.task_status + } + + if issuable.edited? + response[:updated_at] = issuable.updated_at + response[:updated_by_name] = issuable.last_edited_by.name + response[:updated_by_path] = user_path(issuable.last_edited_by) + end + + render json: response + end + def destroy issuable.destroy destroy_method = "destroy_#{issuable.class.name.underscore}".to_sym @@ -68,6 +116,10 @@ module IssuableActions end end + def authorize_update_issuable! + render_404 unless can?(current_user, :"update_#{resource_name}", issuable) + end + def bulk_update_params permitted_keys = [ :issuable_ids, @@ -92,4 +144,24 @@ module IssuableActions def resource_name @resource_name ||= controller_name.singularize end + + def render_entity_json + if @issuable.valid? + render json: serializer.represent(@issuable) + else + render json: { errors: @issuable.errors.full_messages }, status: :unprocessable_entity + end + end + + def show_view + 'show' + end + + def serializer + raise NotImplementedError + end + + def update_service + raise NotImplementedError + end end diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb index 2b6afaa6233..738afd612f0 100644 --- a/app/controllers/concerns/lfs_request.rb +++ b/app/controllers/concerns/lfs_request.rb @@ -94,10 +94,9 @@ module LfsRequest @storage_project ||= begin result = project - loop do - break unless result.forked? - result = result.forked_from_project - end + # TODO: Make this go to the fork_network root immeadiatly + # dependant on the discussion in: https://gitlab.com/gitlab-org/gitlab-ce/issues/39769 + result = result.fork_source while result.forked? result end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 1126f706393..57b45f335fa 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -4,6 +4,7 @@ module NotesActions included do before_action :set_polling_interval_header, only: [:index] + before_action :noteable, only: :index before_action :authorize_admin_note!, only: [:update, :destroy] before_action :note_project, only: [:create] end @@ -108,6 +109,8 @@ module NotesActions diff_discussion_html: diff_discussion_html(discussion), discussion_html: discussion_html(discussion) ) + + attrs[:discussion_line_code] = discussion.line_code if discussion.diff_discussion? end end else @@ -188,7 +191,7 @@ module NotesActions end def noteable - @noteable ||= notes_finder.target + @noteable ||= notes_finder.target || render_404 end def last_fetched_at diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb index 80ab681ed87..bc0948cd3fb 100644 --- a/app/controllers/confirmations_controller.rb +++ b/app/controllers/confirmations_controller.rb @@ -10,7 +10,7 @@ class ConfirmationsController < Devise::ConfirmationsController users_almost_there_path end - def after_confirmation_path_for(_resource_name, resource) + def after_confirmation_path_for(resource_name, resource) # incoming resource can either be a :user or an :email if signed_in?(:user) after_sign_in(resource) diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index 4bceb1d67a3..7d6fe6a0232 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -30,11 +30,11 @@ class JwtController < ApplicationController render_unauthorized end end - rescue Gitlab::Auth::MissingPersonalTokenError - render_missing_personal_token + rescue Gitlab::Auth::MissingPersonalAccessTokenError + render_missing_personal_access_token end - def render_missing_personal_token + def render_missing_personal_access_token render json: { errors: [ { code: 'UNAUTHORIZED', diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index b02e64a132b..2443f529c7b 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -16,25 +16,18 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController end def create - @application = Doorkeeper::Application.new(application_params) + @application = Applications::CreateService.new(current_user, create_application_params).execute(request) - @application.owner = current_user + if @application.persisted? + flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create]) - if @application.save - redirect_to_oauth_application_page + redirect_to oauth_application_url(@application) else set_index_vars render :index end end - protected - - def redirect_to_oauth_application_page - flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create]) - redirect_to oauth_application_url(@application) - end - private def verify_user_oauth_applications_enabled @@ -61,4 +54,10 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController rescue_from ActiveRecord::RecordNotFound do |exception| render "errors/not_found", layout: "errors", status: 404 end + + def create_application_params + application_params.tap do |params| + params[:owner] = current_user + end + end end diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb index 069e6a810f2..f0e5d2aa94e 100644 --- a/app/controllers/profiles/keys_controller.rb +++ b/app/controllers/profiles/keys_controller.rb @@ -11,10 +11,10 @@ class Profiles::KeysController < Profiles::ApplicationController end def create - @key = Keys::CreateService.new(current_user, key_params).execute + @key = Keys::CreateService.new(current_user, key_params.merge(ip_address: request.remote_ip)).execute if @key.persisted? - redirect_to_profile_key_path + redirect_to profile_key_path(@key) else @keys = current_user.keys.select(&:persisted?) render :index @@ -50,12 +50,6 @@ class Profiles::KeysController < Profiles::ApplicationController end end - protected - - def redirect_to_profile_key_path - redirect_to profile_key_path(@key) - end - private def key_params diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 4146deefa89..6d9873e38df 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -39,7 +39,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController end def set_index_vars - @scopes = Gitlab::Auth.available_scopes + @scopes = Gitlab::Auth.available_scopes(current_user) @inactive_personal_access_tokens = finder(state: 'inactive').execute @active_personal_access_tokens = finder(state: 'active').execute.order(:expires_at) diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 5d87037f012..dbf61a17724 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -24,16 +24,6 @@ class ProfilesController < Profiles::ApplicationController end end - def reset_private_token - Users::UpdateService.new(current_user, user: @user).execute! do |user| - user.reset_authentication_token! - end - - flash[:notice] = "Private token was successfully reset" - - redirect_to profile_account_path - end - def reset_incoming_email_token Users::UpdateService.new(current_user, user: @user).execute! do |user| user.reset_incoming_email_token! @@ -41,7 +31,7 @@ class ProfilesController < Profiles::ApplicationController flash[:notice] = "Incoming email token was successfully reset" - redirect_to profile_account_path + redirect_to profile_personal_access_tokens_path end def reset_rss_token @@ -51,7 +41,7 @@ class ProfilesController < Profiles::ApplicationController flash[:notice] = "RSS token was successfully reset" - redirect_to profile_account_path + redirect_to profile_personal_access_tokens_path end def audit_log diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 7f03ce07dec..f28df83d5a5 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -15,6 +15,8 @@ class Projects::BranchesController < Projects::ApplicationController respond_to do |format| format.html do @refs_pipelines = @project.pipelines.latest_successful_for_refs(@branches.map(&:name)) + @merged_branch_names = + repository.merged_branch_names(@branches.map(&:name)) # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37429 Gitlab::GitalyClient.allow_n_plus_1_calls do @max_commits = @branches.reduce(0) do |memo, branch| diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb index 0fd5635523f..9a56c9de858 100644 --- a/app/controllers/projects/clusters_controller.rb +++ b/app/controllers/projects/clusters_controller.rb @@ -30,11 +30,13 @@ class Projects::ClustersController < Projects::ApplicationController end def new_gcp - @cluster = project.build_cluster + @cluster = Clusters::Cluster.new.tap do |cluster| + cluster.build_provider_gcp + end end def create - @cluster = Ci::CreateClusterService + @cluster = Clusters::CreateService .new(project, current_user, create_params) .execute(token_in_session) @@ -61,7 +63,7 @@ class Projects::ClustersController < Projects::ApplicationController end def update - Ci::UpdateClusterService + Clusters::UpdateService .new(project, current_user, update_params) .execute(cluster) @@ -91,19 +93,19 @@ class Projects::ClustersController < Projects::ApplicationController def create_params params.require(:cluster).permit( - :gcp_project_id, - :gcp_cluster_zone, - :gcp_cluster_name, - :gcp_cluster_size, - :gcp_machine_type, - :project_namespace, - :enabled) + :enabled, + :name, + :provider_type, + provider_gcp_attributes: [ + :gcp_project_id, + :zone, + :num_nodes, + :machine_type + ]) end def update_params - params.require(:cluster).permit( - :project_namespace, - :enabled) + params.require(:cluster).permit(:enabled) end def authorize_google_api diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb index 95d7a02e9e9..dd5e66f60e3 100644 --- a/app/controllers/projects/git_http_client_controller.rb +++ b/app/controllers/projects/git_http_client_controller.rb @@ -53,8 +53,8 @@ class Projects::GitHttpClientController < Projects::ApplicationController send_challenges render plain: "HTTP Basic: Access denied\n", status: 401 - rescue Gitlab::Auth::MissingPersonalTokenError - render_missing_personal_token + rescue Gitlab::Auth::MissingPersonalAccessTokenError + render_missing_personal_access_token end def basic_auth_provided? @@ -78,7 +78,7 @@ class Projects::GitHttpClientController < Projects::ApplicationController @project, @wiki, @redirected_path = Gitlab::RepoPath.parse("#{params[:namespace_id]}/#{params[:project_id]}") end - def render_missing_personal_token + def render_missing_personal_access_token render plain: "HTTP Basic: Access denied\n" \ "You must use a personal access token with 'api' scope for Git over HTTP.\n" \ "You can generate one at #{profile_personal_access_tokens_url}", diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb index f59200d3b1f..dbc1c8bcc28 100644 --- a/app/controllers/projects/group_links_controller.rb +++ b/app/controllers/projects/group_links_controller.rb @@ -12,12 +12,7 @@ class Projects::GroupLinksController < Projects::ApplicationController if group return render_404 unless can?(current_user, :read_group, group) - - project.project_group_links.create( - group: group, - group_access: params[:link_group_access], - expires_at: params[:expires_at] - ) + Projects::GroupLinks::CreateService.new(project, current_user, group_link_create_params).execute(group) else flash[:alert] = 'Please select a group.' end @@ -32,7 +27,9 @@ class Projects::GroupLinksController < Projects::ApplicationController end def destroy - project.project_group_links.find(params[:id]).destroy + group_link = project.project_group_links.find(params[:id]) + + ::Projects::GroupLinks::DestroyService.new(project, current_user).execute(group_link) respond_to do |format| format.html do @@ -47,4 +44,8 @@ class Projects::GroupLinksController < Projects::ApplicationController def group_link_params params.require(:group_link).permit(:group_access, :expires_at) end + + def group_link_create_params + params.permit(:link_group_access, :expires_at) + end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index b7a108a0ebd..d4e763aa5b8 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -16,7 +16,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action :authorize_create_issue!, only: [:new, :create] # Allow modify issue - before_action :authorize_update_issue!, only: [:update, :move] + before_action :authorize_update_issuable!, only: [:edit, :update, :move] # Allow create a new branch and empty WIP merge request from current issue before_action :authorize_create_merge_request!, only: [:create_merge_request] @@ -63,16 +63,8 @@ class Projects::IssuesController < Projects::ApplicationController respond_with(@issue) end - def show - @noteable = @issue - @note = @project.notes.new(noteable: @issue) - - respond_to do |format| - format.html - format.json do - render json: serializer.represent(@issue) - end - end + def edit + respond_with(@issue) end def discussions @@ -116,21 +108,6 @@ class Projects::IssuesController < Projects::ApplicationController end end - def update - update_params = issue_params.merge(spammable_params) - - @issue = Issues::UpdateService.new(project, current_user, update_params).execute(issue) - - respond_to do |format| - format.json do - render_issue_json - end - end - - rescue ActiveRecord::StaleObjectError - render_conflict_response - end - def move params.require(:move_to_project_id) @@ -188,26 +165,6 @@ class Projects::IssuesController < Projects::ApplicationController end end - def realtime_changes - Gitlab::PollingInterval.set_header(response, interval: 3_000) - - response = { - title: view_context.markdown_field(@issue, :title), - title_text: @issue.title, - description: view_context.markdown_field(@issue, :description), - description_text: @issue.description, - task_status: @issue.task_status - } - - if @issue.edited? - response[:updated_at] = @issue.updated_at - response[:updated_by_name] = @issue.last_edited_by.name - response[:updated_by_path] = user_path(@issue.last_edited_by) - end - - render json: response - end - def create_merge_request result = ::MergeRequests::CreateFromIssueService.new(project, current_user, issue_iid: issue.iid).execute @@ -223,7 +180,8 @@ class Projects::IssuesController < Projects::ApplicationController def issue return @issue if defined?(@issue) # The Sortable default scope causes performance issues when used with find_by - @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take! + @issuable = @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take! + @note = @project.notes.new(noteable: @issuable) return render_404 unless can?(current_user, :read_issue, @issue) @@ -238,14 +196,6 @@ class Projects::IssuesController < Projects::ApplicationController project_issue_path(@project, @issue) end - def authorize_update_issue! - render_404 unless can?(current_user, :update_issue, @issue) - end - - def authorize_admin_issues! - render_404 unless can?(current_user, :admin_issue, @project) - end - def authorize_create_merge_request! render_404 unless can?(current_user, :push_code, @project) && @issue.can_be_worked_on?(current_user) end @@ -297,4 +247,9 @@ class Projects::IssuesController < Projects::ApplicationController def serializer IssueSerializer.new(current_user: current_user, project: issue.project) end + + def update_service + update_params = issue_params.merge(spammable_params) + Issues::UpdateService.new(project, current_user, update_params) + end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index c5204080333..17cac69e588 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -9,7 +9,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo skip_before_action :merge_request, only: [:index, :bulk_update] skip_before_action :ensure_ref_fetched, only: [:index, :bulk_update] - before_action :authorize_update_merge_request!, only: [:close, :edit, :update, :remove_wip, :sort] + before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort] before_action :authenticate_user!, only: [:assign_related_issues] @@ -83,7 +83,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo format.json do Gitlab::PollingInterval.set_header(response, interval: 10_000) - render json: serializer.represent(@merge_request, basic: params[:basic]) + render json: serializer.represent(@merge_request, serializer: params[:serializer]) end format.patch do @@ -256,14 +256,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo alias_method :issuable, :merge_request alias_method :awardable, :merge_request - def authorize_update_merge_request! - return render_404 unless can?(current_user, :update_merge_request, @merge_request) - end - - def authorize_admin_merge_request! - return render_404 unless can?(current_user, :admin_merge_request, @merge_request) - end - def validates_merge_request # Show git not found page # if there is no saved commits between source & target branch diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index c94384d2a1a..980bbf699b6 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -2,13 +2,13 @@ class Projects::MilestonesController < Projects::ApplicationController include MilestoneActions before_action :check_issuables_available! - before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels] + before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels, :promote] # Allow read any milestone before_action :authorize_read_milestone! # Allow admin milestone - before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels] + before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels, :promote] respond_to :html @@ -69,6 +69,14 @@ class Projects::MilestonesController < Projects::ApplicationController end end + def promote + promoted_milestone = Milestones::PromoteService.new(project, current_user).execute(milestone) + flash[:notice] = "Milestone has been promoted to group milestone." + redirect_to group_milestone_path(project.group, promoted_milestone.iid) + rescue Milestones::PromoteService::PromoteMilestoneError => error + redirect_to milestone, alert: error.message + end + def destroy return access_denied! unless can?(current_user, :admin_milestone, @project) diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index 8022547a6ad..4dd573c61f1 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -63,34 +63,34 @@ module CiStatusHelper def ci_icon_for_status(status) if detailed_status?(status) - return custom_icon(status.icon) + return sprite_icon(status.icon) end icon_name = case status when 'success' - 'icon_status_success' + 'status_success' when 'success_with_warnings' - 'icon_status_warning' + 'status_warning' when 'failed' - 'icon_status_failed' + 'status_failed' when 'pending' - 'icon_status_pending' + 'status_pending' when 'running' - 'icon_status_running' + 'status_running' when 'play' - 'icon_play' + 'play' when 'created' - 'icon_status_created' + 'status_created' when 'skipped' - 'icon_status_skipped' + 'status_skipped' when 'manual' - 'icon_status_manual' + 'status_manual' else - 'icon_status_canceled' + 'status_canceled' end - custom_icon(icon_name) + sprite_icon(icon_name, size: 16) end def pipeline_status_cache_key(pipeline_status) diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index d4a91e533c1..a77aa0ad2cc 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -71,11 +71,13 @@ module GitlabRoutingHelper project_commit_url(entity.project, entity.sha, *args) end - def preview_markdown_path(project, *args) + def preview_markdown_path(parent, *args) + return group_preview_markdown_path(parent) if parent.is_a?(Group) + if @snippet.is_a?(PersonalSnippet) preview_markdown_snippets_path else - preview_markdown_project_path(project, *args) + preview_markdown_project_path(parent, *args) end end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index baa2d6e375e..85407e38532 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -33,15 +33,17 @@ module IssuablesHelper end def serialize_issuable(issuable) - case issuable - when Issue - IssueSerializer.new(current_user: current_user, project: issuable.project).represent(issuable).to_json - when MergeRequest - MergeRequestSerializer - .new(current_user: current_user, project: issuable.project) - .represent(issuable) - .to_json - end + serializer_klass = case issuable + when Issue + IssueSerializer + when MergeRequest + MergeRequestSerializer + end + + serializer_klass + .new(current_user: current_user, project: issuable.project) + .represent(issuable) + .to_json end def template_dropdown_tag(issuable, &block) @@ -209,15 +211,13 @@ module IssuablesHelper def issuable_initial_data(issuable) data = { - endpoint: project_issue_path(@project, issuable), - canUpdate: can?(current_user, :update_issue, issuable), - canDestroy: can?(current_user, :destroy_issue, issuable), + endpoint: issuable_path(issuable), + canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable), + canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable), issuableRef: issuable.to_reference, - markdownPreviewPath: preview_markdown_path(@project), + markdownPreviewPath: preview_markdown_path(parent), markdownDocsPath: help_page_path('user/markdown'), issuableTemplates: issuable_templates(issuable), - projectPath: ref_project.path, - projectNamespace: ref_project.namespace.full_path, initialTitleHtml: markdown_field(issuable, :title), initialTitleText: issuable.title, initialDescriptionHtml: markdown_field(issuable, :description), @@ -225,6 +225,12 @@ module IssuablesHelper initialTaskStatus: issuable.task_status } + if parent.is_a?(Group) + data[:groupPath] = parent.path + else + data.merge!(projectPath: ref_project.path, projectNamespace: ref_project.namespace.full_path) + end + data.merge!(updated_at_by(issuable)) data.to_json @@ -261,12 +267,7 @@ module IssuablesHelper end def issuable_path(issuable, *options) - case issuable - when Issue - issue_path(issuable, *options) - when MergeRequest - merge_request_path(issuable, *options) - end + polymorphic_path(issuable, *options) end def issuable_url(issuable, *options) @@ -357,7 +358,8 @@ module IssuablesHelper def issuable_sidebar_options(issuable, can_edit_issuable) { - endpoint: "#{issuable_json_path(issuable)}?basic=true", + endpoint: "#{issuable_json_path(issuable)}?serializer=sidebar", + toggleSubscriptionEndpoint: toggle_subscription_path(issuable), moveIssueEndpoint: move_namespace_project_issue_path(namespace_id: issuable.project.namespace.to_param, project_id: issuable.project, id: issuable), projectsAutocompleteEndpoint: autocomplete_projects_path(project_id: @project.id), editable: can_edit_issuable, @@ -366,4 +368,8 @@ module IssuablesHelper fullPath: @project.full_path } end + + def parent + @project || @group + end end diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 5a74511afa7..8ada746b244 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -19,11 +19,7 @@ module NavHelper end elsif current_path?('jobs#show') %w[page-gutter build-sidebar right-sidebar-expanded] - elsif current_path?('wikis#show') || - current_path?('wikis#edit') || - current_path?('wikis#update') || - current_path?('wikis#history') || - current_path?('wikis#git_access') + elsif current_controller?('wikis') && current_action?('show', 'create', 'edit', 'update', 'history', 'git_access') %w[page-gutter wiki-sidebar right-sidebar-expanded] else [] diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index d085c1a0e57..f48d47953e4 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -110,7 +110,15 @@ module ProjectsHelper def remove_fork_project_message(project) _("You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?") % - { forked_from_project: @project.forked_from_project.name_with_namespace } + { forked_from_project: fork_source_name(project) } + end + + def fork_source_name(project) + if @project.fork_source + @project.fork_source.full_name + else + @project.fork_network&.deleted_root_project_name + end end def project_nav_tabs @@ -140,8 +148,8 @@ module ProjectsHelper def can_change_visibility_level?(project, current_user) return false unless can?(current_user, :change_visibility_level, project) - if project.forked? - project.forked_from_project.visibility_level > Gitlab::VisibilityLevel::PRIVATE + if project.fork_source + project.fork_source.visibility_level > Gitlab::VisibilityLevel::PRIVATE else true end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index f266e7db6da..5e16badabec 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -420,7 +420,7 @@ class ApplicationSetting < ActiveRecord::Base # the enabling/disabling is `performance_bar_allowed_group_id` # - If `enable` is false, we set `performance_bar_allowed_group_id` to `nil` def performance_bar_enabled=(enable) - return if enable + return if Gitlab::Utils.to_boolean(enable) self.performance_bar_allowed_group_id = nil end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index cf3ce3c9e54..ca65e81f27a 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -249,9 +249,7 @@ module Ci end def commit - @commit ||= project.commit(sha) - rescue - nil + @commit ||= project.commit_by(oid: sha) end def branch? diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb new file mode 100644 index 00000000000..955dba51745 --- /dev/null +++ b/app/models/clusters/cluster.rb @@ -0,0 +1,73 @@ +module Clusters + class Cluster < ActiveRecord::Base + include Presentable + + self.table_name = 'clusters' + + belongs_to :user + + has_many :cluster_projects, class_name: 'Clusters::Project' + has_many :projects, through: :cluster_projects, class_name: '::Project' + + # we force autosave to happen when we save `Cluster` model + has_one :provider_gcp, class_name: 'Clusters::Providers::Gcp', autosave: true + + # We have to ":destroy" it today to ensure that we clean also the Kubernetes Integration + has_one :platform_kubernetes, class_name: 'Clusters::Platforms::Kubernetes', autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + + accepts_nested_attributes_for :provider_gcp, update_only: true + accepts_nested_attributes_for :platform_kubernetes, update_only: true + + validates :name, cluster_name: true + validate :restrict_modification, on: :update + + # TODO: Move back this into Clusters::Platforms::Kubernetes in 10.3 + # We need callback here because `enabled` belongs to Clusters::Cluster + # Callbacks in Clusters::Platforms::Kubernetes will not be called after update + after_save :update_kubernetes_integration! + + delegate :status, to: :provider, allow_nil: true + delegate :status_reason, to: :provider, allow_nil: true + delegate :status_name, to: :provider, allow_nil: true + delegate :on_creation?, to: :provider, allow_nil: true + delegate :update_kubernetes_integration!, to: :platform, allow_nil: true + + enum platform_type: { + kubernetes: 1 + } + + enum provider_type: { + user: 0, + gcp: 1 + } + + scope :enabled, -> { where(enabled: true) } + scope :disabled, -> { where(enabled: false) } + + def provider + return provider_gcp if gcp? + end + + def platform + return platform_kubernetes if kubernetes? + end + + def first_project + return @first_project if defined?(@first_project) + + @first_project = projects.first + end + alias_method :project, :first_project + + private + + def restrict_modification + if provider&.on_creation? + errors.add(:base, "cannot modify during creation") + return false + end + + true + end + end +end diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb new file mode 100644 index 00000000000..b11701797c2 --- /dev/null +++ b/app/models/clusters/platforms/kubernetes.rb @@ -0,0 +1,101 @@ +module Clusters + module Platforms + class Kubernetes < ActiveRecord::Base + self.table_name = 'cluster_platforms_kubernetes' + + belongs_to :cluster, inverse_of: :platform_kubernetes, class_name: 'Clusters::Cluster' + + attr_encrypted :password, + mode: :per_attribute_iv, + key: Gitlab::Application.secrets.db_key_base, + algorithm: 'aes-256-cbc' + + attr_encrypted :token, + mode: :per_attribute_iv, + key: Gitlab::Application.secrets.db_key_base, + algorithm: 'aes-256-cbc' + + before_validation :enforce_namespace_to_lower_case + + validates :namespace, + allow_blank: true, + length: 1..63, + format: { + with: Gitlab::Regex.kubernetes_namespace_regex, + message: Gitlab::Regex.kubernetes_namespace_regex_message + } + + # We expect to be `active?` only when enabled and cluster is created (the api_url is assigned) + validates :api_url, url: true, presence: true + validates :token, presence: true + + # TODO: Glue code till we migrate Kubernetes Integration into Platforms::Kubernetes + after_destroy :destroy_kubernetes_integration! + + alias_attribute :ca_pem, :ca_cert + + delegate :project, to: :cluster, allow_nil: true + delegate :enabled?, to: :cluster, allow_nil: true + + class << self + def namespace_for_project(project) + "#{project.path}-#{project.id}" + end + end + + def actual_namespace + if namespace.present? + namespace + else + default_namespace + end + end + + def default_namespace + self.class.namespace_for_project(project) if project + end + + def update_kubernetes_integration! + raise 'Kubernetes service already configured' unless manages_kubernetes_service? + + # This is neccesary, otheriwse enabled? returns true even though cluster updated with enabled: false + cluster.reload + + ensure_kubernetes_service&.update!( + active: enabled?, + api_url: api_url, + namespace: namespace, + token: token, + ca_pem: ca_cert + ) + end + + private + + def enforce_namespace_to_lower_case + self.namespace = self.namespace&.downcase + end + + # TODO: glue code till we migrate Kubernetes Service into Platforms::Kubernetes class + def manages_kubernetes_service? + return true unless kubernetes_service&.active? + + kubernetes_service.api_url == api_url + end + + def destroy_kubernetes_integration! + return unless manages_kubernetes_service? + + kubernetes_service&.destroy! + end + + def kubernetes_service + @kubernetes_service ||= project&.kubernetes_service + end + + def ensure_kubernetes_service + @kubernetes_service ||= kubernetes_service || project&.build_kubernetes_service + end + end + end +end diff --git a/app/models/clusters/project.rb b/app/models/clusters/project.rb new file mode 100644 index 00000000000..eeb734b20b8 --- /dev/null +++ b/app/models/clusters/project.rb @@ -0,0 +1,8 @@ +module Clusters + class Project < ActiveRecord::Base + self.table_name = 'cluster_projects' + + belongs_to :cluster, class_name: 'Clusters::Cluster' + belongs_to :project, class_name: '::Project' + end +end diff --git a/app/models/clusters/providers/gcp.rb b/app/models/clusters/providers/gcp.rb new file mode 100644 index 00000000000..7700ba86f1a --- /dev/null +++ b/app/models/clusters/providers/gcp.rb @@ -0,0 +1,79 @@ +module Clusters + module Providers + class Gcp < ActiveRecord::Base + self.table_name = 'cluster_providers_gcp' + + belongs_to :cluster, inverse_of: :provider_gcp, class_name: 'Clusters::Cluster' + + default_value_for :zone, 'us-central1-a' + default_value_for :num_nodes, 3 + default_value_for :machine_type, 'n1-standard-4' + + attr_encrypted :access_token, + mode: :per_attribute_iv, + key: Gitlab::Application.secrets.db_key_base, + algorithm: 'aes-256-cbc' + + validates :gcp_project_id, + length: 1..63, + format: { + with: Gitlab::Regex.kubernetes_namespace_regex, + message: Gitlab::Regex.kubernetes_namespace_regex_message + } + + validates :zone, presence: true + + validates :num_nodes, + presence: true, + numericality: { + only_integer: true, + greater_than: 0 + } + + state_machine :status, initial: :scheduled do + state :scheduled, value: 1 + state :creating, value: 2 + state :created, value: 3 + state :errored, value: 4 + + event :make_creating do + transition any - [:creating] => :creating + end + + event :make_created do + transition any - [:created] => :created + end + + event :make_errored do + transition any - [:errored] => :errored + end + + before_transition any => [:errored, :created] do |provider| + provider.access_token = nil + provider.operation_id = nil + end + + before_transition any => [:creating] do |provider, transition| + operation_id = transition.args.first + raise 'operation_id is required' unless operation_id + provider.operation_id = operation_id + end + + before_transition any => [:errored] do |provider, transition| + status_reason = transition.args.first + provider.status_reason = status_reason if status_reason + end + end + + def on_creation? + scheduled? || creating? + end + + def api_client + return unless access_token + + @api_client ||= GoogleApi::CloudPlatform::Client.new(access_token, nil) + end + end + end +end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 9417033d1f6..98776eab424 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -49,7 +49,8 @@ module CacheMarkdownField # Always include a project key, or Banzai complains project = self.project if self.respond_to?(:project) - context = cached_markdown_fields[field].merge(project: project) + group = self.group if self.respond_to?(:group) + context = cached_markdown_fields[field].merge(project: project, group: group) # Banzai is less strict about authors, so don't always have an author key context[:author] = self.author if self.respond_to?(:author) diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 27f4dedffd3..a928b9d6367 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -14,7 +14,6 @@ module Issuable include StripAttribute include Awardable include Taskable - include TimeTrackable include Importable include Editable include AfterCommitQueue @@ -95,8 +94,6 @@ module Issuable strip_attributes :title - acts_as_paranoid - after_save :record_metrics, unless: :imported? # We want to use optimistic lock for cases when only title or description are involved diff --git a/app/models/concerns/repository_mirroring.rb b/app/models/concerns/repository_mirroring.rb deleted file mode 100644 index f6aba91bc4c..00000000000 --- a/app/models/concerns/repository_mirroring.rb +++ /dev/null @@ -1,32 +0,0 @@ -module RepositoryMirroring - IMPORT_HEAD_REFS = '+refs/heads/*:refs/heads/*'.freeze - IMPORT_TAG_REFS = '+refs/tags/*:refs/tags/*'.freeze - - def set_remote_as_mirror(name) - # This is used to define repository as equivalent as "git clone --mirror" - raw_repository.rugged.config["remote.#{name}.fetch"] = 'refs/*:refs/*' - raw_repository.rugged.config["remote.#{name}.mirror"] = true - raw_repository.rugged.config["remote.#{name}.prune"] = true - end - - def set_import_remote_as_mirror(remote_name) - # Add first fetch with Rugged so it does not create its own. - raw_repository.rugged.config["remote.#{remote_name}.fetch"] = IMPORT_HEAD_REFS - - add_remote_fetch_config(remote_name, IMPORT_TAG_REFS) - - raw_repository.rugged.config["remote.#{remote_name}.mirror"] = true - raw_repository.rugged.config["remote.#{remote_name}.prune"] = true - end - - def add_remote_fetch_config(remote_name, refspec) - run_git(%W[config --add remote.#{remote_name}.fetch #{refspec}]) - end - - def fetch_mirror(remote, url) - add_remote(remote, url) - set_remote_as_mirror(remote) - fetch_remote(remote, forced: true) - remove_remote(remote) - end -end diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb index 274b38a7708..f478c8ede18 100644 --- a/app/models/concerns/subscribable.rb +++ b/app/models/concerns/subscribable.rb @@ -13,6 +13,8 @@ module Subscribable end def subscribed?(user, project = nil) + return false unless user + if subscription = subscriptions.find_by(user: user, project: project) subscription.subscribed else diff --git a/app/models/email.rb b/app/models/email.rb index 384f38f2db7..2da8b050149 100644 --- a/app/models/email.rb +++ b/app/models/email.rb @@ -14,6 +14,8 @@ class Email < ActiveRecord::Base devise :confirmable self.reconfirmable = false # currently email can't be changed, no need to reconfirm + delegate :username, to: :user + def email=(value) write_attribute(:email, value.downcase.strip) end diff --git a/app/models/environment.rb b/app/models/environment.rb index e613d21add6..21a028e351c 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -30,7 +30,6 @@ class Environment < ActiveRecord::Base message: Gitlab::Regex.environment_slug_regex_message } validates :external_url, - uniqueness: { scope: :project_id }, length: { maximum: 255 }, allow_nil: true, addressable_url: true @@ -110,7 +109,7 @@ class Environment < ActiveRecord::Base end def ref_path - "refs/#{Repository::REF_ENVIRONMENTS}/#{generate_slug}" + "refs/#{Repository::REF_ENVIRONMENTS}/#{slug}" end def formatted_external_url @@ -164,6 +163,10 @@ class Environment < ActiveRecord::Base end end + def slug + super.presence || generate_slug + end + # An environment name is not necessarily suitable for use in URLs, DNS # or other third-party contexts, so provide a slugified version. A slug has # the following properties: diff --git a/app/models/epic.rb b/app/models/epic.rb new file mode 100644 index 00000000000..62898a02e2d --- /dev/null +++ b/app/models/epic.rb @@ -0,0 +1,7 @@ +# Placeholder class for model that is implemented in EE +# It will reserve (ee#3853) '&' as a reference prefix, but the table does not exists in CE +class Epic < ActiveRecord::Base + # TODO: this will be implemented as part of #3853 + def to_reference + end +end diff --git a/app/models/fork_network.rb b/app/models/fork_network.rb index 218e37a5312..7f1728e8c77 100644 --- a/app/models/fork_network.rb +++ b/app/models/fork_network.rb @@ -12,4 +12,8 @@ class ForkNetwork < ActiveRecord::Base def find_forks_in(other_projects) projects.where(id: other_projects) end + + def merge_requests + MergeRequest.where(target_project: projects) + end end diff --git a/app/models/gcp/cluster.rb b/app/models/gcp/cluster.rb deleted file mode 100644 index 162a690c0e3..00000000000 --- a/app/models/gcp/cluster.rb +++ /dev/null @@ -1,116 +0,0 @@ -module Gcp - class Cluster < ActiveRecord::Base - extend Gitlab::Gcp::Model - include Presentable - - belongs_to :project, inverse_of: :cluster - belongs_to :user - belongs_to :service - - scope :enabled, -> { where(enabled: true) } - scope :disabled, -> { where(enabled: false) } - - default_value_for :gcp_cluster_zone, 'us-central1-a' - default_value_for :gcp_cluster_size, 3 - default_value_for :gcp_machine_type, 'n1-standard-4' - - attr_encrypted :password, - mode: :per_attribute_iv, - key: Gitlab::Application.secrets.db_key_base, - algorithm: 'aes-256-cbc' - - attr_encrypted :kubernetes_token, - mode: :per_attribute_iv, - key: Gitlab::Application.secrets.db_key_base, - algorithm: 'aes-256-cbc' - - attr_encrypted :gcp_token, - mode: :per_attribute_iv, - key: Gitlab::Application.secrets.db_key_base, - algorithm: 'aes-256-cbc' - - validates :gcp_project_id, - length: 1..63, - format: { - with: Gitlab::Regex.kubernetes_namespace_regex, - message: Gitlab::Regex.kubernetes_namespace_regex_message - } - - validates :gcp_cluster_name, - length: 1..63, - format: { - with: Gitlab::Regex.kubernetes_namespace_regex, - message: Gitlab::Regex.kubernetes_namespace_regex_message - } - - validates :gcp_cluster_zone, presence: true - - validates :gcp_cluster_size, - presence: true, - numericality: { - only_integer: true, - greater_than: 0 - } - - validates :project_namespace, - allow_blank: true, - length: 1..63, - format: { - with: Gitlab::Regex.kubernetes_namespace_regex, - message: Gitlab::Regex.kubernetes_namespace_regex_message - } - - # if we do not do status transition we prevent change - validate :restrict_modification, on: :update, unless: :status_changed? - - state_machine :status, initial: :scheduled do - state :scheduled, value: 1 - state :creating, value: 2 - state :created, value: 3 - state :errored, value: 4 - - event :make_creating do - transition any - [:creating] => :creating - end - - event :make_created do - transition any - [:created] => :created - end - - event :make_errored do - transition any - [:errored] => :errored - end - - before_transition any => [:errored, :created] do |cluster| - cluster.gcp_token = nil - cluster.gcp_operation_id = nil - end - - before_transition any => [:errored] do |cluster, transition| - status_reason = transition.args.first - cluster.status_reason = status_reason if status_reason - end - end - - def project_namespace_placeholder - "#{project.path}-#{project.id}" - end - - def on_creation? - scheduled? || creating? - end - - def api_url - 'https://' + endpoint if endpoint - end - - def restrict_modification - if on_creation? - errors.add(:base, "cannot modify during creation") - return false - end - - true - end - end -end diff --git a/app/models/group.rb b/app/models/group.rb index 07fb62bb249..c660de7fcb6 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -42,6 +42,7 @@ class Group < Namespace after_create :post_create_hook after_destroy :post_destroy_hook after_save :update_two_factor_requirement + after_update :path_changed_hook, if: :path_changed? class << self def supports_nested_groups? @@ -180,6 +181,12 @@ class Group < Namespace add_user(user, :owner, current_user: current_user) end + def member?(user, min_access_level = Gitlab::Access::GUEST) + return false unless user + + max_member_access_for_user(user) >= min_access_level + end + def has_owner?(user) return false unless user @@ -289,6 +296,12 @@ class Group < Namespace list_of_ids.reverse.map { |group| variables[group.id] }.compact.flatten end + def full_path_was + return path_was unless has_parent? + + "#{parent.full_path}/#{path_was}" + end + private def update_two_factor_requirement @@ -297,6 +310,10 @@ class Group < Namespace users.find_each(&:update_two_factor_requirement) end + def path_changed_hook + system_hook_service.execute_hooks_for(self, :rename) + end + def visibility_level_allowed_by_parent return if visibility_level_allowed_by_parent? diff --git a/app/models/identity.rb b/app/models/identity.rb index 920a25932b4..ac8094b610e 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -7,7 +7,10 @@ class Identity < ActiveRecord::Base validates :extern_uid, allow_blank: true, uniqueness: { scope: :provider } validates :user_id, uniqueness: { scope: :provider } - scope :with_extern_uid, ->(provider, extern_uid) { where(extern_uid: extern_uid, provider: provider) } + scope :with_extern_uid, ->(provider, extern_uid) do + extern_uid = Gitlab::LDAP::Person.normalize_dn(extern_uid) if provider.starts_with?('ldap') + where(extern_uid: extern_uid, provider: provider) + end def ldap? provider.starts_with?('ldap') diff --git a/app/models/issue.rb b/app/models/issue.rb index 36e4108b9d6..fc590f9257e 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -10,6 +10,7 @@ class Issue < ActiveRecord::Base include FasterCacheKeys include RelativePositioning include CreatedAtFilterable + include TimeTrackable DueDateStruct = Struct.new(:title, :name).freeze NoDueDate = DueDateStruct.new('No Due Date', '0').freeze @@ -74,6 +75,8 @@ class Issue < ActiveRecord::Base end end + acts_as_paranoid + def self.reference_prefix '#' end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index c3fae16d109..3133dc9e7eb 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -6,6 +6,7 @@ class MergeRequest < ActiveRecord::Base include Sortable include IgnorableColumn include CreatedAtFilterable + include TimeTrackable ignore_column :locked_at @@ -119,6 +120,8 @@ class MergeRequest < ActiveRecord::Base after_save :keep_around_commit + acts_as_paranoid + def self.reference_prefix '!' end @@ -396,6 +399,10 @@ class MergeRequest < ActiveRecord::Base end def merge_ongoing? + # While the MergeRequest is locked, it should present itself as 'merge ongoing'. + # The unlocking process is handled by StuckMergeJobsWorker scheduled in Cron. + return true if locked? + !!merge_jid && !merged? && Gitlab::SidekiqStatus.running?(merge_jid) end @@ -874,7 +881,7 @@ class MergeRequest < ActiveRecord::Base # def all_commit_shas if persisted? - column_shas = MergeRequestDiffCommit.where(merge_request_diff: merge_request_diffs).pluck('DISTINCT(sha)') + column_shas = MergeRequestDiffCommit.where(merge_request_diff: merge_request_diffs).limit(10_000).pluck('sha') serialised_shas = merge_request_diffs.where.not(st_commits: nil).flat_map(&:commit_shas) (column_shas + serialised_shas).uniq diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index faf0b95f842..1eda0f9cbbd 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -48,6 +48,10 @@ class MergeRequestDiff < ActiveRecord::Base # Collect information about commits and diff from repository # and save it to the database as serialized data def save_git_content + MergeRequest + .where('id = ? AND COALESCE(latest_merge_request_diff_id, 0) < ?', self.merge_request_id, self.id) + .update_all(latest_merge_request_diff_id: self.id) + ensure_commit_shas save_commits save_diffs diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb index 670b26d4ca3..b75387e236e 100644 --- a/app/models/merge_request_diff_commit.rb +++ b/app/models/merge_request_diff_commit.rb @@ -17,7 +17,9 @@ class MergeRequestDiffCommit < ActiveRecord::Base commit_hash.merge( merge_request_diff_id: merge_request_diff_id, relative_order: index, - sha: sha_attribute.type_cast_for_database(sha) + sha: sha_attribute.type_cast_for_database(sha), + authored_date: Gitlab::Database.sanitize_timestamp(commit_hash[:authored_date]), + committed_date: Gitlab::Database.sanitize_timestamp(commit_hash[:committed_date]) ) end diff --git a/app/models/note.rb b/app/models/note.rb index 8939e590ef1..f9676361072 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -69,7 +69,7 @@ class Note < ActiveRecord::Base delegate :title, to: :noteable, allow_nil: true validates :note, presence: true - validates :project, presence: true, unless: :for_personal_snippet? + validates :project, presence: true, if: :for_project_noteable? # Attachments are deprecated and are handled by Markdown uploader validates :attachment, file_size: { maximum: :max_attachment_size } @@ -114,7 +114,7 @@ class Note < ActiveRecord::Base after_initialize :ensure_discussion_id before_validation :nullify_blank_type, :nullify_blank_line_code before_validation :set_discussion_id, on: :create - after_save :keep_around_commit, unless: :for_personal_snippet? + after_save :keep_around_commit, if: :for_project_noteable? after_save :expire_etag_cache after_destroy :expire_etag_cache @@ -208,6 +208,10 @@ class Note < ActiveRecord::Base noteable.is_a?(PersonalSnippet) end + def for_project_noteable? + !for_personal_snippet? + end + def skip_project_check? for_personal_snippet? end diff --git a/app/models/oauth_access_token.rb b/app/models/oauth_access_token.rb index f89e60ad9f4..e8595b13d6d 100644 --- a/app/models/oauth_access_token.rb +++ b/app/models/oauth_access_token.rb @@ -2,5 +2,13 @@ class OauthAccessToken < Doorkeeper::AccessToken belongs_to :resource_owner, class_name: 'User' belongs_to :application, class_name: 'Doorkeeper::Application' - alias_method :user, :resource_owner + alias_attribute :user, :resource_owner + + def scopes=(value) + if value.is_a?(Array) + super(Doorkeeper::OAuth::Scopes.from_array(value).to_s) + else + super + end + end end diff --git a/app/models/project.rb b/app/models/project.rb index 4689b588906..b91b6a2220f 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -26,7 +26,15 @@ class Project < ActiveRecord::Base NUMBER_OF_PERMITTED_BOARDS = 1 UNKNOWN_IMPORT_URL = 'http://unknown.git'.freeze - LATEST_STORAGE_VERSION = 1 + # Hashed Storage versions handle rolling out new storage to project and dependents models: + # nil: legacy + # 1: repository + # 2: attachments + LATEST_STORAGE_VERSION = 2 + HASHED_STORAGE_FEATURES = { + repository: 1, + attachments: 2 + }.freeze cache_markdown_field :description, pipeline: :description @@ -120,6 +128,7 @@ class Project < ActiveRecord::Base has_one :mock_deployment_service has_one :mock_monitoring_service has_one :microsoft_teams_service + has_one :packagist_service # TODO: replace these relations with the fork network versions has_one :forked_project_link, foreign_key: "forked_to_project_id" @@ -177,7 +186,9 @@ class Project < ActiveRecord::Base has_one :import_data, class_name: 'ProjectImportData', inverse_of: :project, autosave: true has_one :project_feature, inverse_of: :project has_one :statistics, class_name: 'ProjectStatistics' - has_one :cluster, class_name: 'Gcp::Cluster', inverse_of: :project + + has_one :cluster_project, class_name: 'Clusters::Project' + has_one :cluster, through: :cluster_project, class_name: 'Clusters::Cluster' # Container repositories need to remove data from the container registry, # which is not managed by the DB. Hence we're still using dependent: :destroy @@ -540,6 +551,10 @@ class Project < ActiveRecord::Base repository.commit(ref) end + def commit_by(oid:) + repository.commit_by(oid: oid) + end + # ref can't be HEAD, can only be branch/tag name or SHA def latest_successful_builds_for(ref = default_branch) latest_pipeline = pipelines.latest_successful_for(ref) @@ -553,7 +568,7 @@ class Project < ActiveRecord::Base def merge_base_commit(first_commit_id, second_commit_id) sha = repository.merge_base(first_commit_id, second_commit_id) - repository.commit(sha) if sha + commit_by(oid: sha) if sha end def saved? @@ -1027,6 +1042,10 @@ class Project < ActiveRecord::Base !(forked_project_link.nil? || forked_project_link.forked_from_project.nil?) end + def fork_source + forked_from_project || fork_network&.root_project + end + def personal? !group end @@ -1079,6 +1098,7 @@ class Project < ActiveRecord::Base def hook_attrs(backward: true) attrs = { + id: id, name: name, description: description, web_url: web_url, @@ -1390,6 +1410,19 @@ class Project < ActiveRecord::Base end end + def after_rename_repo + path_before_change = previous_changes['path'].first + + # We need to check if project had been rolled out to move resource to hashed storage or not and decide + # if we need execute any take action or no-op. + + unless hashed_storage?(:attachments) + Gitlab::UploadsTransfer.new.rename_project(path_before_change, self.path, namespace.full_path) + end + + Gitlab::PagesTransfer.new.rename_project(path_before_change, self.path, namespace.full_path) + end + def rename_repo_notify! send_move_instructions(full_path_was) expires_full_path_cache @@ -1400,13 +1433,6 @@ class Project < ActiveRecord::Base reload_repository! end - def after_rename_repo - path_before_change = previous_changes['path'].first - - Gitlab::UploadsTransfer.new.rename_project(path_before_change, self.path, namespace.full_path) - Gitlab::PagesTransfer.new.rename_project(path_before_change, self.path, namespace.full_path) - end - def running_or_pending_build_count(force: false) Rails.cache.fetch(['projects', id, 'running_or_pending_build_count'], force: force) do builds.running_or_pending.count(:all) @@ -1468,7 +1494,8 @@ class Project < ActiveRecord::Base { key: 'CI_PROJECT_PATH', value: full_path, public: true }, { key: 'CI_PROJECT_PATH_SLUG', value: full_path_slug, public: true }, { key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path, public: true }, - { key: 'CI_PROJECT_URL', value: web_url, public: true } + { key: 'CI_PROJECT_URL', value: web_url, public: true }, + { key: 'CI_PROJECT_VISIBILITY', value: Gitlab::VisibilityLevel.string_level(visibility_level), public: true } ] end @@ -1596,8 +1623,13 @@ class Project < ActiveRecord::Base [nil, 0].include?(self.storage_version) end - def hashed_storage? - self.storage_version && self.storage_version >= 1 + # Check if Hashed Storage is enabled for the project with at least informed feature rolled out + # + # @param [Symbol] feature that needs to be rolled out for the project (:repository, :attachments) + def hashed_storage?(feature) + raise ArgumentError, "Invalid feature" unless HASHED_STORAGE_FEATURES.include?(feature) + + self.storage_version && self.storage_version >= HASHED_STORAGE_FEATURES[feature] end def renamed? @@ -1633,7 +1665,7 @@ class Project < ActiveRecord::Base end def migrate_to_hashed_storage! - return if hashed_storage? + return if hashed_storage?(:repository) update!(repository_read_only: true) @@ -1654,11 +1686,15 @@ class Project < ActiveRecord::Base Gitlab::GlRepository.gl_repository(self, is_wiki) end + def reference_counter(wiki: false) + Gitlab::ReferenceCounter.new(gl_repository(is_wiki: wiki)) + end + private def storage @storage ||= - if hashed_storage? + if hashed_storage?(:repository) Storage::HashedProject.new(self) else Storage::LegacyProject.new(self) @@ -1672,11 +1708,11 @@ class Project < ActiveRecord::Base end def repo_reference_count - Gitlab::ReferenceCounter.new(gl_repository(is_wiki: false)).value + reference_counter.value end def wiki_reference_count - Gitlab::ReferenceCounter.new(gl_repository(is_wiki: true)).value + reference_counter(wiki: true).value end def check_repository_absence! diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index 8ba07173c74..5c0b3338a62 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -153,7 +153,10 @@ class KubernetesService < DeploymentService end def default_namespace - "#{project.path}-#{project.id}" if project.present? + return unless project + + slug = "#{project.path}-#{project.id}".downcase + slug.gsub(/[^-a-z0-9]/, '-').gsub(/^-+/, '') end def build_kubeclient!(api_path: 'api', api_version: 'v1') diff --git a/app/models/project_services/packagist_service.rb b/app/models/project_services/packagist_service.rb new file mode 100644 index 00000000000..f68a0c1a3c3 --- /dev/null +++ b/app/models/project_services/packagist_service.rb @@ -0,0 +1,65 @@ +class PackagistService < Service + include HTTParty + + prop_accessor :username, :token, :server + + validates :username, presence: true, if: :activated? + validates :token, presence: true, if: :activated? + + default_value_for :push_events, true + default_value_for :tag_push_events, true + + after_save :compose_service_hook, if: :activated? + + def title + 'Packagist' + end + + def description + 'Update your project on Packagist, the main Composer repository' + end + + def self.to_param + 'packagist' + end + + def fields + [ + { type: 'text', name: 'username', placeholder: '', required: true }, + { type: 'text', name: 'token', placeholder: '', required: true }, + { type: 'text', name: 'server', placeholder: 'https://packagist.org', required: false } + ] + end + + def self.supported_events + %w(push merge_request tag_push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + service_hook.execute(data) + end + + def test(data) + begin + result = execute(data) + return { success: false, result: result[:message] } if result[:http_status] != 202 + rescue StandardError => error + return { success: false, result: error } + end + + { success: true, result: result[:message] } + end + + def compose_service_hook + hook = service_hook || build_service_hook + hook.url = hook_url + hook.save + end + + def hook_url + base_url = server.present? ? server : 'https://packagist.org' + "#{base_url}/api/update-package?username=#{username}&apiToken=#{token}" + end +end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index bb7be29ef66..43de6809178 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -135,7 +135,7 @@ class ProjectWiki end def repository - @repository ||= Repository.new(full_path, @project, disk_path: disk_path) + @repository ||= Repository.new(full_path, @project, disk_path: disk_path, is_wiki: true) end def default_branch diff --git a/app/models/repository.rb b/app/models/repository.rb index 7497b69f674..69cddb36b2e 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -15,9 +15,8 @@ class Repository ].freeze include Gitlab::ShellAdapter - include RepositoryMirroring - attr_accessor :full_path, :disk_path, :project + attr_accessor :full_path, :disk_path, :project, :is_wiki delegate :ref_name_for_sha, to: :raw_repository @@ -72,10 +71,12 @@ class Repository end end - def initialize(full_path, project, disk_path: nil) + def initialize(full_path, project, disk_path: nil, is_wiki: false) @full_path = full_path @disk_path = disk_path || full_path @project = project + @commit_cache = {} + @is_wiki = is_wiki end def ==(other) @@ -103,18 +104,17 @@ class Repository def commit(ref = 'HEAD') return nil unless exists? + return ref if ref.is_a?(::Commit) - commit = - if ref.is_a?(Gitlab::Git::Commit) - ref - else - Gitlab::Git::Commit.find(raw_repository, ref) - end + find_commit(ref) + end - commit = ::Commit.new(commit, @project) if commit - commit - rescue Rugged::OdbError, Rugged::TreeError - nil + # Finding a commit by the passed SHA + # Also takes care of caching, based on the SHA + def commit_by(oid:) + return @commit_cache[oid] if @commit_cache.key?(oid) + + @commit_cache[oid] = find_commit(oid) end def commits(ref, path: nil, limit: nil, offset: nil, skip_merges: false, after: nil, before: nil) @@ -231,7 +231,7 @@ class Repository # branches or tags, but we want to keep some of these commits around, for # example if they have comments or CI builds. def keep_around(sha) - return unless sha && commit(sha) + return unless sha && commit_by(oid: sha) return if kept_around?(sha) @@ -902,18 +902,27 @@ class Repository end end - def merged_to_root_ref?(branch_name) - branch_commit = commit(branch_name) - root_ref_commit = commit(root_ref) + def merged_to_root_ref?(branch_or_name, pre_loaded_merged_branches = nil) + branch = Gitlab::Git::Branch.find(self, branch_or_name) - if branch_commit - same_head = branch_commit.id == root_ref_commit.id - !same_head && ancestor?(branch_commit.id, root_ref_commit.id) + if branch + root_ref_sha = commit(root_ref).sha + same_head = branch.target == root_ref_sha + merged = + if pre_loaded_merged_branches + pre_loaded_merged_branches.include?(branch.name) + else + ancestor?(branch.target, root_ref_sha) + end + + !same_head && merged else nil end end + delegate :merged_branch_names, to: :raw_repository + def merge_base(first_commit_id, second_commit_id) first_commit_id = commit(first_commit_id).try(:id) || first_commit_id second_commit_id = commit(second_commit_id).try(:id) || second_commit_id @@ -956,21 +965,8 @@ class Repository run_git(args).first.lines.map(&:strip) end - def add_remote(name, url) - raw_repository.remote_add(name, url) - rescue Rugged::ConfigError - raw_repository.remote_update(name, url: url) - end - - def remove_remote(name) - raw_repository.remote_delete(name) - true - rescue Rugged::ConfigError - false - end - - def fetch_remote(remote, forced: false, no_tags: false) - gitlab_shell.fetch_remote(raw_repository, remote, forced: forced, no_tags: no_tags) + def fetch_remote(remote, forced: false, ssh_auth: nil, no_tags: false) + gitlab_shell.fetch_remote(raw_repository, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags) end def fetch_source_branch(source_repository, source_branch, local_ref) @@ -1064,6 +1060,18 @@ class Repository private + # TODO Generice finder, later split this on finders by Ref or Oid + # gitlab-org/gitlab-ce#39239 + def find_commit(oid_or_ref) + commit = if oid_or_ref.is_a?(Gitlab::Git::Commit) + oid_or_ref + else + Gitlab::Git::Commit.find(raw_repository, oid_or_ref) + end + + ::Commit.new(commit, @project) if commit + end + def blob_data_at(sha, path) blob = blob_at(sha, path) return unless blob @@ -1102,12 +1110,12 @@ class Repository def last_commit_for_path_by_gitaly(sha, path) c = raw_repository.gitaly_commit_client.last_commit_for_path(sha, path) - commit(c) + commit_by(oid: c) end def last_commit_for_path_by_rugged(sha, path) sha = last_commit_id_for_path_by_shelling_out(sha, path) - commit(sha) + commit_by(oid: sha) end def last_commit_id_for_path_by_shelling_out(sha, path) @@ -1120,7 +1128,7 @@ class Repository end def initialize_raw_repository - Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', Gitlab::GlRepository.gl_repository(project, false)) + Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', Gitlab::GlRepository.gl_repository(project, is_wiki)) end def find_commits_by_message_by_shelling_out(query, ref, path, limit, offset) diff --git a/app/models/service.rb b/app/models/service.rb index 6b64079215f..fdd2605e3e3 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -238,6 +238,7 @@ class Service < ActiveRecord::Base kubernetes mattermost_slash_commands mattermost + packagist pipelines_email pivotaltracker prometheus diff --git a/app/models/user.rb b/app/models/user.rb index 9459b6d4fa4..bcda4564595 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -21,8 +21,8 @@ class User < ActiveRecord::Base ignore_column :external_email ignore_column :email_provider + ignore_column :authentication_token - add_authentication_token_field :authentication_token add_authentication_token_field :incoming_email_token add_authentication_token_field :rss_token @@ -163,11 +163,12 @@ class User < ActiveRecord::Base before_validation :sanitize_attrs before_validation :set_notification_email, if: :email_changed? before_validation :set_public_email, if: :public_email_changed? - before_save :ensure_authentication_token, :ensure_incoming_email_token + before_save :ensure_incoming_email_token before_save :ensure_user_rights_and_limits, if: :external_changed? before_save :skip_reconfirmation!, if: ->(user) { user.email_changed? && user.read_only_attribute?(:email) } before_save :check_for_verified_email, if: ->(user) { user.email_changed? && !user.new_record? } after_save :ensure_namespace_correct + after_update :username_changed_hook, if: :username_changed? after_destroy :post_destroy_hook after_commit :update_emails_with_primary_email, on: :update, if: -> { previous_changes.key?('email') } after_commit :update_invalid_gpg_signatures, on: :update, if: -> { previous_changes.key?('email') } @@ -185,8 +186,6 @@ class User < ActiveRecord::Base # Note: When adding an option, it MUST go on the end of the array. enum project_view: [:readme, :activity, :files] - alias_attribute :private_token, :authentication_token - delegate :path, to: :namespace, allow_nil: true, prefix: true state_machine :state, initial: :active do @@ -873,6 +872,10 @@ class User < ActiveRecord::Base end end + def username_changed_hook + system_hook_service.execute_hooks_for(self, :rename) + end + def post_destroy_hook log_info("User \"#{name}\" (#{email}) was removed") system_hook_service.execute_hooks_for(self, :destroy) diff --git a/app/policies/gcp/cluster_policy.rb b/app/policies/clusters/cluster_policy.rb index e77173ea6e1..1f7c13072b9 100644 --- a/app/policies/gcp/cluster_policy.rb +++ b/app/policies/clusters/cluster_policy.rb @@ -1,8 +1,8 @@ -module Gcp +module Clusters class ClusterPolicy < BasePolicy alias_method :cluster, :subject - delegate { @subject.project } + delegate { cluster.project } rule { can?(:master_access) }.policy do enable :update_cluster diff --git a/app/presenters/gcp/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb index f7908f92a37..01cb59d0d44 100644 --- a/app/presenters/gcp/cluster_presenter.rb +++ b/app/presenters/clusters/cluster_presenter.rb @@ -1,9 +1,9 @@ -module Gcp +module Clusters class ClusterPresenter < Gitlab::View::Presenter::Delegated presents :cluster def gke_cluster_url - "https://console.cloud.google.com/kubernetes/clusters/details/#{gcp_cluster_zone}/#{gcp_cluster_name}" + "https://console.cloud.google.com/kubernetes/clusters/details/#{provider.zone}/#{name}" if gcp? end end end diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb index 61c7a428745..3b5a4fd4f79 100644 --- a/app/serializers/issuable_entity.rb +++ b/app/serializers/issuable_entity.rb @@ -1,20 +1,16 @@ class IssuableEntity < Grape::Entity + include RequestAwareEntity + expose :id expose :iid expose :author_id expose :description expose :lock_version expose :milestone_id - expose :state expose :title expose :updated_by_id expose :created_at expose :updated_at - expose :deleted_at - expose :time_estimate - expose :total_time_spent - expose :human_time_estimate - expose :human_total_time_spent expose :milestone, using: API::Entities::Milestone expose :labels, using: LabelEntity end diff --git a/app/serializers/issuable_sidebar_entity.rb b/app/serializers/issuable_sidebar_entity.rb new file mode 100644 index 00000000000..ff23d8bf0c7 --- /dev/null +++ b/app/serializers/issuable_sidebar_entity.rb @@ -0,0 +1,16 @@ +class IssuableSidebarEntity < Grape::Entity + include RequestAwareEntity + + expose :participants, using: ::API::Entities::UserBasic do |issuable| + issuable.participants(request.current_user) + end + + expose :subscribed do |issuable| + issuable.subscribed?(request.current_user, issuable.project) + end + + expose :time_estimate + expose :total_time_spent + expose :human_time_estimate + expose :human_total_time_spent +end diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index 10d3ad0214b..5f47592e4ad 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -1,6 +1,8 @@ class IssueEntity < IssuableEntity - include RequestAwareEntity + include TimeTrackableEntity + expose :state + expose :deleted_at expose :branch_name expose :confidential expose :discussion_locked diff --git a/app/serializers/issue_serializer.rb b/app/serializers/issue_serializer.rb index 4fff54a9126..2555595379b 100644 --- a/app/serializers/issue_serializer.rb +++ b/app/serializers/issue_serializer.rb @@ -1,3 +1,16 @@ class IssueSerializer < BaseSerializer - entity IssueEntity + # This overrided method takes care of which entity should be used + # to serialize the `issue` based on `basic` key in `opts` param. + # Hence, `entity` doesn't need to be declared on the class scope. + def represent(merge_request, opts = {}) + entity = + case opts[:serializer] + when 'sidebar' + IssueSidebarEntity + else + IssueEntity + end + + super(merge_request, opts, entity) + end end diff --git a/app/serializers/issue_sidebar_entity.rb b/app/serializers/issue_sidebar_entity.rb new file mode 100644 index 00000000000..6c823dbfe95 --- /dev/null +++ b/app/serializers/issue_sidebar_entity.rb @@ -0,0 +1,3 @@ +class IssueSidebarEntity < IssuableSidebarEntity + expose :assignees, using: API::Entities::UserBasic +end diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb index 8461f158bb5..d54a6516aed 100644 --- a/app/serializers/merge_request_basic_entity.rb +++ b/app/serializers/merge_request_basic_entity.rb @@ -1,11 +1,7 @@ -class MergeRequestBasicEntity < Grape::Entity +class MergeRequestBasicEntity < IssuableSidebarEntity expose :assignee_id expose :merge_status expose :merge_error expose :state expose :source_branch_exists?, as: :source_branch_exists - expose :time_estimate - expose :total_time_spent - expose :human_time_estimate - expose :human_total_time_spent end diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb index 297a459e394..b53a49fe59e 100644 --- a/app/serializers/merge_request_entity.rb +++ b/app/serializers/merge_request_entity.rb @@ -1,6 +1,8 @@ class MergeRequestEntity < IssuableEntity - include RequestAwareEntity + include TimeTrackableEntity + expose :state + expose :deleted_at expose :in_progress_merge_commit_sha expose :merge_commit_sha expose :merge_error diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index f67034ce47a..e9d98d8baca 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -3,7 +3,14 @@ class MergeRequestSerializer < BaseSerializer # to serialize the `merge_request` based on `basic` key in `opts` param. # Hence, `entity` doesn't need to be declared on the class scope. def represent(merge_request, opts = {}) - entity = opts[:basic] ? MergeRequestBasicEntity : MergeRequestEntity + entity = + case opts[:serializer] + when 'basic', 'sidebar' + MergeRequestBasicEntity + else + MergeRequestEntity + end + super(merge_request, opts, entity) end end diff --git a/app/serializers/time_trackable_entity.rb b/app/serializers/time_trackable_entity.rb new file mode 100644 index 00000000000..e81cd7bec72 --- /dev/null +++ b/app/serializers/time_trackable_entity.rb @@ -0,0 +1,11 @@ +module TimeTrackableEntity + extend ActiveSupport::Concern + extend Grape + + included do + expose :time_estimate + expose :total_time_spent + expose :human_time_estimate + expose :human_total_time_spent + end +end diff --git a/app/services/access_token_validation_service.rb b/app/services/access_token_validation_service.rb index 9c00ea789ec..46e19230328 100644 --- a/app/services/access_token_validation_service.rb +++ b/app/services/access_token_validation_service.rb @@ -39,11 +39,8 @@ class AccessTokenValidationService token_scopes = token.scopes.map(&:to_sym) required_scopes.any? do |scope| - if scope.respond_to?(:sufficient?) - scope.sufficient?(token_scopes, request) - else - API::Scope.new(scope).sufficient?(token_scopes, request) - end + scope = API::Scope.new(scope) unless scope.is_a?(API::Scope) + scope.sufficient?(token_scopes, request) end end end diff --git a/app/services/applications/create_service.rb b/app/services/applications/create_service.rb new file mode 100644 index 00000000000..35d45f25a71 --- /dev/null +++ b/app/services/applications/create_service.rb @@ -0,0 +1,13 @@ +module Applications + class CreateService + def initialize(current_user, params) + @current_user = current_user + @params = params + @ip_address = @params.delete(:ip_address) + end + + def execute(request = nil) + Doorkeeper::Application.create(@params) + end + end +end diff --git a/app/services/ci/create_cluster_service.rb b/app/services/ci/create_cluster_service.rb deleted file mode 100644 index f7ee0e468e2..00000000000 --- a/app/services/ci/create_cluster_service.rb +++ /dev/null @@ -1,15 +0,0 @@ -module Ci - class CreateClusterService < BaseService - def execute(access_token) - params['gcp_machine_type'] ||= GoogleApi::CloudPlatform::Client::DEFAULT_MACHINE_TYPE - - cluster_params = - params.merge(user: current_user, - gcp_token: access_token) - - project.create_cluster(cluster_params).tap do |cluster| - ClusterProvisionWorker.perform_async(cluster.id) if cluster.persisted? - end - end - end -end diff --git a/app/services/ci/fetch_gcp_operation_service.rb b/app/services/ci/fetch_gcp_operation_service.rb deleted file mode 100644 index 0b68e4d6ea9..00000000000 --- a/app/services/ci/fetch_gcp_operation_service.rb +++ /dev/null @@ -1,17 +0,0 @@ -module Ci - class FetchGcpOperationService - def execute(cluster) - api_client = - GoogleApi::CloudPlatform::Client.new(cluster.gcp_token, nil) - - operation = api_client.projects_zones_operations( - cluster.gcp_project_id, - cluster.gcp_cluster_zone, - cluster.gcp_operation_id) - - yield(operation) if block_given? - rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e - return cluster.make_errored!("Failed to request to CloudPlatform; #{e.message}") - end - end -end diff --git a/app/services/ci/finalize_cluster_creation_service.rb b/app/services/ci/finalize_cluster_creation_service.rb deleted file mode 100644 index 347875c5697..00000000000 --- a/app/services/ci/finalize_cluster_creation_service.rb +++ /dev/null @@ -1,33 +0,0 @@ -module Ci - class FinalizeClusterCreationService - def execute(cluster) - api_client = - GoogleApi::CloudPlatform::Client.new(cluster.gcp_token, nil) - - begin - gke_cluster = api_client.projects_zones_clusters_get( - cluster.gcp_project_id, - cluster.gcp_cluster_zone, - cluster.gcp_cluster_name) - rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e - return cluster.make_errored!("Failed to request to CloudPlatform; #{e.message}") - end - - endpoint = gke_cluster.endpoint - api_url = 'https://' + endpoint - ca_cert = Base64.decode64(gke_cluster.master_auth.cluster_ca_certificate) - username = gke_cluster.master_auth.username - password = gke_cluster.master_auth.password - - kubernetes_token = Ci::FetchKubernetesTokenService.new( - api_url, ca_cert, username, password).execute - - unless kubernetes_token - return cluster.make_errored!('Failed to get a default token of kubernetes') - end - - Ci::IntegrateClusterService.new.execute( - cluster, endpoint, ca_cert, kubernetes_token, username, password) - end - end -end diff --git a/app/services/ci/integrate_cluster_service.rb b/app/services/ci/integrate_cluster_service.rb deleted file mode 100644 index d123ce8d26b..00000000000 --- a/app/services/ci/integrate_cluster_service.rb +++ /dev/null @@ -1,26 +0,0 @@ -module Ci - class IntegrateClusterService - def execute(cluster, endpoint, ca_cert, token, username, password) - Gcp::Cluster.transaction do - cluster.update!( - enabled: true, - endpoint: endpoint, - ca_cert: ca_cert, - kubernetes_token: token, - username: username, - password: password, - service: cluster.project.find_or_initialize_service('kubernetes'), - status_event: :make_created) - - cluster.service.update!( - active: true, - api_url: cluster.api_url, - ca_pem: ca_cert, - namespace: cluster.project_namespace, - token: token) - end - rescue ActiveRecord::RecordInvalid => e - cluster.make_errored!("Failed to integrate cluster into kubernetes_service: #{e.message}") - end - end -end diff --git a/app/services/ci/provision_cluster_service.rb b/app/services/ci/provision_cluster_service.rb deleted file mode 100644 index 52d80b01813..00000000000 --- a/app/services/ci/provision_cluster_service.rb +++ /dev/null @@ -1,36 +0,0 @@ -module Ci - class ProvisionClusterService - def execute(cluster) - api_client = - GoogleApi::CloudPlatform::Client.new(cluster.gcp_token, nil) - - begin - operation = api_client.projects_zones_clusters_create( - cluster.gcp_project_id, - cluster.gcp_cluster_zone, - cluster.gcp_cluster_name, - cluster.gcp_cluster_size, - machine_type: cluster.gcp_machine_type) - rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e - return cluster.make_errored!("Failed to request to CloudPlatform; #{e.message}") - end - - unless operation.status == 'RUNNING' || operation.status == 'PENDING' - return cluster.make_errored!("Operation status is unexpected; #{operation.status_message}") - end - - cluster.gcp_operation_id = api_client.parse_operation_id(operation.self_link) - - unless cluster.gcp_operation_id - return cluster.make_errored!('Can not find operation_id from self_link') - end - - if cluster.make_creating - WaitForClusterCreationWorker.perform_in( - WaitForClusterCreationWorker::INITIAL_INTERVAL, cluster.id) - else - return cluster.make_errored!("Failed to update cluster record; #{cluster.errors}") - end - end - end -end diff --git a/app/services/ci/update_cluster_service.rb b/app/services/ci/update_cluster_service.rb deleted file mode 100644 index 70d88fca660..00000000000 --- a/app/services/ci/update_cluster_service.rb +++ /dev/null @@ -1,22 +0,0 @@ -module Ci - class UpdateClusterService < BaseService - def execute(cluster) - Gcp::Cluster.transaction do - cluster.update!(params) - - if params['enabled'] == 'true' - cluster.service.update!( - active: true, - api_url: cluster.api_url, - ca_pem: cluster.ca_cert, - namespace: cluster.project_namespace, - token: cluster.kubernetes_token) - else - cluster.service.update!(active: false) - end - end - rescue ActiveRecord::RecordInvalid => e - cluster.errors.add(:base, e.message) - end - end -end diff --git a/app/services/clusters/create_service.rb b/app/services/clusters/create_service.rb new file mode 100644 index 00000000000..1d407739b21 --- /dev/null +++ b/app/services/clusters/create_service.rb @@ -0,0 +1,29 @@ +module Clusters + class CreateService < BaseService + attr_reader :access_token + + def execute(access_token) + @access_token = access_token + + create_cluster.tap do |cluster| + ClusterProvisionWorker.perform_async(cluster.id) if cluster.persisted? + end + end + + private + + def create_cluster + Clusters::Cluster.create(cluster_params) + end + + def cluster_params + return @cluster_params if defined?(@cluster_params) + + params[:provider_gcp_attributes].try do |provider| + provider[:access_token] = access_token + end + + @cluster_params = params.merge(user: current_user, projects: [project]) + end + end +end diff --git a/app/services/clusters/gcp/fetch_operation_service.rb b/app/services/clusters/gcp/fetch_operation_service.rb new file mode 100644 index 00000000000..a4cd3ca5c11 --- /dev/null +++ b/app/services/clusters/gcp/fetch_operation_service.rb @@ -0,0 +1,16 @@ +module Clusters + module Gcp + class FetchOperationService + def execute(provider) + operation = provider.api_client.projects_zones_operations( + provider.gcp_project_id, + provider.zone, + provider.operation_id) + + yield(operation) if block_given? + rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e + provider.make_errored!("Failed to request to CloudPlatform; #{e.message}") + end + end + end +end diff --git a/app/services/clusters/gcp/finalize_creation_service.rb b/app/services/clusters/gcp/finalize_creation_service.rb new file mode 100644 index 00000000000..cea56f4e849 --- /dev/null +++ b/app/services/clusters/gcp/finalize_creation_service.rb @@ -0,0 +1,56 @@ +module Clusters + module Gcp + class FinalizeCreationService + attr_reader :provider + + def execute(provider) + @provider = provider + + configure_provider + configure_kubernetes + + cluster.save! + rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e + provider.make_errored!("Failed to request to CloudPlatform; #{e.message}") + rescue ActiveRecord::RecordInvalid => e + provider.make_errored!("Failed to configure GKE Cluster: #{e.message}") + end + + private + + def configure_provider + provider.endpoint = gke_cluster.endpoint + provider.status_event = :make_created + end + + def configure_kubernetes + cluster.platform_type = :kubernetes + cluster.build_platform_kubernetes( + api_url: 'https://' + gke_cluster.endpoint, + ca_cert: Base64.decode64(gke_cluster.master_auth.cluster_ca_certificate), + username: gke_cluster.master_auth.username, + password: gke_cluster.master_auth.password, + token: request_kuberenetes_token) + end + + def request_kuberenetes_token + Ci::FetchKubernetesTokenService.new( + 'https://' + gke_cluster.endpoint, + Base64.decode64(gke_cluster.master_auth.cluster_ca_certificate), + gke_cluster.master_auth.username, + gke_cluster.master_auth.password).execute + end + + def gke_cluster + @gke_cluster ||= provider.api_client.projects_zones_clusters_get( + provider.gcp_project_id, + provider.zone, + cluster.name) + end + + def cluster + @cluster ||= provider.cluster + end + end + end +end diff --git a/app/services/clusters/gcp/provision_service.rb b/app/services/clusters/gcp/provision_service.rb new file mode 100644 index 00000000000..8beea5a8cfb --- /dev/null +++ b/app/services/clusters/gcp/provision_service.rb @@ -0,0 +1,47 @@ +module Clusters + module Gcp + class ProvisionService + attr_reader :provider + + def execute(provider) + @provider = provider + + get_operation_id do |operation_id| + if provider.make_creating(operation_id) + WaitForClusterCreationWorker.perform_in( + Clusters::Gcp::VerifyProvisionStatusService::INITIAL_INTERVAL, + provider.cluster_id) + else + provider.make_errored!("Failed to update provider record; #{provider.errors}") + end + end + end + + private + + def get_operation_id + operation = provider.api_client.projects_zones_clusters_create( + provider.gcp_project_id, + provider.zone, + provider.cluster.name, + provider.num_nodes, + machine_type: provider.machine_type) + + unless operation.status == 'PENDING' || operation.status == 'RUNNING' + return provider.make_errored!("Operation status is unexpected; #{operation.status_message}") + end + + operation_id = provider.api_client.parse_operation_id(operation.self_link) + + unless operation_id + return provider.make_errored!('Can not find operation_id from self_link') + end + + yield(operation_id) + + rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e + provider.make_errored!("Failed to request to CloudPlatform; #{e.message}") + end + end + end +end diff --git a/app/services/clusters/gcp/verify_provision_status_service.rb b/app/services/clusters/gcp/verify_provision_status_service.rb new file mode 100644 index 00000000000..bc33756f27c --- /dev/null +++ b/app/services/clusters/gcp/verify_provision_status_service.rb @@ -0,0 +1,48 @@ +module Clusters + module Gcp + class VerifyProvisionStatusService + attr_reader :provider + + INITIAL_INTERVAL = 2.minutes + EAGER_INTERVAL = 10.seconds + TIMEOUT = 20.minutes + + def execute(provider) + @provider = provider + + request_operation do |operation| + case operation.status + when 'PENDING', 'RUNNING' + continue_creation(operation) + when 'DONE' + finalize_creation + else + return provider.make_errored!("Unexpected operation status; #{operation.status} #{operation.status_message}") + end + end + end + + private + + def continue_creation(operation) + if elapsed_time_from_creation(operation) < TIMEOUT + WaitForClusterCreationWorker.perform_in(EAGER_INTERVAL, provider.cluster_id) + else + provider.make_errored!("Cluster creation time exceeds timeout; #{TIMEOUT}") + end + end + + def elapsed_time_from_creation(operation) + Time.now.utc - operation.start_time.to_time.utc + end + + def finalize_creation + Clusters::Gcp::FinalizeCreationService.new.execute(provider) + end + + def request_operation(&blk) + Clusters::Gcp::FetchOperationService.new.execute(provider, &blk) + end + end + end +end diff --git a/app/services/clusters/update_service.rb b/app/services/clusters/update_service.rb new file mode 100644 index 00000000000..989218e32a2 --- /dev/null +++ b/app/services/clusters/update_service.rb @@ -0,0 +1,7 @@ +module Clusters + class UpdateService < BaseService + def execute(cluster) + cluster.update(params) + end + end +end diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb new file mode 100644 index 00000000000..92eaa5d5115 --- /dev/null +++ b/app/services/issuable/common_system_notes_service.rb @@ -0,0 +1,81 @@ +module Issuable + class CommonSystemNotesService < ::BaseService + attr_reader :issuable + + def execute(issuable, old_labels) + @issuable = issuable + + if issuable.previous_changes.include?('title') + create_title_change_note(issuable.previous_changes['title'].first) + end + + handle_description_change_note + + handle_time_tracking_note if issuable.is_a?(TimeTrackable) + create_labels_note(old_labels) if issuable.labels != old_labels + create_discussion_lock_note if issuable.previous_changes.include?('discussion_locked') + create_milestone_note if issuable.previous_changes.include?('milestone_id') + end + + private + + def handle_time_tracking_note + if issuable.previous_changes.include?('time_estimate') + create_time_estimate_note + end + + if issuable.time_spent? + create_time_spent_note + end + end + + def handle_description_change_note + if issuable.previous_changes.include?('description') + if issuable.tasks? && issuable.updated_tasks.any? + create_task_status_note + else + # TODO: Show this note if non-task content was modified. + # https://gitlab.com/gitlab-org/gitlab-ce/issues/33577 + create_description_change_note + end + end + end + + def create_labels_note(old_labels) + added_labels = issuable.labels - old_labels + removed_labels = old_labels - issuable.labels + + SystemNoteService.change_label(issuable, issuable.project, current_user, added_labels, removed_labels) + end + + def create_title_change_note(old_title) + SystemNoteService.change_title(issuable, issuable.project, current_user, old_title) + end + + def create_description_change_note + SystemNoteService.change_description(issuable, issuable.project, current_user) + end + + def create_task_status_note + issuable.updated_tasks.each do |task| + SystemNoteService.change_task_status(issuable, issuable.project, current_user, task) + end + end + + def create_time_estimate_note + SystemNoteService.change_time_estimate(issuable, issuable.project, current_user) + end + + def create_time_spent_note + SystemNoteService.change_time_spent(issuable, issuable.project, issuable.time_spent_user) + end + + def create_milestone_note + SystemNoteService.change_milestone(issuable, issuable.project, current_user, issuable.milestone) + end + + def create_discussion_lock_note + SystemNoteService.discussion_lock(issuable, current_user) + end + end +end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index d61a342ebad..68b49d880f7 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -1,56 +1,10 @@ class IssuableBaseService < BaseService private - def create_milestone_note(issuable) - SystemNoteService.change_milestone( - issuable, issuable.project, current_user, issuable.milestone) - end - - def create_labels_note(issuable, old_labels) - added_labels = issuable.labels - old_labels - removed_labels = old_labels - issuable.labels - - SystemNoteService.change_label( - issuable, issuable.project, current_user, added_labels, removed_labels) - end - - def create_title_change_note(issuable, old_title) - SystemNoteService.change_title( - issuable, issuable.project, current_user, old_title) - end - - def create_description_change_note(issuable) - SystemNoteService.change_description(issuable, issuable.project, current_user) - end - - def create_branch_change_note(issuable, branch_type, old_branch, new_branch) - SystemNoteService.change_branch( - issuable, issuable.project, current_user, branch_type, - old_branch, new_branch) - end - - def create_task_status_note(issuable) - issuable.updated_tasks.each do |task| - SystemNoteService.change_task_status(issuable, issuable.project, current_user, task) - end - end - - def create_time_estimate_note(issuable) - SystemNoteService.change_time_estimate(issuable, issuable.project, current_user) - end - - def create_time_spent_note(issuable) - SystemNoteService.change_time_spent(issuable, issuable.project, current_user) - end - - def create_discussion_lock_note(issuable) - SystemNoteService.discussion_lock(issuable, current_user) - end - def filter_params(issuable) ability_name = :"admin_#{issuable.to_ability_name}" - unless can?(current_user, ability_name, project) + unless can?(current_user, ability_name, issuable) params.delete(:milestone_id) params.delete(:labels) params.delete(:add_label_ids) @@ -233,15 +187,14 @@ class IssuableBaseService < BaseService # We have to perform this check before saving the issuable as Rails resets # the changed fields upon calling #save. - update_project_counters = issuable.update_project_counter_caches? + update_project_counters = issuable.project && issuable.update_project_counter_caches? if issuable.with_transaction_returning_status { issuable.save } # We do not touch as it will affect a update on updated_at field ActiveRecord::Base.no_touching do - handle_common_system_notes(issuable, old_labels: old_labels) + Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels) end - change_discussion_lock(issuable) handle_changes( issuable, old_labels: old_labels, @@ -300,12 +253,6 @@ class IssuableBaseService < BaseService end end - def change_discussion_lock(issuable) - if issuable.previous_changes.include?('discussion_locked') - create_discussion_lock_note(issuable) - end - end - def toggle_award(issuable) award = params.delete(:emoji_award) if award @@ -328,35 +275,17 @@ class IssuableBaseService < BaseService attrs_changed || labels_changed || assignees_changed end - def handle_common_system_notes(issuable, old_labels: []) - if issuable.previous_changes.include?('title') - create_title_change_note(issuable, issuable.previous_changes['title'].first) - end - - if issuable.previous_changes.include?('description') - if issuable.tasks? && issuable.updated_tasks.any? - create_task_status_note(issuable) - else - # TODO: Show this note if non-task content was modified. - # https://gitlab.com/gitlab-org/gitlab-ce/issues/33577 - create_description_change_note(issuable) - end - end - - if issuable.previous_changes.include?('time_estimate') - create_time_estimate_note(issuable) - end - - if issuable.time_spent? - create_time_spent_note(issuable) - end - - create_labels_note(issuable, old_labels) if issuable.labels != old_labels - end - def invalidate_cache_counts(issuable, users: []) users.each do |user| user.public_send("invalidate_#{issuable.model_name.singular}_cache_counts") # rubocop:disable GitlabSecurity/PublicSend end end + + # override if needed + def handle_changes(issuable, options) + end + + # override if needed + def execute_hooks(issuable, action = 'open', params = {}) + end end diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb index 35de4337b15..62b4b4b6a1e 100644 --- a/app/services/issues/reopen_service.rb +++ b/app/services/issues/reopen_service.rb @@ -9,6 +9,7 @@ module Issues notification_service.reopen_issue(issue, current_user) execute_hooks(issue, 'reopen') invalidate_cache_counts(issue, users: issue.assignees) + issue.update_project_counter_caches end issue diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index e0339ddf9bb..1b7b5927c5a 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -27,10 +27,6 @@ module Issues todo_service.update_issue(issue, current_user, old_mentioned_users) end - if issue.previous_changes.include?('milestone_id') - create_milestone_note(issue) - end - if issue.assignees != old_assignees create_assignee_note(issue, old_assignees) notification_service.reassigned_issue(issue, current_user, old_assignees) diff --git a/app/services/keys/base_service.rb b/app/services/keys/base_service.rb index 545832d0bd4..f78791932a7 100644 --- a/app/services/keys/base_service.rb +++ b/app/services/keys/base_service.rb @@ -4,6 +4,7 @@ module Keys def initialize(user, params) @user, @params = user, params + @ip_address = @params.delete(:ip_address) end def notification_service diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 8c5821aa870..156e7b2f078 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -82,16 +82,9 @@ module MergeRequests @merge_request.can_remove_source_branch?(branch_deletion_user) end - # Logs merge error message and cleans `MergeRequest#merge_jid`. - # def handle_merge_error(log_message:, save_message_on_model: false) Rails.logger.error("MergeService ERROR: #{merge_request_info} - #{log_message}") - - if save_message_on_model - @merge_request.update(merge_error: log_message, merge_jid: nil) - else - clean_merge_jid - end + @merge_request.update(merge_error: log_message) if save_message_on_model end def merge_request_info diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb index b9c65be36ec..c599a90f9fe 100644 --- a/app/services/merge_requests/reopen_service.rb +++ b/app/services/merge_requests/reopen_service.rb @@ -11,6 +11,7 @@ module MergeRequests merge_request.reload_diff(current_user) merge_request.mark_as_unchecked invalidate_cache_counts(merge_request, users: merge_request.assignees) + merge_request.update_project_counter_caches end merge_request diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 2832d893e95..1f394cacc64 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -40,10 +40,6 @@ module MergeRequests merge_request.target_branch) end - if merge_request.previous_changes.include?('milestone_id') - create_milestone_note(merge_request) - end - if merge_request.previous_changes.include?('assignee_id') create_assignee_note(merge_request) notification_service.reassigned_merge_request(merge_request, current_user) @@ -111,5 +107,11 @@ module MergeRequests end end end + + def create_branch_change_note(issuable, branch_type, old_branch, new_branch) + SystemNoteService.change_branch( + issuable, issuable.project, current_user, branch_type, + old_branch, new_branch) + end end end diff --git a/app/services/metrics_service.rb b/app/services/metrics_service.rb index a02eee4961b..6b3939aeba5 100644 --- a/app/services/metrics_service.rb +++ b/app/services/metrics_service.rb @@ -6,8 +6,7 @@ class MetricsService Gitlab::HealthChecks::Redis::RedisCheck, Gitlab::HealthChecks::Redis::CacheCheck, Gitlab::HealthChecks::Redis::QueuesCheck, - Gitlab::HealthChecks::Redis::SharedStateCheck, - Gitlab::HealthChecks::FsShardsCheck + Gitlab::HealthChecks::Redis::SharedStateCheck ].freeze def prometheus_metrics_text diff --git a/app/services/milestones/promote_service.rb b/app/services/milestones/promote_service.rb new file mode 100644 index 00000000000..bd9cfd4e0ea --- /dev/null +++ b/app/services/milestones/promote_service.rb @@ -0,0 +1,80 @@ +module Milestones + class PromoteService < Milestones::BaseService + PromoteMilestoneError = Class.new(StandardError) + + def execute(milestone) + check_project_milestone!(milestone) + + Milestone.transaction do + # Destroy all milestones with same title across projects + destroy_old_milestones(milestone) + + group_milestone = clone_project_milestone(milestone) + + move_children_to_group_milestone(group_milestone) + + # Just to be safe + unless group_milestone.valid? + raise_error(group_milestone.errors.full_messages.to_sentence) + end + + group_milestone + end + end + + private + + def milestone_ids_for_merge(group_milestone) + # Pluck need to be used here instead of select so the array of ids + # is persistent after old milestones gets deleted. + @milestone_ids_for_merge ||= begin + search_params = { title: group_milestone.title, project_ids: group_project_ids, state: 'all' } + milestones = MilestonesFinder.new(search_params).execute + milestones.pluck(:id) + end + end + + def move_children_to_group_milestone(group_milestone) + milestone_ids_for_merge(group_milestone).in_groups_of(100) do |milestone_ids| + update_children(group_milestone, milestone_ids) + end + end + + def check_project_milestone!(milestone) + raise_error('Only project milestones can be promoted.') unless milestone.project_milestone? + end + + def clone_project_milestone(milestone) + params = milestone.slice(:title, :description, :start_date, :due_date, :state_event) + + create_service = CreateService.new(group, current_user, params) + + create_service.execute + end + + def update_children(group_milestone, milestone_ids) + issues = Issue.where(project_id: group_project_ids, milestone_id: milestone_ids) + merge_requests = MergeRequest.where(source_project_id: group_project_ids, milestone_id: milestone_ids) + + [issues, merge_requests].each do |issuable_collection| + issuable_collection.update_all(milestone_id: group_milestone.id) + end + end + + def group + @group ||= parent.group || raise_error('Project does not belong to a group.') + end + + def destroy_old_milestones(group_milestone) + Milestone.where(id: milestone_ids_for_merge(group_milestone)).destroy_all + end + + def group_project_ids + @group_project_ids ||= group.projects.map(&:id) + end + + def raise_error(message) + raise PromoteMilestoneError, "Promotion failed - #{message}" + end + end +end diff --git a/app/services/projects/group_links/create_service.rb b/app/services/projects/group_links/create_service.rb new file mode 100644 index 00000000000..35624577024 --- /dev/null +++ b/app/services/projects/group_links/create_service.rb @@ -0,0 +1,15 @@ +module Projects + module GroupLinks + class CreateService < BaseService + def execute(group) + return false unless group + + project.project_group_links.create( + group: group, + group_access: params[:link_group_access], + expires_at: params[:expires_at] + ) + end + end + end +end diff --git a/app/services/projects/group_links/destroy_service.rb b/app/services/projects/group_links/destroy_service.rb new file mode 100644 index 00000000000..fbf31214c28 --- /dev/null +++ b/app/services/projects/group_links/destroy_service.rb @@ -0,0 +1,10 @@ +module Projects + module GroupLinks + class DestroyService < BaseService + def execute(group_link) + return false unless group_link + group_link.destroy + end + end + end +end diff --git a/app/services/projects/hashed_storage_migration_service.rb b/app/services/projects/hashed_storage_migration_service.rb index 41259de3a16..f5945f3b87f 100644 --- a/app/services/projects/hashed_storage_migration_service.rb +++ b/app/services/projects/hashed_storage_migration_service.rb @@ -10,7 +10,7 @@ module Projects end def execute - return if project.hashed_storage? + return if project.hashed_storage?(:repository) @old_disk_path = project.disk_path has_wiki = project.wiki.repository_exists? diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index c3bf0031409..455b302d819 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -44,7 +44,7 @@ module Projects else clone_repository end - rescue Gitlab::Shell::Error => e + rescue Gitlab::Shell::Error, Gitlab::Git::RepositoryMirroring::RemoteError => e # Expire cache to prevent scenarios such as: # 1. First import failed, but the repo was imported successfully, so +exists?+ returns true # 2. Retried import, repo is broken or not imported but +exists?+ still returns true diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb index 2b82e5732e4..c499f384426 100644 --- a/app/services/projects/unlink_fork_service.rb +++ b/app/services/projects/unlink_fork_service.rb @@ -3,18 +3,24 @@ module Projects def execute return unless @project.forked? - @project.forked_from_project.lfs_objects.find_each do |lfs_object| - lfs_object.projects << @project + if fork_source = @project.fork_source + fork_source.lfs_objects.find_each do |lfs_object| + lfs_object.projects << @project + end + + refresh_forks_count(fork_source) end - merge_requests = @project.forked_from_project.merge_requests.opened.from_project(@project) + merge_requests = @project.fork_network + .merge_requests + .opened + .where.not(target_project: @project) + .from_project(@project) merge_requests.each do |mr| ::MergeRequests::CloseService.new(@project, @current_user).execute(mr) end - refresh_forks_count(@project.forked_from_project) - @project.fork_network_member.destroy @project.forked_project_link.destroy end diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb index a1c2f8d0180..911cc919bb8 100644 --- a/app/services/system_hooks_service.rb +++ b/app/services/system_hooks_service.rb @@ -35,24 +35,22 @@ class SystemHooksService data[:old_path_with_namespace] = model.old_path_with_namespace end when User - data.merge!({ - name: model.name, - email: model.email, - user_id: model.id, - username: model.username - }) + data.merge!(user_data(model)) + + if event == :rename + data[:old_username] = model.username_was + end when ProjectMember data.merge!(project_member_data(model)) when Group - owner = model.owner + data.merge!(group_data(model)) - data.merge!( - name: model.name, - path: model.path, - group_id: model.id, - owner_name: owner.respond_to?(:name) ? owner.name : nil, - owner_email: owner.respond_to?(:email) ? owner.email : nil - ) + if event == :rename + data.merge!( + old_path: model.path_was, + old_full_path: model.full_path_was + ) + end when GroupMember data.merge!(group_member_data(model)) end @@ -83,7 +81,7 @@ class SystemHooksService project_id: model.id, owner_name: owner.name, owner_email: owner.respond_to?(:email) ? owner.email : "", - project_visibility: Project.visibility_levels.key(model.visibility_level_value).downcase + project_visibility: model.visibility.downcase } end @@ -104,6 +102,19 @@ class SystemHooksService } end + def group_data(model) + owner = model.owner + + { + name: model.name, + path: model.path, + full_path: model.full_path, + group_id: model.id, + owner_name: owner.try(:name), + owner_email: owner.try(:email) + } + end + def group_member_data(model) { group_name: model.group.name, @@ -116,4 +127,13 @@ class SystemHooksService group_access: model.human_access } end + + def user_data(model) + { + name: model.name, + email: model.email, + user_id: model.id, + username: model.username + } + end end diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index 7027ac4b5db..d4ba3a028be 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -30,7 +30,7 @@ class FileUploader < GitlabUploader # # Returns a String without a trailing slash def self.dynamic_path_segment(model) - File.join(CarrierWave.root, base_dir, model.full_path) + File.join(CarrierWave.root, base_dir, model.disk_path) end attr_accessor :model diff --git a/app/validators/cluster_name_validator.rb b/app/validators/cluster_name_validator.rb new file mode 100644 index 00000000000..13ec342f399 --- /dev/null +++ b/app/validators/cluster_name_validator.rb @@ -0,0 +1,24 @@ +# ClusterNameValidator +# +# Custom validator for ClusterName. +class ClusterNameValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + if record.user? + unless value.present? + record.errors.add(attribute, " has to be present") + end + elsif record.gcp? + if record.persisted? && record.name_changed? + record.errors.add(attribute, " can not be changed because it's synchronized with provider") + end + + unless value.length >= 1 && value.length <= 63 + record.errors.add(attribute, " is invalid syntax") + end + + unless value =~ Gitlab::Regex.kubernetes_namespace_regex + record.errors.add(attribute, Gitlab::Regex.kubernetes_namespace_regex_message) + end + end + end +end diff --git a/app/views/admin/hook_logs/_index.html.haml b/app/views/admin/hook_logs/_index.html.haml index 7dd9943190f..91a8c0c62fe 100644 --- a/app/views/admin/hook_logs/_index.html.haml +++ b/app/views/admin/hook_logs/_index.html.haml @@ -24,7 +24,7 @@ %td = truncate(hook_log.url, length: 50) %td.light - #{number_with_precision(hook_log.execution_duration, precision: 2)} ms + #{number_with_precision(hook_log.execution_duration, precision: 2)} sec %td.light = time_ago_with_tooltip(hook_log.created_at) %td diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml index 4d8754afdd2..c37d8ac45b9 100644 --- a/app/views/admin/projects/index.html.haml +++ b/app/views/admin/projects/index.html.haml @@ -14,7 +14,7 @@ = hidden_field_tag :namespace_id, params[:namespace_id] - namespace = Namespace.find(params[:namespace_id]) - toggle_text = "#{namespace.kind}: #{namespace.full_path}" - = dropdown_toggle(toggle_text, { toggle: 'dropdown' }, { toggle_class: 'js-namespace-select large' }) + = dropdown_toggle(toggle_text, { toggle: 'dropdown', is_filter: 'true' }, { toggle_class: 'js-namespace-select large' }) .dropdown-menu.dropdown-select.dropdown-menu-align-right = dropdown_title('Namespaces') = dropdown_filter("Search for Namespace") diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index ab4165c0bf2..42f92079d85 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -115,7 +115,7 @@ = f.label :new_namespace_id, "Namespace", class: 'control-label' .col-sm-10 .dropdown - = dropdown_toggle('Search for Namespace', { toggle: 'dropdown', field_name: 'new_namespace_id', show_any: 'false' }, { toggle_class: 'js-namespace-select large' }) + = dropdown_toggle('Search for Namespace', { toggle: 'dropdown', field_name: 'new_namespace_id' }, { toggle_class: 'js-namespace-select large' }) .dropdown-menu.dropdown-select = dropdown_title('Namespaces') = dropdown_filter("Search for Namespace") diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index 76f4a817744..4965dffab9d 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -52,22 +52,23 @@ %br - if @runners.any? - .table-holder - %table.table - %thead - %tr - %th Type - %th Runner token - %th Description - %th Version - %th Projects - %th Jobs - %th Tags - %th= link_to 'Last contact', admin_runners_path(params.slice(:search).merge(sort: 'contacted_asc')) - %th + .runners-content + .table-holder + %table.table + %thead + %tr + %th Type + %th Runner token + %th Description + %th Version + %th Projects + %th Jobs + %th Tags + %th Last contact + %th - - @runners.each do |runner| - = render "admin/runners/runner", runner: runner - = paginate @runners, theme: "gitlab" + - @runners.each do |runner| + = render "admin/runners/runner", runner: runner + = paginate @runners, theme: "gitlab" - else .nothing-here-block No runners found diff --git a/app/views/ci/status/_badge.html.haml b/app/views/ci/status/_badge.html.haml index 39c7fb0eba2..35a3563dff1 100644 --- a/app/views/ci/status/_badge.html.haml +++ b/app/views/ci/status/_badge.html.haml @@ -5,9 +5,9 @@ - if link && status.has_details? = link_to status.details_path, class: css_classes, title: title do - = custom_icon(status.icon) + = sprite_icon(status.icon) = status.text - else %span{ class: css_classes, title: title } - = custom_icon(status.icon) + = sprite_icon(status.icon) = status.text diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml index dcfb7f0c32d..c5b4439e273 100644 --- a/app/views/ci/status/_dropdown_graph_badge.html.haml +++ b/app/views/ci/status/_dropdown_graph_badge.html.haml @@ -7,13 +7,13 @@ - if status.has_details? = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do - %span{ class: klass }= custom_icon(status.icon) + %span{ class: klass }= sprite_icon(status.icon) %span.ci-build-text= subject.name - else .menu-item.mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } } - %span{ class: klass }= custom_icon(status.icon) + %span{ class: klass }= sprite_icon(status.icon) %span.ci-build-text= subject.name - if status.has_action? - = link_to status.action_path, class: 'ci-action-icon-wrapper js-ci-action-icon', method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do - = custom_icon(status.action_icon) + = link_to status.action_path, class: "ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do + = sprite_icon(status.action_icon, css_class: "icon-action-#{status.action_icon}") diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index f62a0cd681e..a5686002328 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -8,7 +8,7 @@ %li.todos-pending{ class: active_when(params[:state].blank? || params[:state] == 'pending') }> = link_to todos_filter_path(state: 'pending') do %span - To do + Todos %span.badge = number_with_delimiter(todos_pending_count) %li.todos-done{ class: active_when(params[:state] == 'done') }> diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml index cc879e5a308..a1be0d3220a 100644 --- a/app/views/groups/milestones/_form.html.haml +++ b/app/views/groups/milestones/_form.html.haml @@ -11,7 +11,7 @@ = f.label :description, "Description", class: "control-label" .col-sm-10 = render layout: 'projects/md_preview', locals: { url: group_preview_markdown_path } do - = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...' + = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...', supports_autocomplete: false .clearfix .error-alert diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index f82207559a3..66146e61263 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -274,7 +274,7 @@ Members %ul.sidebar-sub-level-items.is-fly-out-only = nav_link(path: %w[members#show], html_options: { class: "fly-out-top-item" } ) do - = link_to project_settings_members_path(@project) do + = link_to project_project_members_path(@project) do %strong.fly-out-top-item-name #{ _('Members') } diff --git a/app/views/profiles/accounts/_reset_token.html.haml b/app/views/profiles/accounts/_reset_token.html.haml deleted file mode 100644 index c31a4a8ecd4..00000000000 --- a/app/views/profiles/accounts/_reset_token.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -- name = label.parameterize -- attribute = name.underscore - -.reset-action - %p.cgray - = label_tag name, label, class: "label-light" - = text_field_tag name, current_user.send(attribute), class: 'form-control', readonly: true, onclick: 'this.select()' - %p.help-block - = help_text - .prepend-top-default - = link_to button_label, [:reset, attribute, :profile], method: :put, data: { confirm: 'Are you sure?' }, class: 'btn btn-default private-token' diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 7f79168dfb3..ced58dffcdc 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -9,22 +9,6 @@ .row.prepend-top-default .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 - Private Tokens - %p - Keep these tokens secret, anyone with access to them can interact with - GitLab as if they were you. - .col-lg-8.private-tokens-reset - = render partial: 'reset_token', locals: { label: 'Private token', button_label: 'Reset private token', help_text: 'Your private token is used to access the API and Atom feeds without username/password authentication.' } - - = render partial: 'reset_token', locals: { label: 'RSS token', button_label: 'Reset RSS token', help_text: 'Your RSS token is used to create urls for personalized RSS feeds.' } - - - if incoming_email_token_enabled? - = render partial: 'reset_token', locals: { label: 'Incoming email token', button_label: 'Reset incoming email token', help_text: 'Your incoming email token is used to create new issues by email, and is included in your project-specific email addresses.' } - -%hr -.row.prepend-top-default - .col-lg-4.profile-settings-sidebar - %h4.prepend-top-0 Two-Factor Authentication %p Increase your account's security by enabling Two-Factor Authentication (2FA). diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 06bb72b9f0d..26c2e4c5936 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -30,3 +30,40 @@ = render "shared/personal_access_tokens_form", path: profile_personal_access_tokens_path, impersonation: false, token: @personal_access_token, scopes: @scopes = render "shared/personal_access_tokens_table", impersonation: false, active_tokens: @active_personal_access_tokens, inactive_tokens: @inactive_personal_access_tokens + +%hr +.row.prepend-top-default + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 + RSS token + %p + Your RSS token is used to authenticate you when your RSS reader loads a personalized RSS feed, and is included in your personal RSS feed URLs. + %p + It cannot be used to access any other data. + .col-lg-8.rss-token-reset + = label_tag :rss_token, 'RSS token', class: "label-light" + = text_field_tag :rss_token, current_user.rss_token, class: 'form-control', readonly: true, onclick: 'this.select()' + %p.help-block + Keep this token secret. Anyone who gets ahold of it can read activity and issue RSS feeds as if they were you. + You should + = link_to 'reset it', [:reset, :rss_token, :profile], method: :put, data: { confirm: 'Are you sure? Any RSS URLs currently in use will stop working.' } + if that ever happens. + +- if incoming_email_token_enabled? + %hr + .row.prepend-top-default + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 + Incoming email token + %p + Your incoming email token is used to authenticate you when you create a new issue by email, and is included in your personal project-specific email addresses. + %p + It cannot be used to access any other data. + .col-lg-8.incoming-email-token-reset + = label_tag :incoming_email_token, 'Incoming email token', class: "label-light" + = text_field_tag :incoming_email_token, current_user.incoming_email_token, class: 'form-control', readonly: true, onclick: 'this.select()' + %p.help-block + Keep this token secret. Anyone who gets ahold of it can create issues as if they were you. + You should + = link_to 'reset it', [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: 'Are you sure? Any issue email addresses currently in use will stop working.' } + if that ever happens. diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index 623d3bc91c6..c5b1897c492 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -3,7 +3,7 @@ - project = local_assigns.fetch(:project) - expanded = Rails.env.test? -%section.settings +%section.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 Export project @@ -11,7 +11,7 @@ = expanded ? 'Collapse' : 'Expand' %p Export this project with all its related data in order to move your project to a new GitLab instance. Once the export is finished, you can import the file from the "New Project" page. - .settings-content.no-animate{ class: ('expanded' if expanded) } + .settings-content .bs-callout.bs-callout-info %p.append-bottom-0 %p diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 619b632918e..1d644dda177 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -1,6 +1,5 @@ - empty_repo = @project.empty_repo? - fork_network = @project.fork_network -- forked_from_project = @project.forked_from_project || fork_network&.root_project .project-home-panel.text-center{ class: ("empty-project" if empty_repo) } .limit-container-width{ class: container_class } .avatar-container.s70.project-avatar @@ -16,13 +15,13 @@ - if @project.forked? %p - - if forked_from_project + - if @project.fork_source #{ s_('ForkedFromProjectPath|Forked from') } - = link_to project_path(forked_from_project) do - = forked_from_project.full_name + = link_to project_path(@project.fork_source) do + = fork_source_name(@project) - else - deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)') - = deleted_message % { project_name: fork_network.deleted_root_project_name } + = deleted_message % { project_name: fork_source_name(@project) } .project-repo-buttons .count-buttons diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 49101d1efa4..6e02ae6c9cc 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -1,3 +1,4 @@ +- merged = local_assigns.fetch(:merged, false) - commit = @repository.commit(branch.dereferenced_target) - bar_graph_width_factor = @max_commits > 0 ? 100.0/@max_commits : 0 - diverging_commit_counts = @repository.diverging_commit_counts(branch) @@ -12,7 +13,7 @@ - if branch.name == @repository.root_ref %span.label.label-primary default - - elsif @repository.merged_to_root_ref? branch.name + - elsif merged %span.label.label-info.has-tooltip{ title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref } } = s_('Branches|merged') @@ -47,7 +48,7 @@ target: "#modal-delete-branch", delete_path: project_branch_path(@project, branch.name), branch_name: branch.name, - is_merged: ("true" if @repository.merged_to_root_ref?(branch.name)) } } + is_merged: ("true" if merged) } } = icon("trash-o") - else %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled", diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index 7d9645d79e6..aade310236e 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -38,7 +38,7 @@ - if @branches.any? %ul.content-list.all-branches - @branches.each do |branch| - = render "projects/branches/branch", branch: branch + = render "projects/branches/branch", branch: branch, merged: @repository.merged_to_root_ref?(branch, @merged_branch_names) = paginate @branches, theme: 'gitlab' - else .nothing-here-block diff --git a/app/views/projects/clusters/_form.html.haml b/app/views/projects/clusters/_form.html.haml index 371cdb1e403..d8e5b55bb88 100644 --- a/app/views/projects/clusters/_form.html.haml +++ b/app/views/projects/clusters/_form.html.haml @@ -4,34 +4,32 @@ - link_to_help_page = link_to(s_('ClusterIntegration|help page'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') = s_('ClusterIntegration|Read our %{link_to_help_page} on cluster integration.').html_safe % { link_to_help_page: link_to_help_page} - = form_for [@project.namespace.becomes(Namespace), @project, @cluster] do |field| + = form_for @cluster, url: namespace_project_clusters_path(@project.namespace, @project, @cluster), as: :cluster do |field| + = field.hidden_field :provider_type, value: :gcp = form_errors(@cluster) .form-group - = field.label :gcp_cluster_name, s_('ClusterIntegration|Cluster name') - = field.text_field :gcp_cluster_name, class: 'form-control' + = field.label :name, s_('ClusterIntegration|Cluster name') + = field.text_field :name, class: 'form-control' - .form-group - = field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project ID') - = link_to(s_('ClusterIntegration|See your projects'), 'https://console.cloud.google.com/home/dashboard', target: '_blank', rel: 'noopener noreferrer') - = field.text_field :gcp_project_id, class: 'form-control' - - .form-group - = field.label :gcp_cluster_zone, s_('ClusterIntegration|Zone') - = link_to(s_('ClusterIntegration|See zones'), 'https://cloud.google.com/compute/docs/regions-zones/regions-zones', target: '_blank', rel: 'noopener noreferrer') - = field.text_field :gcp_cluster_zone, class: 'form-control', placeholder: 'us-central1-a' + = field.fields_for :provider_gcp, @cluster.provider_gcp do |provider_gcp_field| + .form-group + = provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project ID') + = link_to(s_('ClusterIntegration|See your projects'), 'https://console.cloud.google.com/home/dashboard', target: '_blank', rel: 'noopener noreferrer') + = provider_gcp_field.text_field :gcp_project_id, class: 'form-control' - .form-group - = field.label :gcp_cluster_size, s_('ClusterIntegration|Number of nodes') - = field.text_field :gcp_cluster_size, class: 'form-control', placeholder: '3' + .form-group + = provider_gcp_field.label :zone, s_('ClusterIntegration|Zone') + = link_to(s_('ClusterIntegration|See zones'), 'https://cloud.google.com/compute/docs/regions-zones/regions-zones', target: '_blank', rel: 'noopener noreferrer') + = provider_gcp_field.text_field :zone, class: 'form-control', placeholder: 'us-central1-a' - .form-group - = field.label :gcp_machine_type, s_('ClusterIntegration|Machine type') - = link_to(s_('ClusterIntegration|See machine types'), 'https://cloud.google.com/compute/docs/machine-types', target: '_blank', rel: 'noopener noreferrer') - = field.text_field :gcp_machine_type, class: 'form-control', placeholder: 'n1-standard-4' + .form-group + = provider_gcp_field.label :num_nodes, s_('ClusterIntegration|Number of nodes') + = provider_gcp_field.text_field :num_nodes, class: 'form-control', placeholder: '3' .form-group - = field.label :project_namespace, s_('ClusterIntegration|Project namespace (optional, unique)') - = field.text_field :project_namespace, class: 'form-control', placeholder: @cluster.project_namespace_placeholder + = provider_gcp_field.label :machine_type, s_('ClusterIntegration|Machine type') + = link_to(s_('ClusterIntegration|See machine types'), 'https://cloud.google.com/compute/docs/machine-types', target: '_blank', rel: 'noopener noreferrer') + = provider_gcp_field.text_field :machine_type, class: 'form-control', placeholder: 'n1-standard-4' .form-group = field.submit s_('ClusterIntegration|Create cluster'), class: 'btn btn-save' diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml index b127e06030e..ebb9383ca12 100644 --- a/app/views/projects/clusters/show.html.haml +++ b/app/views/projects/clusters/show.html.haml @@ -10,9 +10,9 @@ cluster_status: @cluster.status_name, cluster_status_reason: @cluster.status_reason } } - %section.settings + %section.settings.no-animate.expanded %h4= s_('ClusterIntegration|Enable cluster integration') - .settings-content.expanded + .settings-content .hidden.js-cluster-error.alert.alert-danger.alert-block.append-bottom-10{ role: 'alert' } = s_('ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine') @@ -33,7 +33,7 @@ - else = s_('ClusterIntegration|Cluster integration is disabled for this project.') - = form_for [@project.namespace.becomes(Namespace), @project, @cluster] do |field| + = form_for @cluster, url: namespace_project_cluster_path(@project.namespace, @project, @cluster), as: :cluster do |field| = form_errors(@cluster) .form-group.append-bottom-20 %label.append-bottom-10 @@ -49,28 +49,28 @@ .form-group = field.submit _('Save'), class: 'btn btn-success' - %section.settings#js-cluster-details + %section.settings.no-animate#js-cluster-details{ class: ('expanded' if expanded) } .settings-header %h4= s_('ClusterIntegration|Cluster details') %button.btn.js-settings-toggle = expanded ? 'Collapse' : 'Expand' %p= s_('ClusterIntegration|See and edit the details for your cluster') - .settings-content.no-animate{ class: ('expanded' if expanded) } + .settings-content .form_group.append-bottom-20 %label.append-bottom-10{ for: 'cluter-name' } = s_('ClusterIntegration|Cluster name') .input-group - %input.form-control.cluster-name{ value: @cluster.gcp_cluster_name, disabled: true } + %input.form-control.cluster-name{ value: @cluster.name, disabled: true } %span.input-group-addon.clipboard-addon - = clipboard_button(text: @cluster.gcp_cluster_name, title: s_('ClusterIntegration|Copy cluster name')) + = clipboard_button(text: @cluster.name, title: s_('ClusterIntegration|Copy cluster name')) - %section.settings#js-cluster-advanced-settings + %section.settings.no-animate#js-cluster-advanced-settings{ class: ('expanded' if expanded) } .settings-header %h4= _('Advanced settings') %button.btn.js-settings-toggle = expanded ? 'Collapse' : 'Expand' %p= s_('ClusterIntegration|Manage Cluster integration on your GitLab project') - .settings-content.no-animate{ class: ('expanded' if expanded) } + .settings-content = render 'advanced_settings' diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml index 45985a5ecef..e75ae87e771 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -1,5 +1,5 @@ - expanded = Rails.env.test? -%section.settings +%section.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 Deploy Keys @@ -7,7 +7,7 @@ = expanded ? 'Collapse' : 'Expand' %p Deploy keys allow read-only or read-write (if enabled) access to your repository. Deploy keys can be used for CI, staging or production servers. You can create a deploy key or add an existing one. - .settings-content.no-animate{ class: ('expanded' if expanded) } + .settings-content %h5.prepend-top-0 Create a new deploy key for this project = render @deploy_keys.form_partial_path diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 893e536e289..5ebeae5c35f 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -4,7 +4,7 @@ - expanded = Rails.env.test? .project-edit-container - %section.settings.general-settings + %section.settings.general-settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 General project settings @@ -12,7 +12,7 @@ = expanded ? 'Collapse' : 'Expand' %p Update your project name, description, avatar, and other general settings. - .settings-content.no-animate{ class: ('expanded' if expanded) } + .settings-content .project-edit-errors = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "edit-project" }, authenticity_token: true do |f| %fieldset @@ -61,7 +61,7 @@ = link_to 'Remove avatar', project_avatar_path(@project), data: { confirm: "Project avatar will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-sm remove-avatar" = f.submit 'Save changes', class: "btn btn-save" - %section.settings.sharing-permissions + %section.settings.sharing-permissions.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 Permissions @@ -69,13 +69,13 @@ = expanded ? 'Collapse' : 'Expand' %p Enable or disable certain project features and choose access levels. - .settings-content.no-animate{ class: ('expanded' if expanded) } + .settings-content = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f| %script.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data(@project) .js-project-permissions-form = f.submit 'Save changes', class: "btn btn-save" - %section.settings.merge-requests-feature{ class: ("hidden" if @project.project_feature.send(:merge_requests_access_level) == 0) } + %section.settings.merge-requests-feature.no-animate{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] } .settings-header %h4 Merge request settings @@ -83,14 +83,14 @@ = expanded ? 'Collapse' : 'Expand' %p Customize your merge request restrictions. - .settings-content.no-animate{ class: ('expanded' if expanded) } + .settings-content = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "merge-request-settings-form" }, authenticity_token: true do |f| = render 'merge_request_settings', form: f = f.submit 'Save changes', class: "btn btn-save" = render 'export', project: @project - %section.settings.advanced-settings + %section.settings.advanced-settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 Advanced settings @@ -98,7 +98,7 @@ = expanded ? 'Collapse' : 'Expand' %p Perform advanced options such as housekeeping, archiving, renaming, transferring, or removing your project. - .settings-content.no-animate{ class: ('expanded' if expanded) } + .settings-content .sub-section %h4 Housekeeping %p @@ -173,7 +173,10 @@ %p This will remove the fork relationship to source project = succeed "." do - = link_to @project.forked_from_project.name_with_namespace, project_path(@project.forked_from_project) + - if @project.fork_source + = link_to(fork_source_name(@project), project_path(@project.fork_source)) + - else + = fork_source_name(@project) = form_for([@project.namespace.becomes(Namespace), @project], url: remove_fork_project_path(@project), method: :delete, remote: true, html: { class: 'transfer-project' }) do |f| %p %strong Once removed, the fork relationship cannot be restored and you will no longer be able to send merge requests to the source. diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml index 70156c03e3c..cce16bc58b3 100644 --- a/app/views/projects/graphs/show.html.haml +++ b/app/views/projects/graphs/show.html.haml @@ -1,5 +1,5 @@ - @no_container = true -- page_title "Contributors" +- page_title _('Contributors') - content_for :page_specific_javascripts do = webpack_bundle_tag('common_d3') = webpack_bundle_tag('graphs') @@ -7,23 +7,23 @@ .js-graphs-show{ class: container_class, 'data-project-graph-path': project_graph_path(@project, current_ref, format: :json) } .sub-header-block - .tree-ref-holder + .tree-ref-holder.inline.vertical-align-middle = render 'shared/ref_switcher', destination: 'graphs' - %ul.breadcrumb.repo-breadcrumb - = commits_breadcrumbs + = link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn' .loading-graph .center %h3.page-title %i.fa.fa-spinner.fa-spin - Building repository graph. - %p.slead Please wait a moment, this page will automatically refresh when ready. + = s_('ContributorsPage|Building repository graph.') + %p.slead + = s_('ContributorsPage|Please wait a moment, this page will automatically refresh when ready.') .stat-graph.hide .header.clearfix %h3#date_header.page-title %p.light - Commits to #{@ref}, excluding merge commits. Limited to 6,000 commits. + = s_('ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits.') % { branch_name: @ref } %input#brush_change{ :type => "hidden" } .graphs.row #contributors-master diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml index 05b06cfc8b2..8096d9530c3 100644 --- a/app/views/projects/hook_logs/_index.html.haml +++ b/app/views/projects/hook_logs/_index.html.haml @@ -24,7 +24,7 @@ %td = truncate(hook_log.url, length: 50) %td.light - #{number_with_precision(hook_log.execution_duration, precision: 2)} ms + #{number_with_precision(hook_log.execution_duration, precision: 2)} sec %td.light = time_ago_with_tooltip(hook_log.created_at) %td diff --git a/app/views/projects/issues/edit.html.haml b/app/views/projects/issues/edit.html.haml new file mode 100644 index 00000000000..1b7d878c38c --- /dev/null +++ b/app/views/projects/issues/edit.html.haml @@ -0,0 +1,7 @@ +- page_title "Edit", "#{@issue.title} (#{@issue.to_reference})", "Issues" + +%h3.page-title + Edit Issue ##{@issue.iid} +%hr + += render "form" diff --git a/app/views/projects/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml index 7da4ffd5e43..b5067367802 100644 --- a/app/views/projects/jobs/_sidebar.html.haml +++ b/app/views/projects/jobs/_sidebar.html.haml @@ -91,7 +91,7 @@ - builds.select{|build| build.status == build_status}.each do |build| .build-job{ class: sidebar_build_class(build, @build), data: { stage: build.stage } } = link_to project_job_path(@project, build) do - = icon('arrow-right') + = sprite_icon('arrow-right', size:16, css_class: 'icon-arrow-right') %span{ class: "ci-status-icon-#{build.status}" } = ci_icon_for_status(build.status) %span @@ -100,4 +100,5 @@ - else = build.id - if build.retried? - %i.fa.fa-refresh.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' } + %span.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' } + = sprite_icon('retry', size:16, css_class: 'icon-retry') diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index cb723fe6a18..72d5c4961ec 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -34,7 +34,7 @@ %li{ class: [merge_request_button_visibility(@merge_request, true), 'js-close-item'] } = link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request' %li{ class: merge_request_button_visibility(@merge_request, false) } - = link_to 'Reopen', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request' + = link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request' - if can_update_merge_request = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "hidden-xs hidden-sm btn btn-grouped issuable-edit" diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index a5153df1159..9fc297ab7f6 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -23,14 +23,18 @@ = milestone_date_range(@milestone) .milestone-buttons - if can?(current_user, :admin_milestone, @project) + = link_to edit_project_milestone_path(@project, @milestone), class: "btn btn-grouped btn-nr" do + Edit + + - if @project.group + = link_to promote_project_milestone_path(@milestone.project, @milestone), title: "Promote to Group Milestone", class: 'btn btn-grouped', data: { confirm: "Promoting this milestone will make it available for all projects inside the group. Existing project milestones with the same name will be merged. Are you sure?", toggle: "tooltip" }, method: :post do + Promote + - if @milestone.active? = link_to 'Close milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped" - else = link_to 'Reopen milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped" - = link_to edit_project_milestone_path(@project, @milestone), class: "btn btn-grouped btn-nr" do - Edit - = link_to project_milestone_path(@project, @milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-grouped btn-danger" do Delete @@ -40,6 +44,7 @@ .detail-page-description.milestone-detail %h2.title = markdown_field(@milestone, :title) + %div - if @milestone.description.present? .description diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml index 6a47cbdf724..ba7d98228c3 100644 --- a/app/views/projects/protected_branches/shared/_index.html.haml +++ b/app/views/projects/protected_branches/shared/_index.html.haml @@ -1,6 +1,6 @@ - expanded = Rails.env.test? -%section.settings +%section.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 Protected Branches @@ -8,7 +8,7 @@ = expanded ? 'Collapse' : 'Expand' %p Keep stable branches secure and force developers to use merge requests. - .settings-content.no-animate{ class: ('expanded' if expanded) } + .settings-content %p By default, protected branches are designed to: %ul diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml index c07bd454ff6..e764a37bbd7 100644 --- a/app/views/projects/protected_tags/shared/_index.html.haml +++ b/app/views/projects/protected_tags/shared/_index.html.haml @@ -1,6 +1,6 @@ - expanded = Rails.env.test? -%section.settings +%section.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 Protected Tags @@ -8,7 +8,7 @@ = expanded ? 'Collapse' : 'Expand' %p Limit access to creating and updating tags. - .settings-content.no-animate{ class: ('expanded' if expanded) } + .settings-content %p By default, protected tags are designed to: %ul diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 62455d0d40d..664a4554692 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -4,7 +4,7 @@ - expanded = Rails.env.test? -%section.settings#js-general-pipeline-settings +%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 General pipelines settings @@ -12,10 +12,10 @@ = expanded ? 'Collapse' : 'Expand' %p Update your CI/CD configuration, like job timeout or Auto DevOps. - .settings-content.no-animate{ class: ('expanded' if expanded) } + .settings-content = render 'projects/pipelines_settings/show' -%section.settings +%section.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 Runners settings @@ -23,10 +23,10 @@ = expanded ? 'Collapse' : 'Expand' %p Register and see your runners for this project. - .settings-content.no-animate{ class: ('expanded' if expanded) } + .settings-content = render 'projects/runners/index' -%section.settings +%section.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 Secret variables @@ -35,10 +35,10 @@ = expanded ? 'Collapse' : 'Expand' %p = render "ci/variables/content" - .settings-content.no-animate{ class: ('expanded' if expanded) } + .settings-content = render 'ci/variables/index' -%section.settings +%section.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 Pipeline triggers @@ -48,5 +48,5 @@ Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will impersonate their associated user including their access to projects and their project permissions. - .settings-content.no-animate{ class: ('expanded' if expanded) } + .settings-content = render 'projects/triggers/index' diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 7ea19e6c828..c02f7ee37ed 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -2,14 +2,14 @@ .tree-ref-holder = render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true - - if show_new_repo? + - if show_new_repo? && can_push_branch?(@project, @ref) .js-new-dropdown - else = render 'projects/tree/old_tree_header' .tree-controls - if show_new_repo? - = render 'shared/repo/editable_mode' + .editable-mode - else = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml index dff847159d3..901a177323b 100644 --- a/app/views/shared/_mini_pipeline_graph.html.haml +++ b/app/views/shared/_mini_pipeline_graph.html.haml @@ -7,7 +7,7 @@ .stage-container.dropdown{ class: klass } %button.mini-pipeline-graph-dropdown-toggle.has-tooltip.js-builds-dropdown-button{ class: "ci-status-icon-#{detailed_status.group}", type: 'button', data: { toggle: 'dropdown', title: "#{stage.name}: #{detailed_status.label}", placement: 'top', "stage-endpoint" => stage_project_pipeline_path(pipeline.project, pipeline, stage: stage.name) } } - = custom_icon(icon_status) + = sprite_icon(icon_status) = icon('caret-down') %ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container diff --git a/app/views/shared/hook_logs/_content.html.haml b/app/views/shared/hook_logs/_content.html.haml index af6a499fadb..c80b179d525 100644 --- a/app/views/shared/hook_logs/_content.html.haml +++ b/app/views/shared/hook_logs/_content.html.haml @@ -11,7 +11,7 @@ = hook_log.trigger.singularize.titleize %p %strong Elapsed time: - #{number_with_precision(hook_log.execution_duration, precision: 2)} ms + #{number_with_precision(hook_log.execution_duration, precision: 2)} sec %p %strong Request time: = time_ago_with_tooltip(hook_log.created_at) diff --git a/app/views/shared/icons/_icon_autodevops.svg b/app/views/shared/icons/_icon_autodevops.svg index dde84e14048..423ca6d760d 100644 --- a/app/views/shared/icons/_icon_autodevops.svg +++ b/app/views/shared/icons/_icon_autodevops.svg @@ -29,7 +29,7 @@ </g> <g fill-rule="nonzero" transform="rotate(15 -315.035 277.714)"> <path fill="#FFFFFF" d="M12.275,10.57 C13.986216,9.15630755 15.921048,8.03765363 18,7.26 L18,5.5 C18,2.463 20.47,0 23.493,0 L26.507,0 C27.9648848,0.000530018716 29.3628038,0.580386367 30.3930274,1.61192286 C31.4232511,2.64345935 32.0013267,4.04211574 32,5.5 L32,7.26 C34.098,8.043 36.03,9.17 37.725,10.57 L39.253,9.688 C41.8816141,8.17268496 45.2407537,9.07039379 46.763,11.695 L48.27,14.305 C48.9984289,15.5678669 49.1951495,17.0684426 48.8168566,18.4763972 C48.4385638,19.8843518 47.5162683,21.0842673 46.253,21.812 L44.728,22.693 C44.907,23.769 45,24.873 45,26 C45,27.127 44.907,28.231 44.728,29.307 L46.253,30.187 C48.8800379,31.705769 49.7822744,35.0642181 48.27,37.695 L46.763,40.305 C46.0335844,41.5673849 44.8323832,42.4881439 43.4238487,42.8645658 C42.0153143,43.2409877 40.5149245,43.0422119 39.253,42.312 L37.725,41.43 C36.013784,42.8436924 34.078952,43.9623464 32,44.74 L32,46.5 C32,49.537 29.53,52 26.507,52 L23.493,52 C22.0351152,51.99947 20.6371962,51.4196136 19.6069726,50.3880771 C18.5767489,49.3565406 17.9986733,47.9578843 18,46.5 L18,44.74 C15.921048,43.9623464 13.986216,42.8436924 12.275,41.43 L10.747,42.312 C8.11838594,43.827315 4.75924629,42.9296062 3.237,40.305 L1.73,37.695 C1.00157113,36.4321331 0.804850523,34.9315574 1.18314337,33.5236028 C1.56143621,32.1156482 2.48373172,30.9157327 3.747,30.188 L5.272,29.307 C5.09051204,28.2140265 4.9995366,27.107939 5,26 C5,24.873 5.093,23.769 5.272,22.693 L3.747,21.813 C1.11996213,20.294231 0.217725591,16.9357819 1.73,14.305 L3.237,11.695 C3.96641559,10.4326151 5.16761682,9.51185609 6.57615125,9.13543417 C7.98468568,8.75901226 9.48507553,8.95778814 10.747,9.688 L12.275,10.57 Z"/> - <path class="animated spin infinite" fill="#E1DBF1" d="M17.9996486,7.25963195 L18.0000013,5.49772675 C18.0034459,2.46713881 20.4561478,0.00952173148 23.493,0 L26.507,0 C29.542757,0 32,2.46161709 32,5.5 L32,7.25850184 C34.0799663,8.03664754 36.0149544,9.15559094 37.7260175,10.5694605 L39.2547869,9.68691874 C41.8812087,8.17416302 45.2363972,9.06948854 46.7630175,11.6949424 L48.270687,14.3061027 C48.9989901,15.569417 49.1952874,17.0704122 48.816349,18.4785295 C48.4374106,19.8866468 47.5143145,21.0864021 46.2530682,21.8120114 L44.7278655,22.6926677 C44.9091017,23.7802451 45,24.8850821 45,26 C45,27.1144218 44.9091826,28.218078 44.7278653,29.3073326 L46.2547984,30.1889888 C48.8778516,31.7070439 49.7801588,35.0599752 48.2700175,37.6950576 L46.7625317,40.3058986 C46.0327098,41.5684739 44.8309328,42.4891542 43.4219037,42.8651509 C42.0128746,43.2411475 40.512172,43.0416186 39.2533538,42.312255 L37.7244858,41.4299789 C36.013753,42.8435912 34.0794396,43.9622923 32.0003514,44.7403681 L31.9999987,46.5022733 C31.9965541,49.5328612 29.5438522,51.9904783 26.507,52 L23.493,52 C20.457243,52 18,49.5383829 18,46.5 L18,44.7414988 C15.9200337,43.9633525 13.9850456,42.8444091 12.2739825,41.4305395 L10.7452131,42.3130813 C8.11879127,43.825837 4.76360277,42.9305115 3.23698247,40.3050576 L1.72931303,37.6938973 C1.0010099,36.430583 0.804712603,34.9295878 1.18365098,33.5214705 C1.56258936,32.1133532 2.48568546,30.9135979 3.74693178,30.1879886 L5.27213454,29.3073323 C5.09089825,28.2197549 5,27.114918 5.00000019,26.0008761 C4.99951488,24.8930059 5.0904571,23.7869854 5.27213502,22.6926675 L3.74520157,21.8110112 C1.12214836,20.2929561 0.219841192,16.9400248 1.72998247,14.3049424 L3.23746831,11.6941014 C3.96729024,10.4315261 5.16906725,9.51084579 6.5780963,9.13484913 C7.98712536,8.75885247 9.48782803,8.95838137 10.7466462,9.687745 L12.2748018,10.56961 C14.0209791,9.13635584 15.9392199,8.03072455 17.9996486,7.25963195 Z M13.7518374,14.537862 C13.108069,15.069723 12.2016163,15.1456339 11.4783538,14.728255 L8.74433999,13.1505123 C8.40103903,12.9516035 7.99274958,12.8973186 7.60940137,12.9996143 C7.22605315,13.10191 6.89909107,13.3523954 6.70101753,13.6950576 L5.19724591,16.2994454 C4.78547321,17.0179634 5.03203388,17.9341714 5.74706822,18.3479886 L8.47306822,19.9219886 C9.19530115,20.3390079 9.58295216,21.1604138 9.44574883,21.983032 L9.21798321,23.3486236 C9.07251948,24.2246212 8.99961081,25.111131 9,26 C9,26.8953847 9.0728258,27.7804297 9.21774883,28.649968 L9.44574883,30.016968 C9.58295216,30.8395862 9.19530115,31.6609921 8.47306822,32.0780114 L5.74435077,33.6535776 C5.40046982,33.851417 5.14932721,34.1778291 5.04623114,34.5609292 C4.94313508,34.9440294 4.9965408,35.3523984 5.19401753,35.6949424 L6.69795587,38.2996585 C7.11427713,39.0156351 8.03110189,39.260288 8.7470791,38.8479035 L11.4770791,37.2719035 C12.200376,36.8543519 13.1069795,36.9302031 13.7508374,37.462138 L14.8210499,38.3463136 C16.1898549,39.4774943 17.737648,40.3725891 19.3990866,40.9941596 L20.6990866,41.4791596 C21.4813437,41.7710017 22,42.5180761 22,43.353 L22,46.5 C22,47.3308348 22.6679761,48 23.493,48 L26.5007228,48.0000099 C27.328845,47.9974107 27.99906,47.3258525 28,46.5 L28,43.353 C28,42.5185702 28.5180515,41.771829 29.2996486,41.4796319 L30.599003,40.9938734 C32.261836,40.3715765 33.8093225,39.4764853 35.1790197,38.3444304 L36.2490197,37.4614304 C36.8927697,36.9301861 37.798736,36.8545694 38.5216462,37.271745 L41.25566,38.8494877 C41.598961,39.0483965 42.0072504,39.1026814 42.3905986,39.0003857 C42.7739468,38.89809 43.1009089,38.6476046 43.2989825,38.3049424 L44.8027541,35.7005546 C45.2145268,34.9820366 44.9679661,34.0658286 44.2529318,33.6520114 L41.5269318,32.0780114 C40.8046988,31.6609921 40.4170478,30.8395862 40.5542512,30.016968 L40.7821577,28.6505288 C40.9272286,27.7792134 41,26.8950523 41,26 C41,25.1046153 40.9271742,24.2195703 40.7822512,23.350032 L40.5542512,21.983032 C40.4170478,21.1604138 40.8046988,20.3390079 41.5269318,19.9219886 L44.2556492,18.3464224 C44.5995302,18.148583 44.8506728,17.8221709 44.9537689,17.4390708 C45.0568649,17.0559706 45.0034592,16.6476016 44.8059825,16.3050576 L43.3020441,13.7003415 C42.8857229,12.9843649 41.9688981,12.739712 41.2529209,13.1520965 L38.5229209,14.7280965 C37.799624,15.1456481 36.8930205,15.0697969 36.2491626,14.537862 L35.1789501,13.6536864 C33.8101451,12.5225057 32.262352,11.6274109 30.6021792,11.0063122 L29.3021792,10.5223122 C28.5192618,10.230826 28,9.48341836 28,8.648 L28,5.5 C28,4.66916515 27.3320239,4 26.507,4 L23.4992772,3.99999015 C22.671155,4.00258933 22.00094,4.67414748 22,5.5 L22,8.647 C22,9.48142977 21.4819485,10.228171 20.7003514,10.5203681 L19.400997,11.0061266 C17.738164,11.6284235 16.1906775,12.5235147 14.822142,13.6546103 C14.8121128,13.6628994 14.4553446,13.9573166 13.7518374,14.537862 Z"/> + <path class="animated spin-cw infinite" fill="#E1DBF1" d="M17.9996486,7.25963195 L18.0000013,5.49772675 C18.0034459,2.46713881 20.4561478,0.00952173148 23.493,0 L26.507,0 C29.542757,0 32,2.46161709 32,5.5 L32,7.25850184 C34.0799663,8.03664754 36.0149544,9.15559094 37.7260175,10.5694605 L39.2547869,9.68691874 C41.8812087,8.17416302 45.2363972,9.06948854 46.7630175,11.6949424 L48.270687,14.3061027 C48.9989901,15.569417 49.1952874,17.0704122 48.816349,18.4785295 C48.4374106,19.8866468 47.5143145,21.0864021 46.2530682,21.8120114 L44.7278655,22.6926677 C44.9091017,23.7802451 45,24.8850821 45,26 C45,27.1144218 44.9091826,28.218078 44.7278653,29.3073326 L46.2547984,30.1889888 C48.8778516,31.7070439 49.7801588,35.0599752 48.2700175,37.6950576 L46.7625317,40.3058986 C46.0327098,41.5684739 44.8309328,42.4891542 43.4219037,42.8651509 C42.0128746,43.2411475 40.512172,43.0416186 39.2533538,42.312255 L37.7244858,41.4299789 C36.013753,42.8435912 34.0794396,43.9622923 32.0003514,44.7403681 L31.9999987,46.5022733 C31.9965541,49.5328612 29.5438522,51.9904783 26.507,52 L23.493,52 C20.457243,52 18,49.5383829 18,46.5 L18,44.7414988 C15.9200337,43.9633525 13.9850456,42.8444091 12.2739825,41.4305395 L10.7452131,42.3130813 C8.11879127,43.825837 4.76360277,42.9305115 3.23698247,40.3050576 L1.72931303,37.6938973 C1.0010099,36.430583 0.804712603,34.9295878 1.18365098,33.5214705 C1.56258936,32.1133532 2.48568546,30.9135979 3.74693178,30.1879886 L5.27213454,29.3073323 C5.09089825,28.2197549 5,27.114918 5.00000019,26.0008761 C4.99951488,24.8930059 5.0904571,23.7869854 5.27213502,22.6926675 L3.74520157,21.8110112 C1.12214836,20.2929561 0.219841192,16.9400248 1.72998247,14.3049424 L3.23746831,11.6941014 C3.96729024,10.4315261 5.16906725,9.51084579 6.5780963,9.13484913 C7.98712536,8.75885247 9.48782803,8.95838137 10.7466462,9.687745 L12.2748018,10.56961 C14.0209791,9.13635584 15.9392199,8.03072455 17.9996486,7.25963195 Z M13.7518374,14.537862 C13.108069,15.069723 12.2016163,15.1456339 11.4783538,14.728255 L8.74433999,13.1505123 C8.40103903,12.9516035 7.99274958,12.8973186 7.60940137,12.9996143 C7.22605315,13.10191 6.89909107,13.3523954 6.70101753,13.6950576 L5.19724591,16.2994454 C4.78547321,17.0179634 5.03203388,17.9341714 5.74706822,18.3479886 L8.47306822,19.9219886 C9.19530115,20.3390079 9.58295216,21.1604138 9.44574883,21.983032 L9.21798321,23.3486236 C9.07251948,24.2246212 8.99961081,25.111131 9,26 C9,26.8953847 9.0728258,27.7804297 9.21774883,28.649968 L9.44574883,30.016968 C9.58295216,30.8395862 9.19530115,31.6609921 8.47306822,32.0780114 L5.74435077,33.6535776 C5.40046982,33.851417 5.14932721,34.1778291 5.04623114,34.5609292 C4.94313508,34.9440294 4.9965408,35.3523984 5.19401753,35.6949424 L6.69795587,38.2996585 C7.11427713,39.0156351 8.03110189,39.260288 8.7470791,38.8479035 L11.4770791,37.2719035 C12.200376,36.8543519 13.1069795,36.9302031 13.7508374,37.462138 L14.8210499,38.3463136 C16.1898549,39.4774943 17.737648,40.3725891 19.3990866,40.9941596 L20.6990866,41.4791596 C21.4813437,41.7710017 22,42.5180761 22,43.353 L22,46.5 C22,47.3308348 22.6679761,48 23.493,48 L26.5007228,48.0000099 C27.328845,47.9974107 27.99906,47.3258525 28,46.5 L28,43.353 C28,42.5185702 28.5180515,41.771829 29.2996486,41.4796319 L30.599003,40.9938734 C32.261836,40.3715765 33.8093225,39.4764853 35.1790197,38.3444304 L36.2490197,37.4614304 C36.8927697,36.9301861 37.798736,36.8545694 38.5216462,37.271745 L41.25566,38.8494877 C41.598961,39.0483965 42.0072504,39.1026814 42.3905986,39.0003857 C42.7739468,38.89809 43.1009089,38.6476046 43.2989825,38.3049424 L44.8027541,35.7005546 C45.2145268,34.9820366 44.9679661,34.0658286 44.2529318,33.6520114 L41.5269318,32.0780114 C40.8046988,31.6609921 40.4170478,30.8395862 40.5542512,30.016968 L40.7821577,28.6505288 C40.9272286,27.7792134 41,26.8950523 41,26 C41,25.1046153 40.9271742,24.2195703 40.7822512,23.350032 L40.5542512,21.983032 C40.4170478,21.1604138 40.8046988,20.3390079 41.5269318,19.9219886 L44.2556492,18.3464224 C44.5995302,18.148583 44.8506728,17.8221709 44.9537689,17.4390708 C45.0568649,17.0559706 45.0034592,16.6476016 44.8059825,16.3050576 L43.3020441,13.7003415 C42.8857229,12.9843649 41.9688981,12.739712 41.2529209,13.1520965 L38.5229209,14.7280965 C37.799624,15.1456481 36.8930205,15.0697969 36.2491626,14.537862 L35.1789501,13.6536864 C33.8101451,12.5225057 32.262352,11.6274109 30.6021792,11.0063122 L29.3021792,10.5223122 C28.5192618,10.230826 28,9.48341836 28,8.648 L28,5.5 C28,4.66916515 27.3320239,4 26.507,4 L23.4992772,3.99999015 C22.671155,4.00258933 22.00094,4.67414748 22,5.5 L22,8.647 C22,9.48142977 21.4819485,10.228171 20.7003514,10.5203681 L19.400997,11.0061266 C17.738164,11.6284235 16.1906775,12.5235147 14.822142,13.6546103 C14.8121128,13.6628994 14.4553446,13.9573166 13.7518374,14.537862 Z"/> <g transform="rotate(15 -59.137 82.348)"> <circle cx="8" cy="8" r="8" fill="#FFFFFF" transform="translate(.035 6.008)"/> <path fill="#6B4FBB" d="M7.40192379,14.7679492 C2.98364579,14.7679492 -0.598076211,11.1862272 -0.598076211,6.76794919 C-0.598076211,2.34967119 2.98364579,-1.23205081 7.40192379,-1.23205081 C11.8202018,-1.23205081 15.4019238,2.34967119 15.4019238,6.76794919 C15.4019238,11.1862272 11.8202018,14.7679492 7.40192379,14.7679492 Z M7.40192379,10.7679492 C9.61106279,10.7679492 11.4019238,8.97708819 11.4019238,6.76794919 C11.4019238,4.55881019 9.61106279,2.76794919 7.40192379,2.76794919 C5.19278479,2.76794919 3.40192379,4.55881019 3.40192379,6.76794919 C3.40192379,8.97708819 5.19278479,10.7679492 7.40192379,10.7679492 Z"/> @@ -37,7 +37,7 @@ </g> <g fill-rule="nonzero" transform="rotate(15 -402.968 460.884)"> <path fill="#FFFFFF" d="M9.82,8.53730769 C11.1889728,7.39547918 12.7368384,6.49195101 14.4,5.86384615 L14.4,4.44230769 C14.4,1.98934615 16.376,0 18.7944,0 L21.2056,0 C22.3719078,0.00042809204 23.4902431,0.468773604 24.314422,1.30193769 C25.1386009,2.13510179 25.6010613,3.26478579 25.6,4.44230769 L25.6,5.86384615 C27.2784,6.49626923 28.824,7.40653846 30.18,8.53730769 L31.4024,7.82492308 C33.5052912,6.60101478 36.192603,7.32608729 37.4104,9.44596154 L38.616,11.5540385 C39.1987431,12.5740464 39.3561196,13.7860498 39.0534853,14.9232439 C38.750851,16.060438 38.0130146,17.0296006 37.0024,17.6173846 L35.7824,18.3289615 C35.9256,19.1980385 36,20.0897308 36,21 C36,21.9102692 35.9256,22.8019615 35.7824,23.6710385 L37.0024,24.3818077 C39.1040303,25.6085057 39.8258195,28.3210992 38.616,30.4459615 L37.4104,32.5540385 C36.8268675,33.573657 35.8659065,34.317347 34.739079,34.6213801 C33.6122515,34.9254132 32.4119396,34.7648634 31.4024,34.1750769 L30.18,33.4626923 C28.8110272,34.6045208 27.2631616,35.508049 25.6,36.1361538 L25.6,37.5576923 C25.6,40.0106538 23.624,42 21.2056,42 L18.7944,42 C17.6280922,41.9995719 16.5097569,41.5312264 15.685578,40.6980623 C14.8613991,39.8648982 14.3989387,38.7352142 14.4,37.5576923 L14.4,36.1361538 C12.7368384,35.508049 11.1889728,34.6045208 9.82,33.4626923 L8.5976,34.1750769 C6.49470875,35.3989852 3.80739703,34.6739127 2.5896,32.5540385 L1.384,30.4459615 C0.8012569,29.4259536 0.643880418,28.2139502 0.946514692,27.0767561 C1.24914897,25.939562 1.98698538,24.9703994 2.9976,24.3826154 L4.2176,23.6710385 C4.07240963,22.7882521 3.99962928,21.8948738 4,21 C4,20.0897308 4.0744,19.1980385 4.2176,18.3289615 L2.9976,17.6181923 C0.895969702,16.3914943 0.174180473,13.6789008 1.384,11.5540385 L2.5896,9.44596154 C3.17313247,8.42634297 4.13409345,7.682653 5.260921,7.37861991 C6.38774855,7.07458682 7.58806043,7.23513658 8.5976,7.82492308 L9.82,8.53730769 Z"/> - <path class="animated spin infinite" fill="#FEE1D3" d="M14.0000007,5.6038043 L14.0000013,4.44005609 C14.0029906,1.78475013 16.1390906,-0.376211234 18.7944,-0.384615385 L21.2056,-0.384615385 C23.8595941,-0.384615385 26,1.78021801 26,4.44230769 L26,5.60295806 C27.5208716,6.20655954 28.9434678,7.03621848 30.2204219,8.06411282 L31.1970056,7.49492104 C33.4941909,6.15907529 36.4301298,6.95005805 37.7609369,9.26076474 L38.9671983,11.3699991 C39.5988409,12.4761812 39.768854,13.7886936 39.4405746,15.0202941 C39.1116282,16.2543969 38.308799,17.3078735 37.2096539,17.946304 L36.2175721,18.5246428 C36.3390841,19.3401617 36.4,20.1667594 36.4,21 C36.4,21.8329668 36.339124,22.6588262 36.2175401,23.4753391 L37.2113882,24.0547082 C39.4944154,25.3886826 40.276605,28.3232105 38.9665369,30.6311583 L37.7604568,32.7400742 C37.1252608,33.8495148 36.0768547,34.6604208 34.8452776,34.9922248 C33.6111681,35.324711 32.2964469,35.1482289 31.195569,34.5042428 L30.2192355,33.9354047 C28.9426535,34.9630196 27.5206806,35.7924453 25.9999993,36.3961957 L25.9999987,37.5599439 C25.9970094,40.2152499 23.8609094,42.3762112 21.2056,42.3846154 L18.7944,42.3846154 C16.1404059,42.3846154 14,40.219782 14,37.5576923 L14,36.3970419 C12.4791284,35.7934405 11.0565322,34.9637815 9.77957815,33.9358872 L8.80299442,34.505079 C6.50580915,35.8409247 3.56987021,35.049942 2.23906313,32.7392353 L1.03280169,30.6300009 C0.401159146,29.5238188 0.231145999,28.2113064 0.559425405,26.9797059 C0.888371786,25.7456031 1.69120101,24.6921265 2.79034606,24.053696 L3.78242779,23.4753573 C3.66091587,22.6598457 3.60000002,21.8333228 3.60000019,21.0008678 C3.59964068,20.1722851 3.66061719,19.3449468 3.78254167,18.5247085 L2.78861183,17.9452918 C0.505584602,16.6113174 -0.276605002,13.6767895 1.03346313,11.3688417 L2.23954317,9.25992583 C2.87473915,8.15048519 3.92314533,7.33957919 5.15472238,7.00777521 C6.38883187,6.67528896 7.70355311,6.85177112 8.80443097,7.49575721 L9.78076186,8.06459377 C11.0573465,7.03698045 12.4793194,6.20755475 14.0000007,5.6038043 Z M11.2634746,12.0326234 C10.617233,12.5716613 9.7026973,12.6485026 8.97556903,12.2248582 L6.78774825,10.9501716 C6.60754053,10.8447551 6.39506809,10.8162338 6.19527576,10.8700606 C5.99295099,10.9245697 5.8183659,11.0596053 5.71133687,11.246543 L4.50892658,13.3490215 C4.28085652,13.7508163 4.41776119,14.2644394 4.80485394,14.4906191 L6.98565394,15.7619268 C7.70254629,16.1798426 8.08690703,16.9970357 7.95165511,17.8157512 L7.76948523,18.9184706 C7.65638664,19.6061109 7.59969735,20.3020342 7.6,21 C7.6,21.7031066 7.65662064,22.3978283 7.76925511,23.0801334 L7.95165511,24.1842488 C8.08690703,25.0029643 7.70254629,25.8201574 6.98565394,26.2380732 L4.80213007,27.5109659 C4.61772321,27.6180778 4.48116147,27.7972748 4.42448029,28.0099246 C4.36713215,28.2250767 4.39688141,28.454743 4.50573687,28.6453801 L5.70831165,30.7481858 C5.93243371,31.1373303 6.41410538,31.2670993 6.79049373,31.0482253 L8.97449373,29.7753023 C9.7016554,29.3514832 10.6163433,29.4282639 11.2626746,29.9673766 L12.1188867,30.6815536 C13.1796505,31.566598 14.3786867,32.2666727 15.6649769,32.7525215 L16.7049769,33.1442523 C17.4841581,33.4377419 18,34.1832625 18,35.0158846 L18,37.5576923 C18,38.02074 18.3597694,38.3846154 18.7944,38.3846154 L21.1992624,38.3846254 C21.6372484,38.3832375 21.9994819,38.0167881 22,37.5576923 L22,35.0158846 C22,34.18376 22.5152346,33.4385758 23.2937506,33.1447321 L24.3331012,32.7524389 C25.620867,32.2658727 26.8196661,31.5658006 27.8813806,30.679856 L28.7373806,29.9666637 C29.3836087,29.4282468 30.2976553,29.3517028 31.024431,29.7751418 L33.2122517,31.0498284 C33.3924595,31.1552449 33.6049319,31.1837662 33.8047242,31.1299394 C34.007049,31.0754303 34.1816341,30.9403947 34.2886631,30.753457 L35.4910734,28.6509785 C35.7191435,28.2491837 35.5822388,27.7355606 35.1951461,27.5093809 L33.0143461,26.2380732 C32.2974537,25.8201574 31.913093,25.0029643 32.0483449,24.1842488 L32.2306531,23.0806893 C32.3434217,22.3968737 32.4,21.7028459 32.4,21 C32.4,20.2968934 32.3433794,19.6021717 32.2307449,18.9198666 L32.0483449,17.8157512 C31.913093,16.9970357 32.2974537,16.1798426 33.0143461,15.7619268 L35.1978699,14.4890341 C35.3822768,14.3819222 35.5188385,14.2027252 35.5755197,13.9900754 C35.6328679,13.7749233 35.6031186,13.545257 35.4942631,13.3546199 L34.2916883,11.2518142 C34.0675663,10.8626697 33.5858946,10.7329007 33.2095063,10.9517747 L31.0255063,12.2246977 C30.2983446,12.6485168 29.3836567,12.5717361 28.7373254,12.0326234 L27.8811133,11.3184464 C26.8203495,10.433402 25.6213133,9.73332732 24.3362966,9.24795765 L23.2962966,8.85703457 C22.5164499,8.56389992 22,7.81804293 22,6.98492308 L22,4.44230769 C22,3.97925995 21.6402306,3.61538462 21.2056,3.61538462 L18.8007376,3.61537457 C18.3627516,3.61676247 18.0005181,3.98321188 18,4.44230769 L18,6.98411538 C18,7.81623999 17.4847654,8.56142419 16.7062494,8.85526793 L15.6668988,9.24756113 C14.379133,9.73412728 13.1803339,10.4341994 12.1197785,11.3191775 C12.1108094,11.3266617 11.8253748,11.564477 11.2634746,12.0326234 Z"/> + <path class="animated spin-ccw infinite" fill="#FEE1D3" d="M14.0000007,5.6038043 L14.0000013,4.44005609 C14.0029906,1.78475013 16.1390906,-0.376211234 18.7944,-0.384615385 L21.2056,-0.384615385 C23.8595941,-0.384615385 26,1.78021801 26,4.44230769 L26,5.60295806 C27.5208716,6.20655954 28.9434678,7.03621848 30.2204219,8.06411282 L31.1970056,7.49492104 C33.4941909,6.15907529 36.4301298,6.95005805 37.7609369,9.26076474 L38.9671983,11.3699991 C39.5988409,12.4761812 39.768854,13.7886936 39.4405746,15.0202941 C39.1116282,16.2543969 38.308799,17.3078735 37.2096539,17.946304 L36.2175721,18.5246428 C36.3390841,19.3401617 36.4,20.1667594 36.4,21 C36.4,21.8329668 36.339124,22.6588262 36.2175401,23.4753391 L37.2113882,24.0547082 C39.4944154,25.3886826 40.276605,28.3232105 38.9665369,30.6311583 L37.7604568,32.7400742 C37.1252608,33.8495148 36.0768547,34.6604208 34.8452776,34.9922248 C33.6111681,35.324711 32.2964469,35.1482289 31.195569,34.5042428 L30.2192355,33.9354047 C28.9426535,34.9630196 27.5206806,35.7924453 25.9999993,36.3961957 L25.9999987,37.5599439 C25.9970094,40.2152499 23.8609094,42.3762112 21.2056,42.3846154 L18.7944,42.3846154 C16.1404059,42.3846154 14,40.219782 14,37.5576923 L14,36.3970419 C12.4791284,35.7934405 11.0565322,34.9637815 9.77957815,33.9358872 L8.80299442,34.505079 C6.50580915,35.8409247 3.56987021,35.049942 2.23906313,32.7392353 L1.03280169,30.6300009 C0.401159146,29.5238188 0.231145999,28.2113064 0.559425405,26.9797059 C0.888371786,25.7456031 1.69120101,24.6921265 2.79034606,24.053696 L3.78242779,23.4753573 C3.66091587,22.6598457 3.60000002,21.8333228 3.60000019,21.0008678 C3.59964068,20.1722851 3.66061719,19.3449468 3.78254167,18.5247085 L2.78861183,17.9452918 C0.505584602,16.6113174 -0.276605002,13.6767895 1.03346313,11.3688417 L2.23954317,9.25992583 C2.87473915,8.15048519 3.92314533,7.33957919 5.15472238,7.00777521 C6.38883187,6.67528896 7.70355311,6.85177112 8.80443097,7.49575721 L9.78076186,8.06459377 C11.0573465,7.03698045 12.4793194,6.20755475 14.0000007,5.6038043 Z M11.2634746,12.0326234 C10.617233,12.5716613 9.7026973,12.6485026 8.97556903,12.2248582 L6.78774825,10.9501716 C6.60754053,10.8447551 6.39506809,10.8162338 6.19527576,10.8700606 C5.99295099,10.9245697 5.8183659,11.0596053 5.71133687,11.246543 L4.50892658,13.3490215 C4.28085652,13.7508163 4.41776119,14.2644394 4.80485394,14.4906191 L6.98565394,15.7619268 C7.70254629,16.1798426 8.08690703,16.9970357 7.95165511,17.8157512 L7.76948523,18.9184706 C7.65638664,19.6061109 7.59969735,20.3020342 7.6,21 C7.6,21.7031066 7.65662064,22.3978283 7.76925511,23.0801334 L7.95165511,24.1842488 C8.08690703,25.0029643 7.70254629,25.8201574 6.98565394,26.2380732 L4.80213007,27.5109659 C4.61772321,27.6180778 4.48116147,27.7972748 4.42448029,28.0099246 C4.36713215,28.2250767 4.39688141,28.454743 4.50573687,28.6453801 L5.70831165,30.7481858 C5.93243371,31.1373303 6.41410538,31.2670993 6.79049373,31.0482253 L8.97449373,29.7753023 C9.7016554,29.3514832 10.6163433,29.4282639 11.2626746,29.9673766 L12.1188867,30.6815536 C13.1796505,31.566598 14.3786867,32.2666727 15.6649769,32.7525215 L16.7049769,33.1442523 C17.4841581,33.4377419 18,34.1832625 18,35.0158846 L18,37.5576923 C18,38.02074 18.3597694,38.3846154 18.7944,38.3846154 L21.1992624,38.3846254 C21.6372484,38.3832375 21.9994819,38.0167881 22,37.5576923 L22,35.0158846 C22,34.18376 22.5152346,33.4385758 23.2937506,33.1447321 L24.3331012,32.7524389 C25.620867,32.2658727 26.8196661,31.5658006 27.8813806,30.679856 L28.7373806,29.9666637 C29.3836087,29.4282468 30.2976553,29.3517028 31.024431,29.7751418 L33.2122517,31.0498284 C33.3924595,31.1552449 33.6049319,31.1837662 33.8047242,31.1299394 C34.007049,31.0754303 34.1816341,30.9403947 34.2886631,30.753457 L35.4910734,28.6509785 C35.7191435,28.2491837 35.5822388,27.7355606 35.1951461,27.5093809 L33.0143461,26.2380732 C32.2974537,25.8201574 31.913093,25.0029643 32.0483449,24.1842488 L32.2306531,23.0806893 C32.3434217,22.3968737 32.4,21.7028459 32.4,21 C32.4,20.2968934 32.3433794,19.6021717 32.2307449,18.9198666 L32.0483449,17.8157512 C31.913093,16.9970357 32.2974537,16.1798426 33.0143461,15.7619268 L35.1978699,14.4890341 C35.3822768,14.3819222 35.5188385,14.2027252 35.5755197,13.9900754 C35.6328679,13.7749233 35.6031186,13.545257 35.4942631,13.3546199 L34.2916883,11.2518142 C34.0675663,10.8626697 33.5858946,10.7329007 33.2095063,10.9517747 L31.0255063,12.2246977 C30.2983446,12.6485168 29.3836567,12.5717361 28.7373254,12.0326234 L27.8811133,11.3184464 C26.8203495,10.433402 25.6213133,9.73332732 24.3362966,9.24795765 L23.2962966,8.85703457 C22.5164499,8.56389992 22,7.81804293 22,6.98492308 L22,4.44230769 C22,3.97925995 21.6402306,3.61538462 21.2056,3.61538462 L18.8007376,3.61537457 C18.3627516,3.61676247 18.0005181,3.98321188 18,4.44230769 L18,6.98411538 C18,7.81623999 17.4847654,8.56142419 16.7062494,8.85526793 L15.6668988,9.24756113 C14.379133,9.73412728 13.1803339,10.4341994 12.1197785,11.3191775 C12.1108094,11.3266617 11.8253748,11.564477 11.2634746,12.0326234 Z"/> <g transform="rotate(15 -47.892 66.043)"> <ellipse cx="6.4" cy="6.462" fill="#FFFFFF" rx="6.4" ry="6.462" transform="translate(.028 4.853)"/> <path fill="#FC6D26" d="M5.92153903,11.9125743 C2.3834711,11.9125743 -0.478460969,9.0231237 -0.478460969,5.4664205 C-0.478460969,1.9097173 2.3834711,-0.979733345 5.92153903,-0.979733345 C9.45960696,-0.979733345 12.321539,1.9097173 12.321539,5.4664205 C12.321539,9.0231237 9.45960696,11.9125743 5.92153903,11.9125743 Z M5.92153903,8.71257435 C7.6854047,8.71257435 9.12153903,7.26263103 9.12153903,5.4664205 C9.12153903,3.67020997 7.6854047,2.22026666 5.92153903,2.22026666 C4.15767337,2.22026666 2.72153903,3.67020997 2.72153903,5.4664205 C2.72153903,7.26263103 4.15767337,8.71257435 5.92153903,8.71257435 Z"/> diff --git a/app/views/shared/issuable/_participants.html.haml b/app/views/shared/issuable/_participants.html.haml deleted file mode 100644 index 3f553c9fede..00000000000 --- a/app/views/shared/issuable/_participants.html.haml +++ /dev/null @@ -1,18 +0,0 @@ -- participants_row = 7 -- participants_size = participants.size -- participants_extra = participants_size - participants_row -.block.participants - .sidebar-collapsed-icon - = icon('users') - %span - = participants.count - .title.hide-collapsed - = pluralize participants.count, "participant" - .hide-collapsed.participants-list - - participants.each do |participant| - .participants-author.js-participants-author - = link_to_member(@project, participant, name: false, size: 24, lazy_load: true) - - if participants_extra > 0 - .hide-collapsed.participants-more - %button.btn-transparent.btn-blank.js-participants-more{ type: 'button', data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } } - + #{participants_extra} more diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 7b7411b1e23..e0009a35b9f 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -123,17 +123,10 @@ %script#js-lock-issue-data{ type: "application/json" }= { is_locked: issuable.discussion_locked?, is_editable: can_edit_issuable }.to_json.html_safe #js-lock-entry-point - = render "shared/issuable/participants", participants: issuable.participants(current_user) + .js-sidebar-participants-entry-point + - if current_user - - subscribed = issuable.subscribed?(current_user, @project) - .block.light.subscription{ data: { url: toggle_subscription_path(issuable) } } - .sidebar-collapsed-icon - = icon('rss', 'aria-hidden': 'true') - %span.issuable-header-text.hide-collapsed.pull-left - Notifications - - subscribtion_status = subscribed ? 'subscribed' : 'unsubscribed' - %button.btn.btn-default.pull-right.js-subscribe-button.issuable-subscribe-button.hide-collapsed{ type: "button" } - %span= subscribed ? 'Unsubscribe' : 'Subscribe' + .js-sidebar-subscriptions-entry-point - project_ref = cross_project_reference(@project, issuable) .block.project-reference diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 305e2542281..7ba8f9d4313 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -49,6 +49,13 @@ = link_to edit_project_milestone_path(milestone.project, milestone), class: "btn btn-xs btn-grouped" do Edit \ + + - if @project.group + = link_to promote_project_milestone_path(milestone.project, milestone), title: "Promote to Group Milestone", class: 'btn btn-xs btn-grouped', data: { confirm: "Promoting this milestone will make it available for all projects inside the group. Existing project milestones with the same name will be merged. Are you sure?", toggle: "tooltip" }, method: :post do + Promote + = link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-xs btn-close btn-grouped" + = link_to project_milestone_path(milestone.project, milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-xs btn-remove btn-grouped" do Delete + diff --git a/app/views/shared/repo/_editable_mode.html.haml b/app/views/shared/repo/_editable_mode.html.haml deleted file mode 100644 index 73fdb8b523f..00000000000 --- a/app/views/shared/repo/_editable_mode.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -.editable-mode - %repo-edit-button diff --git a/app/views/shared/repo/_repo.html.haml b/app/views/shared/repo/_repo.html.haml index 7861f92b33f..5867ea58378 100644 --- a/app/views/shared/repo/_repo.html.haml +++ b/app/views/shared/repo/_repo.html.haml @@ -1,11 +1,12 @@ #repo{ data: { root: @path.empty?.to_s, + root_url: project_tree_path(project), url: content_url, + current_branch: @ref, + ref: @commit.id, project_name: project.name, - refs_url: refs_project_path(project, format: :json), project_url: project_path(project), project_id: project.id, - blob_url: namespace_project_blob_path(project.namespace, project, '{{branch}}'), - new_mr_template_url: namespace_project_new_merge_request_path(project.namespace, project, merge_request: { source_branch: '{{source_branch}}' }), + new_merge_request_url: namespace_project_new_merge_request_path(project.namespace, project, merge_request: { source_branch: '' }), can_commit: (!!can_push_branch?(project, @ref)).to_s, on_top_of_branch: (!!on_top_of_branch?(project, @ref)).to_s, current_path: @path } } diff --git a/app/workers/cluster_provision_worker.rb b/app/workers/cluster_provision_worker.rb index 63300b58a25..b01f9708424 100644 --- a/app/workers/cluster_provision_worker.rb +++ b/app/workers/cluster_provision_worker.rb @@ -3,8 +3,10 @@ class ClusterProvisionWorker include ClusterQueue def perform(cluster_id) - Gcp::Cluster.find_by_id(cluster_id).try do |cluster| - Ci::ProvisionClusterService.new.execute(cluster) + Clusters::Cluster.find_by_id(cluster_id).try do |cluster| + cluster.provider.try do |provider| + Clusters::Gcp::ProvisionService.new.execute(provider) if cluster.gcp? + end end end end diff --git a/app/workers/stuck_merge_jobs_worker.rb b/app/workers/stuck_merge_jobs_worker.rb index 7843179d77c..a396c0f27b2 100644 --- a/app/workers/stuck_merge_jobs_worker.rb +++ b/app/workers/stuck_merge_jobs_worker.rb @@ -23,7 +23,7 @@ class StuckMergeJobsWorker merge_requests = MergeRequest.where(id: completed_ids) merge_requests.where.not(merge_commit_sha: nil).update_all(state: :merged) - merge_requests.where(merge_commit_sha: nil).update_all(state: :opened) + merge_requests.where(merge_commit_sha: nil).update_all(state: :opened, merge_jid: nil) Rails.logger.info("Updated state of locked merge jobs. JIDs: #{completed_jids.join(', ')}") end diff --git a/app/workers/update_merge_requests_worker.rb b/app/workers/update_merge_requests_worker.rb index 89ae17cef37..150788ca611 100644 --- a/app/workers/update_merge_requests_worker.rb +++ b/app/workers/update_merge_requests_worker.rb @@ -2,6 +2,10 @@ class UpdateMergeRequestsWorker include Sidekiq::Worker include DedicatedSidekiqQueue + def metrics_tags + @metrics_tags || {} + end + def perform(project_id, user_id, oldrev, newrev, ref) project = Project.find_by(id: project_id) return unless project @@ -9,6 +13,11 @@ class UpdateMergeRequestsWorker user = User.find_by(id: user_id) return unless user + @metrics_tags = { + project_id: project_id, + user_id: user_id + } + MergeRequests::RefreshService.new(project, user).execute(oldrev, newrev, ref) end end diff --git a/app/workers/wait_for_cluster_creation_worker.rb b/app/workers/wait_for_cluster_creation_worker.rb index 5aa3bbdaa9d..241ed3901dc 100644 --- a/app/workers/wait_for_cluster_creation_worker.rb +++ b/app/workers/wait_for_cluster_creation_worker.rb @@ -2,25 +2,10 @@ class WaitForClusterCreationWorker include Sidekiq::Worker include ClusterQueue - INITIAL_INTERVAL = 2.minutes - EAGER_INTERVAL = 10.seconds - TIMEOUT = 20.minutes - def perform(cluster_id) - Gcp::Cluster.find_by_id(cluster_id).try do |cluster| - Ci::FetchGcpOperationService.new.execute(cluster) do |operation| - case operation.status - when 'RUNNING' - if TIMEOUT < Time.now.utc - operation.start_time.to_time.utc - return cluster.make_errored!("Cluster creation time exceeds timeout; #{TIMEOUT}") - end - - WaitForClusterCreationWorker.perform_in(EAGER_INTERVAL, cluster.id) - when 'DONE' - Ci::FinalizeClusterCreationService.new.execute(cluster) - else - return cluster.make_errored!("Unexpected operation status; #{operation.status} #{operation.status_message}") - end + Clusters::Cluster.find_by_id(cluster_id).try do |cluster| + cluster.provider.try do |provider| + Clusters::Gcp::VerifyProvisionStatusService.new.execute(provider) if cluster.gcp? end end end diff --git a/changelogs/unreleased/23206-load-participants-async.yml b/changelogs/unreleased/23206-load-participants-async.yml new file mode 100644 index 00000000000..12ab43fb88f --- /dev/null +++ b/changelogs/unreleased/23206-load-participants-async.yml @@ -0,0 +1,5 @@ +--- +title: Update participants and subscriptions button in issuable sidebar to be async +merge_request: 14836 +author: +type: changed diff --git a/changelogs/unreleased/31454-missing-project-id-pipeline-hook-data.yml b/changelogs/unreleased/31454-missing-project-id-pipeline-hook-data.yml new file mode 100644 index 00000000000..daf7ac715bd --- /dev/null +++ b/changelogs/unreleased/31454-missing-project-id-pipeline-hook-data.yml @@ -0,0 +1,5 @@ +--- +title: Adds project_id to pipeline hook data +merge_request: 15044 +author: Jacopo Beschi @jacopo-beschi +type: added diff --git a/changelogs/unreleased/3274-geo-route-whitelisting.yml b/changelogs/unreleased/3274-geo-route-whitelisting.yml new file mode 100644 index 00000000000..43a5af80497 --- /dev/null +++ b/changelogs/unreleased/3274-geo-route-whitelisting.yml @@ -0,0 +1,5 @@ +--- +title: Tighten up whitelisting of certain Geo routes +merge_request: 15082 +author: +type: fixed diff --git a/changelogs/unreleased/35914-merge-request-update-worker-is-slow.yml b/changelogs/unreleased/35914-merge-request-update-worker-is-slow.yml new file mode 100644 index 00000000000..34bb76195af --- /dev/null +++ b/changelogs/unreleased/35914-merge-request-update-worker-is-slow.yml @@ -0,0 +1,5 @@ +--- +title: Add metric tagging for sidekiq workers +merge_request: 15111 +author: +type: added diff --git a/changelogs/unreleased/3674-hashed-storage-attachments.yml b/changelogs/unreleased/3674-hashed-storage-attachments.yml new file mode 100644 index 00000000000..41bdb5fa568 --- /dev/null +++ b/changelogs/unreleased/3674-hashed-storage-attachments.yml @@ -0,0 +1,5 @@ +--- +title: Hashed Storage support for Attachments +merge_request: 15068 +author: +type: added diff --git a/changelogs/unreleased/37473-expose-project-visibility-as-ci-variable.yml b/changelogs/unreleased/37473-expose-project-visibility-as-ci-variable.yml new file mode 100644 index 00000000000..f6906a3b0e0 --- /dev/null +++ b/changelogs/unreleased/37473-expose-project-visibility-as-ci-variable.yml @@ -0,0 +1,5 @@ +--- +title: Expose project visibility as CI variable - CI_PROJECT_VISIBILITY +merge_request: 15193 +author: +type: added diff --git a/changelogs/unreleased/37631-add-a-merge_request_diff_id-column-to-merge_requests.yml b/changelogs/unreleased/37631-add-a-merge_request_diff_id-column-to-merge_requests.yml new file mode 100644 index 00000000000..a7127f49c16 --- /dev/null +++ b/changelogs/unreleased/37631-add-a-merge_request_diff_id-column-to-merge_requests.yml @@ -0,0 +1,5 @@ +--- +title: Add a latest_merge_request_diff_id column to merge_requests +merge_request: 15035 +author: +type: performance diff --git a/changelogs/unreleased/38677-render-new-discussions-on-diff-tab.yml b/changelogs/unreleased/38677-render-new-discussions-on-diff-tab.yml new file mode 100644 index 00000000000..9de6e54e3af --- /dev/null +++ b/changelogs/unreleased/38677-render-new-discussions-on-diff-tab.yml @@ -0,0 +1,5 @@ +--- +title: Add new diff discussions on MR diffs tab in "realtime" +merge_request: 14981 +author: +type: fixed diff --git a/changelogs/unreleased/39188-change-default-disabled-merge-message.yml b/changelogs/unreleased/39188-change-default-disabled-merge-message.yml deleted file mode 100644 index 7de65f5c3f6..00000000000 --- a/changelogs/unreleased/39188-change-default-disabled-merge-message.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Update default disabled merge request widget message to reflect a general failure -merge_request: 14960 -author: -type: changed diff --git a/changelogs/unreleased/39417-todos-spelled-correctly-on-todos-list-page.yml b/changelogs/unreleased/39417-todos-spelled-correctly-on-todos-list-page.yml new file mode 100644 index 00000000000..edf142f0311 --- /dev/null +++ b/changelogs/unreleased/39417-todos-spelled-correctly-on-todos-list-page.yml @@ -0,0 +1,5 @@ +--- +title: Todos spelled correctly on Todos list page +merge_request: 15015 +author: +type: changed diff --git a/changelogs/unreleased/39495-fix-bitbucket-login.yml b/changelogs/unreleased/39495-fix-bitbucket-login.yml deleted file mode 100644 index b48d557108b..00000000000 --- a/changelogs/unreleased/39495-fix-bitbucket-login.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix bitbucket login -merge_request: 15051 -author: -type: fixed diff --git a/changelogs/unreleased/39509-fix-wiki-create-sidebar-overlap.yml b/changelogs/unreleased/39509-fix-wiki-create-sidebar-overlap.yml new file mode 100644 index 00000000000..aebf6363d97 --- /dev/null +++ b/changelogs/unreleased/39509-fix-wiki-create-sidebar-overlap.yml @@ -0,0 +1,5 @@ +--- +title: Fix overlap of right-sidebar and main content when creating a Wiki page +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/39570-performance-bar-appears-enabled-even-though-it-won-t-show-up.yml b/changelogs/unreleased/39570-performance-bar-appears-enabled-even-though-it-won-t-show-up.yml new file mode 100644 index 00000000000..66939d89d69 --- /dev/null +++ b/changelogs/unreleased/39570-performance-bar-appears-enabled-even-though-it-won-t-show-up.yml @@ -0,0 +1,5 @@ +--- +title: Allow to disable the Performance Bar +merge_request: 15084 +author: +type: fixed diff --git a/changelogs/unreleased/39580-bump-carrierwave-to-1-2-1.yml b/changelogs/unreleased/39580-bump-carrierwave-to-1-2-1.yml new file mode 100644 index 00000000000..bda85ac24e0 --- /dev/null +++ b/changelogs/unreleased/39580-bump-carrierwave-to-1-2-1.yml @@ -0,0 +1,5 @@ +--- +title: Bump carrierwave to 1.2.1 +merge_request: 15072 +author: Takuya Noguchi +type: other diff --git a/changelogs/unreleased/39582-nestingdepth-6.yml b/changelogs/unreleased/39582-nestingdepth-6.yml new file mode 100644 index 00000000000..efe15f0a5f3 --- /dev/null +++ b/changelogs/unreleased/39582-nestingdepth-6.yml @@ -0,0 +1,5 @@ +--- +title: Enable NestingDepth (level 6) on scss-lint +merge_request: 15073 +author: Takuya Noguchi +type: other diff --git a/changelogs/unreleased/39583-reopen-issue-count-cache.yml b/changelogs/unreleased/39583-reopen-issue-count-cache.yml new file mode 100644 index 00000000000..ee35bcbcdae --- /dev/null +++ b/changelogs/unreleased/39583-reopen-issue-count-cache.yml @@ -0,0 +1,5 @@ +--- +title: Refresh open Issue and Merge Request project counter caches when re-opening. +merge_request: 15085 +author: Rob Ede @robjtede +type: fixed diff --git a/changelogs/unreleased/39593-emails-on-push-are-sent-to-only-the-first-recipient-when-using-aws-ses.yml b/changelogs/unreleased/39593-emails-on-push-are-sent-to-only-the-first-recipient-when-using-aws-ses.yml new file mode 100644 index 00000000000..9a7109d054e --- /dev/null +++ b/changelogs/unreleased/39593-emails-on-push-are-sent-to-only-the-first-recipient-when-using-aws-ses.yml @@ -0,0 +1,5 @@ +--- +title: Only set Auto-Submitted header once for emails on push +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/39619-cancel-merge-when-pipeline-succeeds-from-the-api-fails.yml b/changelogs/unreleased/39619-cancel-merge-when-pipeline-succeeds-from-the-api-fails.yml new file mode 100644 index 00000000000..95251b46ecc --- /dev/null +++ b/changelogs/unreleased/39619-cancel-merge-when-pipeline-succeeds-from-the-api-fails.yml @@ -0,0 +1,5 @@ +--- +title: Fix namespacing for MergeWhenPipelineSucceedsService in MR API +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/39704_fix_webhooks_log_time.yml b/changelogs/unreleased/39704_fix_webhooks_log_time.yml new file mode 100644 index 00000000000..1234663e66b --- /dev/null +++ b/changelogs/unreleased/39704_fix_webhooks_log_time.yml @@ -0,0 +1,5 @@ +--- +title: Fix webhooks recent deliveries +merge_request: 15146 +author: Alexander Randa (@randaalex) +type: fixed diff --git a/changelogs/unreleased/39776-remove-responsive-table-bottom-border.yml b/changelogs/unreleased/39776-remove-responsive-table-bottom-border.yml new file mode 100644 index 00000000000..52b6a267ced --- /dev/null +++ b/changelogs/unreleased/39776-remove-responsive-table-bottom-border.yml @@ -0,0 +1,5 @@ +--- +title: Fix double border UI bug on pipelines/environments table and pagination +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/add-packagist-project-service.yml b/changelogs/unreleased/add-packagist-project-service.yml new file mode 100644 index 00000000000..a13d00e91f7 --- /dev/null +++ b/changelogs/unreleased/add-packagist-project-service.yml @@ -0,0 +1,5 @@ +--- +title: Add Packagist project service +merge_request: 14493 +author: Matt Coleman +type: added diff --git a/changelogs/unreleased/backport-workhorse-show-all-refs.yml b/changelogs/unreleased/backport-workhorse-show-all-refs.yml new file mode 100644 index 00000000000..36dd2115152 --- /dev/null +++ b/changelogs/unreleased/backport-workhorse-show-all-refs.yml @@ -0,0 +1,5 @@ +--- +title: Support show-all-refs for git over HTTP +merge_request: 14834 +author: +type: added diff --git a/changelogs/unreleased/bvl-circuitbreaker-backoff.yml b/changelogs/unreleased/bvl-circuitbreaker-backoff.yml deleted file mode 100644 index 5cb90e7c085..00000000000 --- a/changelogs/unreleased/bvl-circuitbreaker-backoff.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Make the circuitbreaker more robust by adding higher thresholds, and multiple - access attempts. -merge_request: 14933 -author: -type: fixed diff --git a/changelogs/unreleased/bvl-circuitbreaker-improvements.yml b/changelogs/unreleased/bvl-circuitbreaker-improvements.yml deleted file mode 100644 index 15cbd5592e9..00000000000 --- a/changelogs/unreleased/bvl-circuitbreaker-improvements.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Store circuitbreaker settings in the database instead of config -merge_request: 14842 -author: -type: changed diff --git a/changelogs/unreleased/bvl-do-not-use-redis-keys.yml b/changelogs/unreleased/bvl-do-not-use-redis-keys.yml deleted file mode 100644 index f703aad2065..00000000000 --- a/changelogs/unreleased/bvl-do-not-use-redis-keys.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Forbid the usage of `Redis#keys` -merge_request: 14889 -author: -type: fixed diff --git a/changelogs/unreleased/bvl-dont-rename-free-names.yml b/changelogs/unreleased/bvl-dont-rename-free-names.yml deleted file mode 100644 index 60a4ec8afbe..00000000000 --- a/changelogs/unreleased/bvl-dont-rename-free-names.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Don't rename paths that were freed up when upgrading -merge_request: 15029 -author: -type: fixed diff --git a/changelogs/unreleased/bvl-fix-push-event-service-for-forks.yml b/changelogs/unreleased/bvl-fix-push-event-service-for-forks.yml deleted file mode 100644 index 2a7d80270ac..00000000000 --- a/changelogs/unreleased/bvl-fix-push-event-service-for-forks.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Only cache last push event for existing projects when pushing to a fork -merge_request: 14989 -author: -type: fixed diff --git a/changelogs/unreleased/bvl-unlink-fixes.yml b/changelogs/unreleased/bvl-unlink-fixes.yml new file mode 100644 index 00000000000..685d78f479d --- /dev/null +++ b/changelogs/unreleased/bvl-unlink-fixes.yml @@ -0,0 +1,5 @@ +--- +title: Fix issues with forked projects of which the source was deleted +merge_request: 15150 +author: +type: fixed diff --git a/changelogs/unreleased/dm-add-sudo-scope.yml b/changelogs/unreleased/dm-add-sudo-scope.yml new file mode 100644 index 00000000000..a0c173ce781 --- /dev/null +++ b/changelogs/unreleased/dm-add-sudo-scope.yml @@ -0,0 +1,6 @@ +--- +title: Add sudo scope for OAuth and Personal Access Tokens to be used by admins to + impersonate other users on the API +merge_request: +author: +type: added diff --git a/changelogs/unreleased/dm-convert-private-tokens.yml b/changelogs/unreleased/dm-convert-private-tokens.yml new file mode 100644 index 00000000000..8f5145c897b --- /dev/null +++ b/changelogs/unreleased/dm-convert-private-tokens.yml @@ -0,0 +1,5 @@ +--- +title: Convert private tokens to Personal Access Tokens with sudo scope +merge_request: +author: +type: security diff --git a/changelogs/unreleased/dm-remove-private-token-from-interface.yml b/changelogs/unreleased/dm-remove-private-token-from-interface.yml new file mode 100644 index 00000000000..1b8996b08c3 --- /dev/null +++ b/changelogs/unreleased/dm-remove-private-token-from-interface.yml @@ -0,0 +1,5 @@ +--- +title: Remove private tokens from web interface and API +merge_request: +author: +type: security diff --git a/changelogs/unreleased/dm-remove-private-token.yml b/changelogs/unreleased/dm-remove-private-token.yml new file mode 100644 index 00000000000..d721495721a --- /dev/null +++ b/changelogs/unreleased/dm-remove-private-token.yml @@ -0,0 +1,5 @@ +--- +title: Remove Session API now that private tokens are removed from user API endpoints +merge_request: +author: +type: removed diff --git a/changelogs/unreleased/enable-scss-lint-mergeable-selector.yml b/changelogs/unreleased/enable-scss-lint-mergeable-selector.yml new file mode 100644 index 00000000000..5f6e0cafe88 --- /dev/null +++ b/changelogs/unreleased/enable-scss-lint-mergeable-selector.yml @@ -0,0 +1,4 @@ +--- +title: Enable MergeableSelector in scss-lint +merge_request: 12810 +author: Takuya Noguchi diff --git a/changelogs/unreleased/feature-plantuml-restructured-text-captions.yml b/changelogs/unreleased/feature-plantuml-restructured-text-captions.yml new file mode 100644 index 00000000000..3d8d0f4fcd1 --- /dev/null +++ b/changelogs/unreleased/feature-plantuml-restructured-text-captions.yml @@ -0,0 +1,5 @@ +--- +title: 'Support uml:: and captions in reStructuredText' +merge_request: 15120 +author: Markus Koller +type: changed diff --git a/changelogs/unreleased/fix-500-on-old-merge-requests.yml b/changelogs/unreleased/fix-500-on-old-merge-requests.yml new file mode 100644 index 00000000000..765d7466819 --- /dev/null +++ b/changelogs/unreleased/fix-500-on-old-merge-requests.yml @@ -0,0 +1,5 @@ +--- +title: Fix 500 errors caused by empty diffs in some discussions +merge_request: 14945 +author: Alexander Popov +type: fixed diff --git a/changelogs/unreleased/fix-add-path-attr-to-wiki-file.yml b/changelogs/unreleased/fix-add-path-attr-to-wiki-file.yml deleted file mode 100644 index 0847b5f6733..00000000000 --- a/changelogs/unreleased/fix-add-path-attr-to-wiki-file.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix broken wiki pages that link to a wiki file -merge_request: 15019 -author: -type: fixed diff --git a/changelogs/unreleased/fix-project-select-js-without-button.yml b/changelogs/unreleased/fix-project-select-js-without-button.yml new file mode 100644 index 00000000000..389ca2394f0 --- /dev/null +++ b/changelogs/unreleased/fix-project-select-js-without-button.yml @@ -0,0 +1,5 @@ +--- +title: Use project select dropdown not only as a combobutton +merge_request: 15043 +author: +type: fixed diff --git a/changelogs/unreleased/fix-user-tab-activity-mobile.yml b/changelogs/unreleased/fix-user-tab-activity-mobile.yml new file mode 100644 index 00000000000..a7e4fcb4355 --- /dev/null +++ b/changelogs/unreleased/fix-user-tab-activity-mobile.yml @@ -0,0 +1,5 @@ +--- +title: Fixed user profile activity tab being off-screen on mobile +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/fix_global_board_routes_39073.yml b/changelogs/unreleased/fix_global_board_routes_39073.yml deleted file mode 100644 index cc9ae8592db..00000000000 --- a/changelogs/unreleased/fix_global_board_routes_39073.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Allow boards as top level route -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/fix_migration_that_adds_ff_merge_field.yml b/changelogs/unreleased/fix_migration_that_adds_ff_merge_field.yml new file mode 100644 index 00000000000..a1685497331 --- /dev/null +++ b/changelogs/unreleased/fix_migration_that_adds_ff_merge_field.yml @@ -0,0 +1,5 @@ +--- +title: Fix a migration that adds merge_requests_ff_only_enabled column to MR table +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/go-get-ssh.yml b/changelogs/unreleased/go-get-ssh.yml new file mode 100644 index 00000000000..e485a94c6db --- /dev/null +++ b/changelogs/unreleased/go-get-ssh.yml @@ -0,0 +1,5 @@ +--- +title: Returns a ssh url for go-get=1 +merge_request: 14990 +author: gvieira37 +type: fixed diff --git a/changelogs/unreleased/issue_38777.yml b/changelogs/unreleased/issue_38777.yml new file mode 100644 index 00000000000..5c49b2f7879 --- /dev/null +++ b/changelogs/unreleased/issue_38777.yml @@ -0,0 +1,5 @@ +--- +title: Allow promoting project milestones to group milestones +merge_request: +author: +type: added diff --git a/changelogs/unreleased/issue_39176.yml b/changelogs/unreleased/issue_39176.yml new file mode 100644 index 00000000000..6255b51c094 --- /dev/null +++ b/changelogs/unreleased/issue_39176.yml @@ -0,0 +1,5 @@ +--- +title: Render 404 when polling commit notes without having permissions +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/jivl-fix-cancel-button-file-upload-new-issue.yml b/changelogs/unreleased/jivl-fix-cancel-button-file-upload-new-issue.yml new file mode 100644 index 00000000000..0205d9626b1 --- /dev/null +++ b/changelogs/unreleased/jivl-fix-cancel-button-file-upload-new-issue.yml @@ -0,0 +1,5 @@ +--- +title: Fix cancel button not working while uploading on the new issue page +merge_request: 15137 +author: +type: fixed diff --git a/changelogs/unreleased/jivl-mobile-friendly-table-runners.yml b/changelogs/unreleased/jivl-mobile-friendly-table-runners.yml new file mode 100644 index 00000000000..3448b003ee0 --- /dev/null +++ b/changelogs/unreleased/jivl-mobile-friendly-table-runners.yml @@ -0,0 +1,5 @@ +--- +title: Mobile-friendly table on Admin Runners +merge_request: +author: Takuya Noguchi +type: fixed diff --git a/changelogs/unreleased/pawel-disable_nfs_metrics_checks_39730.yml b/changelogs/unreleased/pawel-disable_nfs_metrics_checks_39730.yml new file mode 100644 index 00000000000..556d7d069d3 --- /dev/null +++ b/changelogs/unreleased/pawel-disable_nfs_metrics_checks_39730.yml @@ -0,0 +1,5 @@ +--- +title: Remove Filesystem check metrics that use too much CPU to handle requests +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/ph-multi-file-upload-file.yml b/changelogs/unreleased/ph-multi-file-upload-file.yml new file mode 100644 index 00000000000..a2bd3cfe459 --- /dev/null +++ b/changelogs/unreleased/ph-multi-file-upload-file.yml @@ -0,0 +1,5 @@ +--- +title: Allow files to uploaded in the multi-file editor +merge_request: +author: +type: added diff --git a/changelogs/unreleased/refactor-group_links_controller.yml b/changelogs/unreleased/refactor-group_links_controller.yml new file mode 100644 index 00000000000..af3d22c34cb --- /dev/null +++ b/changelogs/unreleased/refactor-group_links_controller.yml @@ -0,0 +1,5 @@ +--- +title: Refactor GroupLinksController +merge_request: +author: 15121 +type: other diff --git a/changelogs/unreleased/sh-disable-unicorn-sampling-sidekiq.yml b/changelogs/unreleased/sh-disable-unicorn-sampling-sidekiq.yml new file mode 100644 index 00000000000..c4ed017dacd --- /dev/null +++ b/changelogs/unreleased/sh-disable-unicorn-sampling-sidekiq.yml @@ -0,0 +1,5 @@ +--- +title: Disable Unicorn sampling in Sidekiq since there are no Unicorn sockets to monitor +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/sh-fix-broken-redirection-relative-url-root.yml b/changelogs/unreleased/sh-fix-broken-redirection-relative-url-root.yml new file mode 100644 index 00000000000..96e5195d247 --- /dev/null +++ b/changelogs/unreleased/sh-fix-broken-redirection-relative-url-root.yml @@ -0,0 +1,5 @@ +--- +title: Fix broken Members link when relative URL root paths are used +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/sh-fix-container-registry-destroy.yml b/changelogs/unreleased/sh-fix-container-registry-destroy.yml deleted file mode 100644 index 21a463da62a..00000000000 --- a/changelogs/unreleased/sh-fix-container-registry-destroy.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix deletion of container registry or images returning an error -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/sh-fix-environment-slug-generation.yml b/changelogs/unreleased/sh-fix-environment-slug-generation.yml new file mode 100644 index 00000000000..8a9c670c52c --- /dev/null +++ b/changelogs/unreleased/sh-fix-environment-slug-generation.yml @@ -0,0 +1,5 @@ +--- +title: Avoid regenerating the ref path for the environment +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/sh-fix-environment-write-ref.yml b/changelogs/unreleased/sh-fix-environment-write-ref.yml deleted file mode 100644 index 8f291843ebe..00000000000 --- a/changelogs/unreleased/sh-fix-environment-write-ref.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix the writing of invalid environment refs -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/update-fe-i18n-guide.yml b/changelogs/unreleased/update-fe-i18n-guide.yml new file mode 100644 index 00000000000..10bcf7836c6 --- /dev/null +++ b/changelogs/unreleased/update-fe-i18n-guide.yml @@ -0,0 +1,5 @@ +--- +title: Update i18n section in FE docs for marking and interpolation +merge_request: +author: +type: changed diff --git a/changelogs/unreleased/use-git-branch-merged.yml b/changelogs/unreleased/use-git-branch-merged.yml new file mode 100644 index 00000000000..24ec226250c --- /dev/null +++ b/changelogs/unreleased/use-git-branch-merged.yml @@ -0,0 +1,5 @@ +--- +title: Improve branch listing page performance +merge_request: 14729 +author: +type: performance diff --git a/changelogs/unreleased/winh-admin-projects-namespace-filter.yml b/changelogs/unreleased/winh-admin-projects-namespace-filter.yml new file mode 100644 index 00000000000..7e906f446b0 --- /dev/null +++ b/changelogs/unreleased/winh-admin-projects-namespace-filter.yml @@ -0,0 +1,5 @@ +--- +title: Make NamespaceSelect change URL when filtering +merge_request: 14888 +author: +type: fixed diff --git a/changelogs/unreleased/winh-i18n-contributors-page.yml b/changelogs/unreleased/winh-i18n-contributors-page.yml new file mode 100644 index 00000000000..9b2611fc4fa --- /dev/null +++ b/changelogs/unreleased/winh-i18n-contributors-page.yml @@ -0,0 +1,5 @@ +--- +title: Make contributors page translatable +merge_request: 14915 +author: +type: other diff --git a/changelogs/unreleased/winh-namespace-rename-hooks.yml b/changelogs/unreleased/winh-namespace-rename-hooks.yml new file mode 100644 index 00000000000..f5090b03b74 --- /dev/null +++ b/changelogs/unreleased/winh-namespace-rename-hooks.yml @@ -0,0 +1,5 @@ +--- +title: Add system hooks user_rename and group_rename +merge_request: 15123 +author: +type: changed diff --git a/changelogs/unreleased/zj-commit-cache.yml b/changelogs/unreleased/zj-commit-cache.yml new file mode 100644 index 00000000000..e3afe0ea7ef --- /dev/null +++ b/changelogs/unreleased/zj-commit-cache.yml @@ -0,0 +1,5 @@ +--- +title: Cache commits fetched from the repository +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/zj-ruby-2-3-5.yml b/changelogs/unreleased/zj-ruby-2-3-5.yml new file mode 100644 index 00000000000..09ec02417aa --- /dev/null +++ b/changelogs/unreleased/zj-ruby-2-3-5.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade Ruby to 2.3.5 to include security patches +merge_request: 15099 +author: +type: security diff --git a/config/environments/test.rb b/config/environments/test.rb index 1edb6fd39b8..d09e51e766a 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -1,6 +1,7 @@ Rails.application.configure do # Make sure the middleware is inserted first in middleware chain config.middleware.insert_before('ActionDispatch::Static', 'Gitlab::Testing::RequestBlockerMiddleware') + config.middleware.insert_before('ActionDispatch::Static', 'Gitlab::Testing::RequestInspectorMiddleware') # Settings specified here will take precedence over those in config/application.rb diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 4bfa5be0136..7547ba4a8fa 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -501,7 +501,7 @@ production: &base # Gitaly settings gitaly: # Path to the directory containing Gitaly client executables. - client_path: /home/git/gitaly + client_path: /home/git/gitaly/bin # Default Gitaly authentication token. Can be overriden per storage. Can # be left blank when Gitaly is running locally on a Unix socket, which # is the normal way to deploy Gitaly. diff --git a/config/initializers/8_metrics.rb b/config/initializers/8_metrics.rb index e1a59d8c152..2d8704622b6 100644 --- a/config/initializers/8_metrics.rb +++ b/config/initializers/8_metrics.rb @@ -123,7 +123,9 @@ def instrument_classes(instrumentation) end # rubocop:enable Metrics/AbcSize -Gitlab::Metrics::UnicornSampler.initialize_instance(Settings.monitoring.unicorn_sampler_interval).start +unless Sidekiq.server? + Gitlab::Metrics::UnicornSampler.initialize_instance(Settings.monitoring.unicorn_sampler_interval).start +end Gitlab::Application.configure do |config| # 0 should be Sentry to catch errors in this middleware diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml index 14d49885fb3..0da6b14c29e 100644 --- a/config/locales/doorkeeper.en.yml +++ b/config/locales/doorkeeper.en.yml @@ -58,9 +58,10 @@ en: expired: "The access token expired" unknown: "The access token is invalid" scopes: - api: Access your API - read_user: Read user information + api: Access the authenticated user's API + read_user: Read the authenticated user's personal information openid: Authenticate using OpenID Connect + sudo: Perform API actions as any user in the system (if the authenticated user is an admin) flash: applications: diff --git a/config/routes/ci.rb b/config/routes/ci.rb index cbd4c2db852..60c1724bc05 100644 --- a/config/routes/ci.rb +++ b/config/routes/ci.rb @@ -1,5 +1,5 @@ namespace :ci do resource :lint, only: [:show, :create] - root to: redirect('/') + root to: redirect('') end diff --git a/config/routes/profile.rb b/config/routes/profile.rb index ddc852f0132..bcfc17a5f66 100644 --- a/config/routes/profile.rb +++ b/config/routes/profile.rb @@ -6,7 +6,6 @@ resource :profile, only: [:show, :update] do get :audit_log get :applications, to: 'oauth/applications#index' - put :reset_private_token put :reset_incoming_email_token put :reset_rss_token put :update_username diff --git a/config/routes/project.rb b/config/routes/project.rb index 4b703ecb193..c52dd542c90 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -294,6 +294,7 @@ constraints(ProjectUrlConstrainer.new) do resources :milestones, constraints: { id: /\d+/ } do member do + post :promote put :sort_issues put :sort_merge_requests get :merge_requests @@ -394,7 +395,7 @@ constraints(ProjectUrlConstrainer.new) do end end namespace :settings do - get :members, to: redirect('/%{namespace_id}/%{project_id}/project_members') + get :members, to: redirect("%{namespace_id}/%{project_id}/project_members") resource :ci_cd, only: [:show], controller: 'ci_cd' resource :integrations, only: [:show] resource :repository, only: [:show], controller: :repository diff --git a/config/routes/snippets.rb b/config/routes/snippets.rb index 0a4ebac3ca3..81bc890d86b 100644 --- a/config/routes/snippets.rb +++ b/config/routes/snippets.rb @@ -17,5 +17,5 @@ resources :snippets, concerns: :awardable do end end -get '/s/:username', to: redirect('/u/%{username}/snippets'), +get '/s/:username', to: redirect('u/%{username}/snippets'), constraints: { username: /[a-zA-Z.0-9_\-]+(?<!\.atom)/ } diff --git a/config/routes/user.rb b/config/routes/user.rb index e682dcd6663..733a3f6ce9a 100644 --- a/config/routes/user.rb +++ b/config/routes/user.rb @@ -22,17 +22,17 @@ scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) d get :contributed, as: :contributed_projects get :snippets get :exists - get '/', to: redirect('/%{username}'), as: nil + get '/', to: redirect('%{username}'), as: nil end # Compatibility with old routing # TODO (dzaporozhets): remove in 10.0 - get '/u/:username', to: redirect('/%{username}') + get '/u/:username', to: redirect('%{username}') # TODO (dzaporozhets): remove in 9.0 - get '/u/:username/groups', to: redirect('/users/%{username}/groups') - get '/u/:username/projects', to: redirect('/users/%{username}/projects') - get '/u/:username/snippets', to: redirect('/users/%{username}/snippets') - get '/u/:username/contributed', to: redirect('/users/%{username}/contributed') + get '/u/:username/groups', to: redirect('users/%{username}/groups') + get '/u/:username/projects', to: redirect('users/%{username}/projects') + get '/u/:username/snippets', to: redirect('users/%{username}/snippets') + get '/u/:username/contributed', to: redirect('users/%{username}/contributed') end constraints(UserUrlConstrainer.new) do diff --git a/db/migrate/20150827121444_add_fast_forward_option_to_project.rb b/db/migrate/20150827121444_add_fast_forward_option_to_project.rb index 6f22641077d..35df121519e 100644 --- a/db/migrate/20150827121444_add_fast_forward_option_to_project.rb +++ b/db/migrate/20150827121444_add_fast_forward_option_to_project.rb @@ -8,7 +8,11 @@ class AddFastForwardOptionToProject < ActiveRecord::Migration disable_ddl_transaction! def up - add_column_with_default(:projects, :merge_requests_ff_only_enabled, :boolean, default: false) + # We put condition here because of a mistake we made a couple of years ago + # see https://gitlab.com/gitlab-org/gitlab-ce/issues/39382#note_45716103 + unless column_exists?(:projects, :merge_requests_ff_only_enabled) + add_column_with_default(:projects, :merge_requests_ff_only_enabled, :boolean, default: false) + end end def down diff --git a/db/migrate/20171012125712_migrate_user_authentication_token_to_personal_access_token.rb b/db/migrate/20171012125712_migrate_user_authentication_token_to_personal_access_token.rb new file mode 100644 index 00000000000..9a909644a44 --- /dev/null +++ b/db/migrate/20171012125712_migrate_user_authentication_token_to_personal_access_token.rb @@ -0,0 +1,78 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class MigrateUserAuthenticationTokenToPersonalAccessToken < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # disable_ddl_transaction! + + TOKEN_NAME = 'Private Token'.freeze + + def up + execute <<~SQL + INSERT INTO personal_access_tokens (user_id, token, name, created_at, updated_at, scopes) + SELECT id, authentication_token, '#{TOKEN_NAME}', NOW(), NOW(), '#{%w[api].to_yaml}' + FROM users + WHERE authentication_token IS NOT NULL + AND admin = FALSE + AND NOT EXISTS ( + SELECT true + FROM personal_access_tokens + WHERE user_id = users.id + AND token = users.authentication_token + ) + SQL + + # Admins also need the `sudo` scope + execute <<~SQL + INSERT INTO personal_access_tokens (user_id, token, name, created_at, updated_at, scopes) + SELECT id, authentication_token, '#{TOKEN_NAME}', NOW(), NOW(), '#{%w[api sudo].to_yaml}' + FROM users + WHERE authentication_token IS NOT NULL + AND admin = TRUE + AND NOT EXISTS ( + SELECT true + FROM personal_access_tokens + WHERE user_id = users.id + AND token = users.authentication_token + ) + SQL + end + + def down + if Gitlab::Database.postgresql? + execute <<~SQL + UPDATE users + SET authentication_token = pats.token + FROM ( + SELECT user_id, token + FROM personal_access_tokens + WHERE name = '#{TOKEN_NAME}' + ) AS pats + WHERE id = pats.user_id + SQL + else + execute <<~SQL + UPDATE users + INNER JOIN personal_access_tokens AS pats + ON users.id = pats.user_id + SET authentication_token = pats.token + WHERE pats.name = '#{TOKEN_NAME}' + SQL + end + + execute <<~SQL + DELETE FROM personal_access_tokens + WHERE name = '#{TOKEN_NAME}' + AND EXISTS ( + SELECT true + FROM users + WHERE id = personal_access_tokens.user_id + AND authentication_token = personal_access_tokens.token + ) + SQL + end +end diff --git a/db/migrate/20171013094327_create_new_clusters_architectures.rb b/db/migrate/20171013094327_create_new_clusters_architectures.rb new file mode 100644 index 00000000000..dabb3e25e48 --- /dev/null +++ b/db/migrate/20171013094327_create_new_clusters_architectures.rb @@ -0,0 +1,68 @@ +class CreateNewClustersArchitectures < ActiveRecord::Migration + DOWNTIME = false + + def change + create_table :clusters do |t| + t.references :user, index: true, foreign_key: { on_delete: :nullify } + + t.integer :provider_type + t.integer :platform_type + + t.datetime_with_timezone :created_at, null: false + t.datetime_with_timezone :updated_at, null: false + + t.boolean :enabled, index: true, default: true + + t.string :name, null: false # If manual, read-write. If gcp, read-only. + end + + create_table :cluster_projects do |t| + t.references :project, null: false, index: true, foreign_key: { on_delete: :cascade } + t.references :cluster, null: false, index: true, foreign_key: { on_delete: :cascade } + + t.datetime_with_timezone :created_at, null: false + t.datetime_with_timezone :updated_at, null: false + end + + create_table :cluster_platforms_kubernetes do |t| + t.references :cluster, null: false, index: { unique: true }, foreign_key: { on_delete: :cascade } + + t.datetime_with_timezone :created_at, null: false + t.datetime_with_timezone :updated_at, null: false + + t.text :api_url + t.text :ca_cert + + t.string :namespace + + t.string :username + t.text :encrypted_password + t.string :encrypted_password_iv + + t.text :encrypted_token + t.string :encrypted_token_iv + end + + create_table :cluster_providers_gcp do |t| + t.references :cluster, null: false, index: { unique: true }, foreign_key: { on_delete: :cascade } + + t.integer :status + t.integer :num_nodes, null: false + + t.datetime_with_timezone :created_at, null: false + t.datetime_with_timezone :updated_at, null: false + + t.text :status_reason + + t.string :gcp_project_id, null: false + t.string :zone, null: false + t.string :machine_type + t.string :operation_id + + t.string :endpoint + + t.text :encrypted_access_token + t.string :encrypted_access_token_iv + end + end +end diff --git a/db/migrate/20171025110159_add_latest_merge_request_diff_id_to_merge_requests.rb b/db/migrate/20171025110159_add_latest_merge_request_diff_id_to_merge_requests.rb new file mode 100644 index 00000000000..74a2badc130 --- /dev/null +++ b/db/migrate/20171025110159_add_latest_merge_request_diff_id_to_merge_requests.rb @@ -0,0 +1,26 @@ +class AddLatestMergeRequestDiffIdToMergeRequests < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column :merge_requests, :latest_merge_request_diff_id, :integer + add_concurrent_index :merge_requests, :latest_merge_request_diff_id + + add_concurrent_foreign_key :merge_requests, :merge_request_diffs, + column: :latest_merge_request_diff_id, + on_delete: :nullify + end + + def down + remove_foreign_key :merge_requests, column: :latest_merge_request_diff_id + + if index_exists?(:merge_requests, :latest_merge_request_diff_id) + remove_concurrent_index :merge_requests, :latest_merge_request_diff_id + end + + remove_column :merge_requests, :latest_merge_request_diff_id + end +end diff --git a/db/post_migrate/20171012150314_remove_user_authentication_token.rb b/db/post_migrate/20171012150314_remove_user_authentication_token.rb new file mode 100644 index 00000000000..d0f3aa06e98 --- /dev/null +++ b/db/post_migrate/20171012150314_remove_user_authentication_token.rb @@ -0,0 +1,20 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveUserAuthenticationToken < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + remove_column :users, :authentication_token + end + + def down + add_column :users, :authentication_token, :string + + add_concurrent_index :users, :authentication_token, unique: true + end +end diff --git a/db/post_migrate/20171013104327_migrate_gcp_clusters_to_new_clusters_architectures.rb b/db/post_migrate/20171013104327_migrate_gcp_clusters_to_new_clusters_architectures.rb new file mode 100644 index 00000000000..4758c694563 --- /dev/null +++ b/db/post_migrate/20171013104327_migrate_gcp_clusters_to_new_clusters_architectures.rb @@ -0,0 +1,99 @@ +class MigrateGcpClustersToNewClustersArchitectures < ActiveRecord::Migration + DOWNTIME = false + + class GcpCluster < ActiveRecord::Base + self.table_name = 'gcp_clusters' + + belongs_to :project, class_name: 'Project' + + include EachBatch + end + + class Cluster < ActiveRecord::Base + self.table_name = 'clusters' + + has_many :cluster_projects, class_name: 'ClustersProject' + has_many :projects, through: :cluster_projects, class_name: 'Project' + has_one :provider_gcp, class_name: 'ProvidersGcp' + has_one :platform_kubernetes, class_name: 'PlatformsKubernetes' + + accepts_nested_attributes_for :provider_gcp + accepts_nested_attributes_for :platform_kubernetes + + enum platform_type: { + kubernetes: 1 + } + + enum provider_type: { + user: 0, + gcp: 1 + } + end + + class Project < ActiveRecord::Base + self.table_name = 'projects' + + has_one :cluster_project, class_name: 'ClustersProject' + has_one :cluster, through: :cluster_project, class_name: 'Cluster' + end + + class ClustersProject < ActiveRecord::Base + self.table_name = 'cluster_projects' + + belongs_to :cluster, class_name: 'Cluster' + belongs_to :project, class_name: 'Project' + end + + class ProvidersGcp < ActiveRecord::Base + self.table_name = 'cluster_providers_gcp' + end + + class PlatformsKubernetes < ActiveRecord::Base + self.table_name = 'cluster_platforms_kubernetes' + end + + def up + GcpCluster.all.find_each(batch_size: 1) do |gcp_cluster| + Cluster.create( + enabled: gcp_cluster.enabled, + user_id: gcp_cluster.user_id, + name: gcp_cluster.gcp_cluster_name, + provider_type: Cluster.provider_types[:gcp], + platform_type: Cluster.platform_types[:kubernetes], + projects: [gcp_cluster.project], + provider_gcp_attributes: { + status: gcp_cluster.status, + status_reason: gcp_cluster.status_reason, + gcp_project_id: gcp_cluster.gcp_project_id, + zone: gcp_cluster.gcp_cluster_zone, + num_nodes: gcp_cluster.gcp_cluster_size, + machine_type: gcp_cluster.gcp_machine_type, + operation_id: gcp_cluster.gcp_operation_id, + endpoint: gcp_cluster.endpoint, + encrypted_access_token: gcp_cluster.encrypted_gcp_token, + encrypted_access_token_iv: gcp_cluster.encrypted_gcp_token_iv + }, + platform_kubernetes_attributes: { + cluster_id: gcp_cluster.id, + api_url: api_url(gcp_cluster.endpoint), + ca_cert: gcp_cluster.ca_cert, + namespace: gcp_cluster.project_namespace, + username: gcp_cluster.username, + encrypted_password: gcp_cluster.encrypted_password, + encrypted_password_iv: gcp_cluster.encrypted_password_iv, + encrypted_token: gcp_cluster.encrypted_kubernetes_token, + encrypted_token_iv: gcp_cluster.encrypted_kubernetes_token_iv + } ) + end + end + + def down + execute('DELETE FROM clusters') + end + + private + + def api_url(endpoint) + endpoint ? 'https://' + endpoint : nil + end +end diff --git a/db/post_migrate/20171026082505_populate_merge_requests_latest_merge_request_diff_id.rb b/db/post_migrate/20171026082505_populate_merge_requests_latest_merge_request_diff_id.rb new file mode 100644 index 00000000000..a7ebbbf34c0 --- /dev/null +++ b/db/post_migrate/20171026082505_populate_merge_requests_latest_merge_request_diff_id.rb @@ -0,0 +1,27 @@ +class PopulateMergeRequestsLatestMergeRequestDiffId < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + BATCH_SIZE = 1_000 + + class MergeRequest < ActiveRecord::Base + self.table_name = 'merge_requests' + + include ::EachBatch + end + + disable_ddl_transaction! + + def up + update = ' + latest_merge_request_diff_id = ( + SELECT MAX(id) + FROM merge_request_diffs + WHERE merge_requests.id = merge_request_diffs.merge_request_id + )'.squish + + MergeRequest.where(latest_merge_request_diff_id: nil).each_batch(of: BATCH_SIZE) do |relation| + relation.update_all(update) + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 530f08022be..f88abaab86e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20171017145932) do +ActiveRecord::Schema.define(version: 20171026082505) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -462,6 +462,63 @@ ActiveRecord::Schema.define(version: 20171017145932) do add_index "ci_variables", ["project_id", "key", "environment_scope"], name: "index_ci_variables_on_project_id_and_key_and_environment_scope", unique: true, using: :btree + create_table "cluster_platforms_kubernetes", force: :cascade do |t| + t.integer "cluster_id", null: false + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.text "api_url" + t.text "ca_cert" + t.string "namespace" + t.string "username" + t.text "encrypted_password" + t.string "encrypted_password_iv" + t.text "encrypted_token" + t.string "encrypted_token_iv" + end + + add_index "cluster_platforms_kubernetes", ["cluster_id"], name: "index_cluster_platforms_kubernetes_on_cluster_id", unique: true, using: :btree + + create_table "cluster_projects", force: :cascade do |t| + t.integer "project_id", null: false + t.integer "cluster_id", null: false + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + end + + add_index "cluster_projects", ["cluster_id"], name: "index_cluster_projects_on_cluster_id", using: :btree + add_index "cluster_projects", ["project_id"], name: "index_cluster_projects_on_project_id", using: :btree + + create_table "cluster_providers_gcp", force: :cascade do |t| + t.integer "cluster_id", null: false + t.integer "status" + t.integer "num_nodes", null: false + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.text "status_reason" + t.string "gcp_project_id", null: false + t.string "zone", null: false + t.string "machine_type" + t.string "operation_id" + t.string "endpoint" + t.text "encrypted_access_token" + t.string "encrypted_access_token_iv" + end + + add_index "cluster_providers_gcp", ["cluster_id"], name: "index_cluster_providers_gcp_on_cluster_id", unique: true, using: :btree + + create_table "clusters", force: :cascade do |t| + t.integer "user_id" + t.integer "provider_type" + t.integer "platform_type" + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.boolean "enabled", default: true + t.string "name", null: false + end + + add_index "clusters", ["enabled"], name: "index_clusters_on_enabled", using: :btree + add_index "clusters", ["user_id"], name: "index_clusters_on_user_id", using: :btree + create_table "container_repositories", force: :cascade do |t| t.integer "project_id", null: false t.string "name", null: false @@ -973,6 +1030,7 @@ ActiveRecord::Schema.define(version: 20171017145932) do t.boolean "ref_fetched" t.string "merge_jid" t.boolean "discussion_locked" + t.integer "latest_merge_request_diff_id" end add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree @@ -981,6 +1039,7 @@ ActiveRecord::Schema.define(version: 20171017145932) do add_index "merge_requests", ["deleted_at"], name: "index_merge_requests_on_deleted_at", using: :btree add_index "merge_requests", ["description"], name: "index_merge_requests_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} add_index "merge_requests", ["head_pipeline_id"], name: "index_merge_requests_on_head_pipeline_id", using: :btree + add_index "merge_requests", ["latest_merge_request_diff_id"], name: "index_merge_requests_on_latest_merge_request_diff_id", using: :btree add_index "merge_requests", ["milestone_id"], name: "index_merge_requests_on_milestone_id", using: :btree add_index "merge_requests", ["source_branch"], name: "index_merge_requests_on_source_branch", using: :btree add_index "merge_requests", ["source_project_id", "source_branch"], name: "index_merge_requests_on_source_project_id_and_source_branch", using: :btree @@ -1670,7 +1729,6 @@ ActiveRecord::Schema.define(version: 20171017145932) do t.string "skype", default: "", null: false t.string "linkedin", default: "", null: false t.string "twitter", default: "", null: false - t.string "authentication_token" t.string "bio" t.integer "failed_attempts", default: 0 t.datetime "locked_at" @@ -1720,7 +1778,6 @@ ActiveRecord::Schema.define(version: 20171017145932) do end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree - add_index "users", ["authentication_token"], name: "index_users_on_authentication_token", unique: true, using: :btree add_index "users", ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true, using: :btree add_index "users", ["created_at"], name: "index_users_on_created_at", using: :btree add_index "users", ["email"], name: "index_users_on_email", unique: true, using: :btree @@ -1810,6 +1867,11 @@ ActiveRecord::Schema.define(version: 20171017145932) do add_foreign_key "ci_triggers", "projects", name: "fk_e3e63f966e", on_delete: :cascade add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade add_foreign_key "ci_variables", "projects", name: "fk_ada5eb64b3", on_delete: :cascade + add_foreign_key "cluster_platforms_kubernetes", "clusters", on_delete: :cascade + add_foreign_key "cluster_projects", "clusters", on_delete: :cascade + add_foreign_key "cluster_projects", "projects", on_delete: :cascade + add_foreign_key "cluster_providers_gcp", "clusters", on_delete: :cascade + add_foreign_key "clusters", "users", on_delete: :nullify add_foreign_key "container_repositories", "projects" add_foreign_key "deploy_keys_projects", "projects", name: "fk_58a901ca7e", on_delete: :cascade add_foreign_key "deployments", "projects", name: "fk_b9a3851b82", on_delete: :cascade @@ -1846,6 +1908,7 @@ ActiveRecord::Schema.define(version: 20171017145932) do add_foreign_key "merge_request_metrics", "ci_pipelines", column: "pipeline_id", on_delete: :cascade add_foreign_key "merge_request_metrics", "merge_requests", on_delete: :cascade add_foreign_key "merge_requests", "ci_pipelines", column: "head_pipeline_id", name: "fk_fd82eae0b9", on_delete: :nullify + add_foreign_key "merge_requests", "merge_request_diffs", column: "latest_merge_request_diff_id", name: "fk_06067f5644", on_delete: :nullify add_foreign_key "merge_requests", "projects", column: "target_project_id", name: "fk_a6963e8447", on_delete: :cascade add_foreign_key "merge_requests_closing_issues", "issues", on_delete: :cascade add_foreign_key "merge_requests_closing_issues", "merge_requests", on_delete: :cascade diff --git a/doc/README.md b/doc/README.md index 5eabc126b95..7c33a708dc7 100644 --- a/doc/README.md +++ b/doc/README.md @@ -1,5 +1,6 @@ --- toc: false +comments: false --- # GitLab Documentation diff --git a/doc/administration/auth/README.md b/doc/administration/auth/README.md index 13bd501e397..ee9b9a9466a 100644 --- a/doc/administration/auth/README.md +++ b/doc/administration/auth/README.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Authentication and Authorization GitLab integrates with the following external authentication and authorization diff --git a/doc/administration/integration/plantuml.md b/doc/administration/integration/plantuml.md index 652ca9cf454..93c3642a1f1 100644 --- a/doc/administration/integration/plantuml.md +++ b/doc/administration/integration/plantuml.md @@ -56,29 +56,34 @@ that, login with an Admin account and do following: With PlantUML integration enabled and configured, we can start adding diagrams to our AsciiDoc snippets, wikis and repos using delimited blocks: -``` -[plantuml, format="png", id="myDiagram", width="200px"] --- -Bob->Alice : hello -Alice -> Bob : Go Away --- -``` +- **Markdown** + + ```plantuml + Bob -> Alice : hello + Alice -> Bob : Go Away + ``` -And in Markdown using fenced code blocks: +- **AsciiDoc** - ```plantuml - Bob -> Alice : hello + ``` + [plantuml, format="png", id="myDiagram", width="200px"] + -- + Bob->Alice : hello Alice -> Bob : Go Away + -- ``` -And in reStructuredText using a directive: +- **reStructuredText** -``` -.. plantuml:: + ``` + .. plantuml:: + :caption: Caption with **bold** and *italic* - Bob -> Alice: hello - Alice -> Bob: Go Away -``` + Bob -> Alice: hello + Alice -> Bob: Go Away + ``` + + You can also use the `uml::` directive for compatibility with [sphinxcontrib-plantuml](https://pypi.python.org/pypi/sphinxcontrib-plantuml), but please note that we currently only support the `caption` option. The above blocks will be converted to an HTML img tag with source pointing to the PlantUML instance. If the PlantUML server is correctly configured, this should diff --git a/doc/administration/monitoring/performance/performance_bar.md b/doc/administration/monitoring/performance/performance_bar.md index 68efe0aae5c..b9464945cea 100644 --- a/doc/administration/monitoring/performance/performance_bar.md +++ b/doc/administration/monitoring/performance/performance_bar.md @@ -28,6 +28,12 @@ will be allowed to display the Performance Bar. Make sure _Enable the Performance Bar_ is checked and hit **Save** to save the changes. +Once the Performance Bar is enabled, you will need to press the [<kbd>p</kbd> + +<kbd>b</kbd> keyboard shortcut](../../../workflow/shortcuts.md) to actually +display it. + +You can toggle the Bar using the same shortcut. + --- ![GitLab Performance Bar Admin Settings](img/performance_bar_configuration_settings.png) diff --git a/doc/administration/monitoring/prometheus/gitlab_metrics.md b/doc/administration/monitoring/prometheus/gitlab_metrics.md index 6baae20d16a..11d5e077a36 100644 --- a/doc/administration/monitoring/prometheus/gitlab_metrics.md +++ b/doc/administration/monitoring/prometheus/gitlab_metrics.md @@ -20,7 +20,7 @@ it, the client IP needs to be [included in a whitelist][whitelist]. Currently the embedded Prometheus server is not automatically configured to collect metrics from this endpoint. We recommend setting up another Prometheus server, because the embedded server configuration is overwritten once every -[reconfigure of GitLab][reconfigure]. In the future this will not be required. +[reconfigure of GitLab][reconfigure]. In the future this will not be required. ## Metrics available @@ -45,6 +45,8 @@ In this experimental phase, only a few metrics are available: | redis_ping_success | Gauge | 9.4 | Whether or not the last redis ping succeeded | | 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 | +| filesystem_circuitbreaker_latency_seconds | Histogram | 9.5 | Latency of the stat check the circuitbreaker uses to probe a shard | +| filesystem_circuitbreaker | Gauge | 9.5 | Wether or not the circuit for a certain shard is broken or not | ## Metrics shared directory diff --git a/doc/administration/operations/sidekiq_memory_killer.md b/doc/administration/operations/sidekiq_memory_killer.md index b5e78348989..cbffd883774 100644 --- a/doc/administration/operations/sidekiq_memory_killer.md +++ b/doc/administration/operations/sidekiq_memory_killer.md @@ -28,7 +28,7 @@ The MemoryKiller is controlled using environment variables. delayed shutdown is triggered. The default value for Omnibus packages is set [in the omnibus-gitlab repository](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/files/gitlab-cookbooks/gitlab/attributes/default.rb). -- `SIDEKIQ_MEMORY_KILLER_GRACE_TIME`: defaults 900 seconds (15 minutes). When +- `SIDEKIQ_MEMORY_KILLER_GRACE_TIME`: defaults to 900 seconds (15 minutes). When a shutdown is triggered, the Sidekiq process will keep working normally for another 15 minutes. - `SIDEKIQ_MEMORY_KILLER_SHUTDOWN_WAIT`: defaults to 30 seconds. When the grace @@ -36,5 +36,3 @@ The MemoryKiller is controlled using environment variables. Existing jobs get 30 seconds to finish. After that, the MemoryKiller tells Sidekiq to shut down, and an external supervision mechanism (e.g. Runit) must restart Sidekiq. -- `SIDEKIQ_MEMORY_KILLER_SHUTDOWN_SIGNAL`: defaults to `SIGKILL`. The name of - the final signal sent to the Sidekiq process when we want it to shut down. diff --git a/doc/administration/repository_storage_types.md b/doc/administration/repository_storage_types.md index fa882bbe28a..bc9b6253f1a 100644 --- a/doc/administration/repository_storage_types.md +++ b/doc/administration/repository_storage_types.md @@ -25,7 +25,10 @@ Any change in the URL will need to be reflected on disk (when groups / users or of load in big installations, and can be even worst if they are using any type of network based filesystem. Last, for GitLab Geo, this storage type means we have to synchronize the disk state, replicate renames in the correct -order or we may end-up with wrong repository or missing data temporarily. +order or we may end-up with wrong repository or missing data temporarily. + +This pattern also exists in other objects stored in GitLab, like issue Attachments, GitLab Pages artifacts, +Docker Containers for the integrated Registry, etc. ## Hashed Storage @@ -67,3 +70,23 @@ To migrate your existing projects to the new storage type, check the specific [r [ce-28283]: https://gitlab.com/gitlab-org/gitlab-ce/issues/28283 [rake tasks]: raketasks/storage.md#migrate-existing-projects-to-hashed-storage [storage-paths]: repository_storage_types.md + +### Hashed Storage coverage + +We are incrementally moving every storable object in GitLab to the Hashed Storage pattern. You can check the current +coverage status below. + +Note that things stored in an S3 compatible endpoint will not have the downsides mentioned earlier, if they are not +prefixed with `#{namespace}/#{project_name}`, which is true for CI Cache and LFS Objects. + +| Storable Object | Legacy Storage | Hashed Storage | S3 Compatible | GitLab Version | +| ----------------| -------------- | -------------- | ------------- | -------------- | +| Repository | Yes | Yes | - | 10.0 | +| Attachments | Yes | Yes | - | 10.2 | +| Avatars | Yes | No | - | - | +| Pages | Yes | No | - | - | +| Docker Registry | Yes | No | - | - | +| CI Build Logs | No | No | - | - | +| CI Artifacts | No | No | - | - | +| CI Cache | No | No | Yes | - | +| LFS Objects | Yes | No | Yes (EEP) | - | diff --git a/doc/administration/troubleshooting/debug.md b/doc/administration/troubleshooting/debug.md index 6f1356ddf8f..be538ea250a 100644 --- a/doc/administration/troubleshooting/debug.md +++ b/doc/administration/troubleshooting/debug.md @@ -141,7 +141,7 @@ separate Rails process to debug the issue: 1. Log in to your GitLab account. 1. Copy the URL that is causing problems (e.g. https://gitlab.com/ABC). -1. Obtain the private token for your user (Profile Settings -> Account). +1. Create a Personal Access Token for your user (Profile Settings -> Access Tokens). 1. Bring up the GitLab Rails console. For omnibus users, run: ``` diff --git a/doc/api/README.md b/doc/api/README.md index 89ffe9d7868..f226716c3b5 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -50,7 +50,6 @@ following locations: - [Repository Files](repository_files.md) - [Runners](runners.md) - [Services](services.md) -- [Session](session.md) - [Settings](settings.md) - [Sidekiq metrics](sidekiq_metrics.md) - [System Hooks](system_hooks.md) @@ -86,27 +85,10 @@ API requests should be prefixed with `api` and the API version. The API version is defined in [`lib/api.rb`][lib-api-url]. For example, the root of the v4 API is at `/api/v4`. -For endpoints that require [authentication](#authentication), you need to pass -a `private_token` parameter via query string or header. If passed as a header, -the header name must be `PRIVATE-TOKEN` (uppercase and with a dash instead of -an underscore). - -Example of a valid API request: - -``` -GET /projects?private_token=9koXpg98eAheJpvBs5tK -``` - -Example of a valid API request using cURL and authentication via header: +Example of a valid API request using cURL: ```shell -curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects" -``` - -Example of a valid API request using cURL and authentication via a query string: - -```shell -curl "https://gitlab.example.com/api/v4/projects?private_token=9koXpg98eAheJpvBs5tK" +curl "https://gitlab.example.com/api/v4/projects" ``` The API uses JSON to serialize data. You don't need to specify `.json` at the @@ -114,15 +96,20 @@ end of an API URL. ## Authentication -Most API requests require authentication via a session cookie or token. For +Most API requests require authentication, or will only return public data when +authentication is not provided. For those cases where it is not required, this will be mentioned in the documentation for each individual endpoint. For example, the [`/projects/:id` endpoint](projects.md). -There are three types of access tokens available: +There are three ways to authenticate with the GitLab API: 1. [OAuth2 tokens](#oauth2-tokens) -1. [Private tokens](#private-tokens) 1. [Personal access tokens](#personal-access-tokens) +1. [Session cookie](#session-cookie) + +For admins who want to authenticate with the API as a specific user, or who want to build applications or scripts that do so, two options are available: +1. [Impersonation tokens](#impersonation-tokens) +2. [Sudo](#sudo) If authentication information is invalid or omitted, an error message will be returned with status code `401`: @@ -133,74 +120,84 @@ returned with status code `401`: } ``` -### Session cookie +### OAuth2 tokens -When signing in to GitLab as an ordinary user, a `_gitlab_session` cookie is -set. The API will use this cookie for authentication if it is present, but using -the API to generate a new session cookie is currently not supported. +You can use an [OAuth2 token](oauth2.md) to authenticate with the API by passing it in either the +`access_token` parameter or the `Authorization` header. -### OAuth2 tokens +Example of using the OAuth2 token in a parameter: -You can use an OAuth 2 token to authenticate with the API by passing it either in the -`access_token` parameter or in the `Authorization` header. +```shell +curl https://gitlab.example.com/api/v4/projects?access_token=OAUTH-TOKEN +``` -Example of using the OAuth2 token in the header: +Example of using the OAuth2 token in a header: ```shell curl --header "Authorization: Bearer OAUTH-TOKEN" https://gitlab.example.com/api/v4/projects ``` -Read more about [GitLab as an OAuth2 client](oauth2.md). +Read more about [GitLab as an OAuth2 provider](oauth2.md). -### Private tokens +### Personal access tokens -Private tokens provide full access to the GitLab API. Anyone with access to -them can interact with GitLab as if they were you. You can find or reset your -private token in your account page (`/profile/account`). +You can use a [personal access token][pat] to authenticate with the API by passing it in either the +`private_token` parameter or the `Private-Token` header. -For examples of usage, [read the basic usage section](#basic-usage). +Example of using the personal access token in a parameter: -### Personal access tokens +```shell +curl https://gitlab.example.com/api/v4/projects?private_token=9koXpg98eAheJpvBs5tK +``` + +Example of using the personal access token in a header: -Instead of using your private token which grants full access to your account, -personal access tokens could be a better fit because of their granular -permissions. +```shell +curl --header "Private-Token: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects +``` -Once you have your token, pass it to the API using either the `private_token` -parameter or the `PRIVATE-TOKEN` header. For examples of usage, -[read the basic usage section](#basic-usage). +Read more about [personal access tokens][pat]. + +### Session cookie + +When signing in to the main GitLab application, a `_gitlab_session` cookie is +set. The API will use this cookie for authentication if it is present, but using +the API to generate a new session cookie is currently not supported. -[Read more about personal access tokens.][pat] +The primary user of this authentication method is the web frontend of GitLab itself, +which can use the API as the authenticated user to get a list of their projects, +for example, without needing to explicitly pass an access token. ### Impersonation tokens > [Introduced][ce-9099] in GitLab 9.0. Needs admin permissions. Impersonation tokens are a type of [personal access token][pat] -that can only be created by an admin for a specific user. +that can only be created by an admin for a specific user. They are a great fit +if you want to build applications or scripts that authenticate with the API as a specific user. -They are a better alternative to using the user's password/private token -or using the [Sudo](#sudo) feature which also requires the admin's password -or private token, since the password/token can change over time. Impersonation -tokens are a great fit if you want to build applications or tools which -authenticate with the API as a specific user. +They are an alternative to directly using the user's password or one of their +personal access tokens, and to using the [Sudo](#sudo) feature, since the user's (or admin's, in the case of Sudo) +password/token may not be known or may change over time. For more information, refer to the [users API](users.md#retrieve-user-impersonation-tokens) docs. -For examples of usage, [read the basic usage section](#basic-usage). +Impersonation tokens are used exactly like regular personal access tokens, and can be passed in either the +`private_token` parameter or the `Private-Token` header. ### Sudo > Needs admin permissions. All API requests support performing an API call as if you were another user, -provided your private token is from an administrator account. You need to pass -the `sudo` parameter either via query string or a header with an ID/username of +provided you are authenticated as an administrator with an OAuth or Personal Access Token that has the `sudo` scope. + +You need to pass the `sudo` parameter either via query string or a header with an ID/username of the user you want to perform the operation as. If passed as a header, the -header name must be `SUDO` (uppercase). +header name must be `Sudo`. -If a non administrative `private_token` is provided, then an error message will +If a non administrative access token is provided, an error message will be returned with status code `403`: ```json @@ -209,12 +206,23 @@ be returned with status code `403`: } ``` +If an access token without the `sudo` scope is provided, an error message will +be returned with status code `403`: + +```json +{ + "error": "insufficient_scope", + "error_description": "The request requires higher privileges than provided by the access token.", + "scope": "sudo" +} +``` + If the sudo user ID or username cannot be found, an error message will be returned with status code `404`: ```json { - "message": "404 Not Found: No user id or username for: <id/username>" + "message": "404 User with ID or username '123' Not Found" } ``` @@ -228,7 +236,7 @@ GET /projects?private_token=9koXpg98eAheJpvBs5tK&sudo=username ``` ```shell -curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --header "SUDO: username" "https://gitlab.example.com/api/v4/projects" +curl --header "Private-Token: 9koXpg98eAheJpvBs5tK" --header "Sudo: username" "https://gitlab.example.com/api/v4/projects" ``` Example of a valid API call and a request using cURL with sudo request, @@ -239,7 +247,7 @@ GET /projects?private_token=9koXpg98eAheJpvBs5tK&sudo=23 ``` ```shell -curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --header "SUDO: 23" "https://gitlab.example.com/api/v4/projects" +curl --header "Private-Token: 9koXpg98eAheJpvBs5tK" --header "Sudo: 23" "https://gitlab.example.com/api/v4/projects" ``` ## Status codes diff --git a/doc/api/pipelines.md b/doc/api/pipelines.md index 890945cfc7e..a6631cab8c3 100644 --- a/doc/api/pipelines.md +++ b/doc/api/pipelines.md @@ -57,7 +57,7 @@ GET /projects/:id/pipelines/:pipeline_id | `pipeline_id` | integer | yes | The ID of a pipeline | ``` -curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/pipeline/46" +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/pipelines/46" ``` Example of response diff --git a/doc/api/services.md b/doc/api/services.md index 6c8f196fd5c..e642ec964de 100644 --- a/doc/api/services.md +++ b/doc/api/services.md @@ -582,6 +582,40 @@ Delete Mattermost slash command service for a project. DELETE /projects/:id/services/mattermost-slash-commands ``` +## Packagist + +Update your project on Packagist, the main Composer repository, when commits or tags are pushed to GitLab. + +### Create/Edit Packagist service + +Set Packagist service for a project. + +``` +PUT /projects/:id/services/packagist +``` + +Parameters: + +- `username` (**required**) +- `token` (**required**) +- `server` (optional) + +### Delete Packagist service + +Delete Packagist service for a project. + +``` +DELETE /projects/:id/services/packagist +``` + +### Get Packagist service settings + +Get Packagist service settings for a project. + +``` +GET /projects/:id/services/packagist +``` + ## Pipeline-Emails Get emails for GitLab CI pipelines. diff --git a/doc/api/session.md b/doc/api/session.md deleted file mode 100644 index b97e26f34a2..00000000000 --- a/doc/api/session.md +++ /dev/null @@ -1,55 +0,0 @@ -# Session API - ->**Deprecation notice:** -Starting in GitLab 8.11, this feature has been **disabled** for users with -[two-factor authentication][2fa] turned on. These users can access the API -using [personal access tokens] instead. - -You can login with both GitLab and LDAP credentials in order to obtain the -private token. - -``` -POST /session -``` - -| Attribute | Type | Required | Description | -| ---------- | ------- | -------- | -------- | -| `login` | string | yes | The username of the user| -| `email` | string | yes if login is not provided | The email of the user | -| `password` | string | yes | The password of the user | - -```bash -curl --request POST "https://gitlab.example.com/api/v4/session?login=john_smith&password=strongpassw0rd" -``` - -Example response: - -```json -{ - "name": "John Smith", - "username": "john_smith", - "id": 32, - "state": "active", - "avatar_url": null, - "created_at": "2015-01-29T21:07:19.440Z", - "is_admin": true, - "bio": null, - "skype": "", - "linkedin": "", - "twitter": "", - "website_url": "", - "email": "john@example.com", - "theme_id": 1, - "color_scheme_id": 1, - "projects_limit": 10, - "current_sign_in_at": "2015-07-07T07:10:58.392Z", - "identities": [], - "can_create_group": true, - "can_create_project": true, - "two_factor_enabled": false, - "private_token": "9koXpg98eAheJpvBs5tK" -} -``` - -[2fa]: ../user/profile/account/two_factor_authentication.md -[personal access tokens]: ../user/profile/personal_access_tokens.md diff --git a/doc/api/users.md b/doc/api/users.md index 1643c584244..aa711090af1 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -410,8 +410,7 @@ GET /user "can_create_group": true, "can_create_project": true, "two_factor_enabled": true, - "external": false, - "private_token": "dd34asd13as" + "external": false } ``` diff --git a/doc/ci/README.md b/doc/ci/README.md index ec0ddfbea75..12404eddbe2 100644 --- a/doc/ci/README.md +++ b/doc/ci/README.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # GitLab Continuous Integration (GitLab CI) ![Pipeline graph](img/cicd_pipeline_infograph.png) diff --git a/doc/ci/docker/README.md b/doc/ci/docker/README.md index 99669a9272a..b0e01d74f7e 100644 --- a/doc/ci/docker/README.md +++ b/doc/ci/docker/README.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Docker integration - [Using Docker Images](using_docker_images.md) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index 4586caa457d..0a2419b7ed2 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -31,12 +31,12 @@ There are three methods to enable the use of `docker build` and `docker run` dur The simplest approach is to install GitLab Runner in `shell` execution mode. GitLab Runner then executes job scripts as the `gitlab-runner` user. -1. Install [GitLab Runner](https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/#installation). +1. Install [GitLab Runner](https://gitlab.com/gitlab-org/gitlab-runner/#installation). 1. During GitLab Runner installation select `shell` as method of executing job scripts or use command: ```bash - sudo gitlab-ci-multi-runner register -n \ + sudo gitlab-runner register -n \ --url https://gitlab.com/ \ --registration-token REGISTRATION_TOKEN \ --executor shell \ @@ -93,7 +93,7 @@ In order to do that, follow the steps: mode: ```bash - sudo gitlab-ci-multi-runner register -n \ + sudo gitlab-runner register -n \ --url https://gitlab.com/ \ --registration-token REGISTRATION_TOKEN \ --executor docker \ @@ -178,7 +178,7 @@ In order to do that, follow the steps: 1. Register GitLab Runner from the command line to use `docker` and share `/var/run/docker.sock`: ```bash - sudo gitlab-ci-multi-runner register -n \ + sudo gitlab-runner register -n \ --url https://gitlab.com/ \ --registration-token REGISTRATION_TOKEN \ --executor docker \ diff --git a/doc/ci/docker/using_docker_images.md b/doc/ci/docker/using_docker_images.md index f7493794b6a..ecb8f15c851 100644 --- a/doc/ci/docker/using_docker_images.md +++ b/doc/ci/docker/using_docker_images.md @@ -501,8 +501,8 @@ First start with creating a file named `build_script`: ```bash cat <<EOF > build_script -git clone https://gitlab.com/gitlab-org/gitlab-ci-multi-runner.git /builds/gitlab-org/gitlab-ci-multi-runner -cd /builds/gitlab-org/gitlab-ci-multi-runner +git clone https://gitlab.com/gitlab-org/gitlab-runner.git /builds/gitlab-org/gitlab-runner +cd /builds/gitlab-org/gitlab-runner make EOF ``` diff --git a/doc/ci/enable_or_disable_ci.md b/doc/ci/enable_or_disable_ci.md index b8f9988e3ef..7aa7de97c43 100644 --- a/doc/ci/enable_or_disable_ci.md +++ b/doc/ci/enable_or_disable_ci.md @@ -1,4 +1,4 @@ -## Enable or disable GitLab CI/CD +# How to enable or disable GitLab CI/CD To effectively use GitLab CI/CD, you need a valid [`.gitlab-ci.yml`](yaml/README.md) file present at the root directory of your project and a @@ -21,7 +21,7 @@ individually under each project's settings, or site-wide by modifying the settings in `gitlab.yml` and `gitlab.rb` for source and Omnibus installations respectively. -### Per-project user setting +## Per-project user setting The setting to enable or disable GitLab CI/CD can be found under your project's **Settings > General > Permissions**. Choose one of "Disabled", "Only team members" @@ -29,7 +29,7 @@ or "Everyone with access" and hit **Save changes** for the settings to take effe ![Sharing & Permissions settings](../user/project/settings/img/sharing_and_permissions_settings.png) -### Site-wide admin setting +## Site-wide admin setting You can disable GitLab CI/CD site-wide, by modifying the settings in `gitlab.yml` and `gitlab.rb` for source and Omnibus installations respectively. diff --git a/doc/ci/examples/README.md b/doc/ci/examples/README.md index f094546c3bd..d05b4db953a 100644 --- a/doc/ci/examples/README.md +++ b/doc/ci/examples/README.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # GitLab CI Examples A collection of `.gitlab-ci.yml` files is maintained at the [GitLab CI Yml project][gitlab-ci-templates]. diff --git a/doc/ci/examples/deployment/composer-npm-deploy.md b/doc/ci/examples/deployment/composer-npm-deploy.md index b9f0485290e..bed379b0254 100644 --- a/doc/ci/examples/deployment/composer-npm-deploy.md +++ b/doc/ci/examples/deployment/composer-npm-deploy.md @@ -1,4 +1,4 @@ -## Running Composer and NPM scripts with deployment via SCP +# Running Composer and NPM scripts with deployment via SCP in GitLab CI/CD This guide covers the building dependencies of a PHP project while compiling assets via an NPM script. @@ -39,13 +39,13 @@ In this particular case, the `npm deploy` script is a Gulp script that does the All these operations will put all files into a `build` folder, which is ready to be deployed to a live server. -### How to transfer files to a live server? +## How to transfer files to a live server You have multiple options: rsync, scp, sftp and so on. For now, we will use scp. To make this work, you need to add a GitLab Secret Variable (accessible on _gitlab.example/your-project-name/variables_). That variable will be called `STAGING_PRIVATE_KEY` and it's the **private** ssh key of your server. -#### Security tip +### Security tip Create a user that has access **only** to the folder that needs to be updated! @@ -69,7 +69,7 @@ In order, this means that: And this is basically all you need in the `before_script` section. -## How to deploy things? +## How to deploy things As we stated above, we need to deploy the `build` folder from the docker image to our server. To do so, we create a new job: @@ -88,7 +88,7 @@ stage_deploy: - ssh -p22 server_user@server_host "rm -rf htdocs/wp-content/themes/_old" ``` -### What's going on here? +Here's the breakdown: 1. `only:dev` means that this build will run only when something is pushed to the `dev` branch. You can remove this block completely and have everything be ran on every push (but probably this is something you don't want) 2. `ssh-add ...` we will add that private key you added on the web UI to the docker container @@ -99,7 +99,7 @@ stage_deploy: What's the deal with the artifacts? We just tell GitLab CI to keep the `build` directory (later on, you can download that as needed). -#### Why we do it this way? +### Why we do it this way If you're using this only for stage server, you could do this in two steps: @@ -112,7 +112,7 @@ The problem is that there will be a small period of time when you won't have the So we use so many steps because we want to make sure that at any given time we have a functional app in place. -## Where to go next? +## Where to go next Since this was a WordPress project, I gave real life code snippets. Some ideas you can pursuit: diff --git a/doc/ci/examples/php.md b/doc/ci/examples/php.md index f2dd12b67d3..6768a2e012f 100644 --- a/doc/ci/examples/php.md +++ b/doc/ci/examples/php.md @@ -267,10 +267,10 @@ terminal execute: ```bash # Check using docker executor -gitlab-ci-multi-runner exec docker test:app +gitlab-runner exec docker test:app # Check using shell executor -gitlab-ci-multi-runner exec shell test:app +gitlab-runner exec shell test:app ``` ## Example project diff --git a/doc/ci/examples/test-and-deploy-python-application-to-heroku.md b/doc/ci/examples/test-and-deploy-python-application-to-heroku.md index 73aebaf6d7f..a6ed1c54e16 100644 --- a/doc/ci/examples/test-and-deploy-python-application-to-heroku.md +++ b/doc/ci/examples/test-and-deploy-python-application-to-heroku.md @@ -1,10 +1,13 @@ -## Test and Deploy a python application +# Test and Deploy a python application with GitLab CI/CD + This example will guide you how to run tests in your Python application and deploy it automatically as Heroku application. -You can checkout the example [source](https://gitlab.com/ayufan/python-getting-started) and check [CI status](https://gitlab.com/ayufan/python-getting-started/builds?scope=all). +You can checkout the [example source](https://gitlab.com/ayufan/python-getting-started). + +## Configure project -### Configure project This is what the `.gitlab-ci.yml` file looks like for this project: + ```yaml test: script: @@ -41,23 +44,27 @@ This project has three jobs: 2. `staging` - used to automatically deploy staging environment every push to `master` branch 3. `production` - used to automatically deploy production environmnet for every created tag -### Store API keys +## Store API keys + You'll need to create two variables in `Project > Variables`: 1. `HEROKU_STAGING_API_KEY` - Heroku API key used to deploy staging app, 2. `HEROKU_PRODUCTION_API_KEY` - Heroku API key used to deploy production app. Find your Heroku API key in [Manage Account](https://dashboard.heroku.com/account). -### Create Heroku application +## Create Heroku application + For each of your environments, you'll need to create a new Heroku application. You can do this through the [Dashboard](https://dashboard.heroku.com/). -### Create runner +## Create Runner + First install [Docker Engine](https://docs.docker.com/installation/). -To build this project you also need to have [GitLab Runner](https://about.gitlab.com/gitlab-ci/#gitlab-runner). +To build this project you also need to have [GitLab Runner](https://docs.gitlab.com/runner). You can use public runners available on `gitlab.com`, but you can register your own: + ``` -gitlab-ci-multi-runner register \ +gitlab-runner register \ --non-interactive \ --url "https://gitlab.com/" \ --registration-token "PROJECT_REGISTRATION_TOKEN" \ diff --git a/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md b/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md index 6fa64a67e82..10fd2616fab 100644 --- a/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md +++ b/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md @@ -1,10 +1,13 @@ -## Test and Deploy a ruby application +# Test and Deploy a ruby application with GitLab CI/CD + This example will guide you how to run tests in your Ruby on Rails application and deploy it automatically as Heroku application. You can checkout the example [source](https://gitlab.com/ayufan/ruby-getting-started) and check [CI status](https://gitlab.com/ayufan/ruby-getting-started/builds?scope=all). -### Configure project +## Configure the project + This is what the `.gitlab-ci.yml` file looks like for this project: + ```yaml test: script: @@ -36,23 +39,28 @@ This project has three jobs: 2. `staging` - used to automatically deploy staging environment every push to `master` branch 3. `production` - used to automatically deploy production environment for every created tag -### Store API keys -You'll need to create two variables in `Project > Variables`: +## Store API keys + +You'll need to create two variables in your project's **Settings > CI/CD > Variables**: + 1. `HEROKU_STAGING_API_KEY` - Heroku API key used to deploy staging app, 2. `HEROKU_PRODUCTION_API_KEY` - Heroku API key used to deploy production app. Find your Heroku API key in [Manage Account](https://dashboard.heroku.com/account). -### Create Heroku application +## Create Heroku application + For each of your environments, you'll need to create a new Heroku application. You can do this through the [Dashboard](https://dashboard.heroku.com/). -### Create runner +## Create Runner + First install [Docker Engine](https://docs.docker.com/installation/). To build this project you also need to have [GitLab Runner](https://about.gitlab.com/gitlab-ci/#gitlab-runner). You can use public runners available on `gitlab.com`, but you can register your own: + ``` -gitlab-ci-multi-runner register \ +gitlab-runner register \ --non-interactive \ --url "https://gitlab.com/" \ --registration-token "PROJECT_REGISTRATION_TOKEN" \ @@ -62,6 +70,6 @@ gitlab-ci-multi-runner register \ --docker-postgres latest ``` -With the command above, you create a runner that uses [ruby:2.2](https://hub.docker.com/r/_/ruby/) image and uses [postgres](https://hub.docker.com/r/_/postgres/) database. +With the command above, you create a Runner that uses [ruby:2.2](https://hub.docker.com/r/_/ruby/) image and uses [postgres](https://hub.docker.com/r/_/postgres/) database. To access PostgreSQL database you need to connect to `host: postgres` as user `postgres` without password. diff --git a/doc/ci/examples/test-clojure-application.md b/doc/ci/examples/test-clojure-application.md index 56b746ce025..3b1026d174f 100644 --- a/doc/ci/examples/test-clojure-application.md +++ b/doc/ci/examples/test-clojure-application.md @@ -1,10 +1,10 @@ -## Test a Clojure application +# Test a Clojure application with GitLab CI/CD This example will guide you how to run tests in your Clojure application. You can checkout the example [source](https://gitlab.com/dzaporozhets/clojure-web-application) and check [CI status](https://gitlab.com/dzaporozhets/clojure-web-application/builds?scope=all). -### Configure project +## Configure the project This is what the `.gitlab-ci.yml` file looks like for this project: @@ -23,13 +23,13 @@ before_script: - lein deps - lein migratus migrate -test: - script: +test: + script: - lein test ``` -In before script we install JRE and [Leiningen](http://leiningen.org/). -Sample project uses [migratus](https://github.com/yogthos/migratus) library to manage database migrations. +In before script we install JRE and [Leiningen](http://leiningen.org/). +Sample project uses [migratus](https://github.com/yogthos/migratus) library to manage database migrations. So we added database migration as last step of `before_script` section You can use public runners available on `gitlab.com` for testing your application with such configuration. diff --git a/doc/ci/examples/test-phoenix-application.md b/doc/ci/examples/test-phoenix-application.md index 150698ca04b..f6c81b076bc 100644 --- a/doc/ci/examples/test-phoenix-application.md +++ b/doc/ci/examples/test-phoenix-application.md @@ -1,9 +1,9 @@ -## Test a Phoenix application +# Test a Phoenix application with GitLab CI/CD This example demonstrates the integration of Gitlab CI with Phoenix, Elixir and Postgres. -### Add `.gitlab-ci.yml` file to project +## Add `.gitlab-ci.yml` to project The following `.gitlab-ci.yml` should be added in the root of your repository to trigger CI: @@ -36,7 +36,7 @@ run your migrations. Finally, the test `script` will run your tests. -### Update the Config Settings +## Update the Config Settings In `config/test.exs`, update the database hostname: @@ -45,12 +45,12 @@ config :my_app, MyApp.Repo, hostname: if(System.get_env("CI"), do: "postgres", else: "localhost"), ``` -### Add the Migrations Folder +## Add the Migrations Folder If you do not have any migrations yet, you will need to create an empty `.gitkeep` file in `priv/repo/migrations`. -### Sources +## Sources - https://medium.com/@nahtnam/using-phoenix-on-gitlab-ci-5a51eec81142 - https://davejlong.com/ci-with-phoenix-and-gitlab/ diff --git a/doc/ci/git_submodules.md b/doc/ci/git_submodules.md index 36c6e153d95..c83d3f6f248 100644 --- a/doc/ci/git_submodules.md +++ b/doc/ci/git_submodules.md @@ -61,7 +61,7 @@ correctly with your CI jobs: 1. First, make sure you have used [relative URLs](#configuring-the-gitmodules-file) for the submodules located in the same GitLab server. -1. Next, if you are using `gitlab-ci-multi-runner` v1.10+, you can set the +1. Next, if you are using `gitlab-runner` v1.10+, you can set the `GIT_SUBMODULE_STRATEGY` variable to either `normal` or `recursive` to tell the runner to fetch your submodules before the job: ```yaml @@ -71,7 +71,7 @@ correctly with your CI jobs: See the [`.gitlab-ci.yml` reference](yaml/README.md#git-submodule-strategy) for more details about `GIT_SUBMODULE_STRATEGY`. -1. If you are using an older version of `gitlab-ci-multi-runner`, then use +1. If you are using an older version of `gitlab-runner`, then use `git submodule sync/update` in `before_script`: ```yaml diff --git a/doc/ci/permissions/README.md b/doc/ci/permissions/README.md index 42eb59f84c8..80d8e46f29c 100644 --- a/doc/ci/permissions/README.md +++ b/doc/ci/permissions/README.md @@ -1,3 +1 @@ -# Users Permissions - This document was moved to [user/permissions.md](../../user/permissions.md#gitlab-ci). diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md index 2d56b2540ef..f621bf07251 100644 --- a/doc/ci/quick_start/README.md +++ b/doc/ci/quick_start/README.md @@ -1,4 +1,4 @@ -# Getting started with GitLab CI +# Getting started with GitLab CI/CD >**Note:** Starting from version 8.0, GitLab [Continuous Integration][ci] (CI) is fully integrated into GitLab itself and is [enabled] by default on all diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md index 8b51d112a2c..df66810a838 100644 --- a/doc/ci/runners/README.md +++ b/doc/ci/runners/README.md @@ -1,4 +1,4 @@ -# Runners +# Configuring GitLab Runners In GitLab CI, Runners run the code defined in [`.gitlab-ci.yml`](../yaml/README.md). They are isolated (virtual) machines that pick up jobs through the coordinator diff --git a/doc/ci/services/README.md b/doc/ci/services/README.md index 4b79461d55c..d94b472b768 100644 --- a/doc/ci/services/README.md +++ b/doc/ci/services/README.md @@ -1,4 +1,8 @@ -## GitLab CI Services +--- +comments: false +--- + +# GitLab CI Services GitLab CI uses the `services` keyword to define what docker containers should be linked with your base image. Below is a list of examples you may use. diff --git a/doc/ci/services/docker-services.md b/doc/ci/services/docker-services.md index df36ebaf7d4..787c5e462e4 100644 --- a/doc/ci/services/docker-services.md +++ b/doc/ci/services/docker-services.md @@ -1,5 +1,9 @@ -## GitLab CI Services +--- +comments: false +--- -+ [Using MySQL](mysql.md) -+ [Using PostgreSQL](postgres.md) -+ [Using Redis](redis.md) +# GitLab CI Services + +- [Using MySQL](mysql.md) +- [Using PostgreSQL](postgres.md) +- [Using Redis](redis.md) diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 73568757aaa..a9e6bda9916 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -1,4 +1,4 @@ -# Variables +# GitLab CI/CD Variables When receiving a job from GitLab CI, the [Runner] prepares the build environment. It starts by setting a list of **predefined variables** (environment variables) @@ -43,7 +43,7 @@ future GitLab releases.** | **CI_COMMIT_TAG** | 9.0 | 0.5 | The commit tag name. Present only when building tags. | | **CI_CONFIG_PATH** | 9.4 | 0.5 | The path to CI config file. Defaults to `.gitlab-ci.yml` | | **CI_DEBUG_TRACE** | all | 1.7 | Whether [debug tracing](#debug-tracing) is enabled | -| **CI_DISPOSABLE_ENVIRONMENT** | all | 10.1 | Mark that job is executed in a disposable environment (something that is created only for this job and disposed of/destroyed after the execution - all executors except `shell` and `ssh`). If the environment is disposable, it is set to true, otherwise it is not defined at all. | +| **CI_DISPOSABLE_ENVIRONMENT** | all | 10.1 | Marks that the job is executed in a disposable environment (something that is created only for this job and disposed of/destroyed after the execution - all executors except `shell` and `ssh`). If the environment is disposable, it is set to true, otherwise it is not defined at all. | | **CI_ENVIRONMENT_NAME** | 8.15 | all | The name of the environment for this job | | **CI_ENVIRONMENT_SLUG** | 8.15 | all | A simplified version of the environment name, suitable for inclusion in DNS, URLs, Kubernetes labels, etc. | | **CI_ENVIRONMENT_URL** | 9.3 | all | The URL of the environment for this job | @@ -66,6 +66,7 @@ future GitLab releases.** | **CI_PROJECT_PATH** | 8.10 | 0.5 | The namespace with project name | | **CI_PROJECT_PATH_SLUG** | 9.3 | all | `$CI_PROJECT_PATH` lowercased and with everything except `0-9` and `a-z` replaced with `-`. Use in URLs and domain names. | | **CI_PROJECT_URL** | 8.10 | 0.5 | The HTTP address to access project | +| **CI_PROJECT_VISIBILITY** | 10.3 | all | The project visibility (internal, private, public) | | **CI_REGISTRY** | 8.10 | 0.5 | If the Container Registry is enabled it returns the address of GitLab's Container Registry | | **CI_REGISTRY_IMAGE** | 8.10 | 0.5 | If the Container Registry is enabled for the project it returns the address of the registry tied to the specific project | | **CI_REGISTRY_PASSWORD** | 9.0 | all | The password to use to push containers to the GitLab Container Registry | @@ -74,7 +75,7 @@ future GitLab releases.** | **CI_SERVER_NAME** | all | all | The name of CI server that is used to coordinate jobs | | **CI_SERVER_REVISION** | all | all | GitLab revision that is used to schedule jobs | | **CI_SERVER_VERSION** | all | all | GitLab version that is used to schedule jobs | -| **CI_SHARED_ENVIRONMENT** | all | 10.1 | Mark that job is executed in a shared environment (something that is persisted across CI invocations like `shell` or `ssh` executor). If the environment is shared, it is set to true, otherwise it is not defined at all. | +| **CI_SHARED_ENVIRONMENT** | all | 10.1 | Marks that the job is executed in a shared environment (something that is persisted across CI invocations like `shell` or `ssh` executor). If the environment is shared, it is set to true, otherwise it is not defined at all. | | **ARTIFACT_DOWNLOAD_ATTEMPTS** | 8.15 | 1.9 | Number of attempts to download artifacts running a job | | **GET_SOURCES_ATTEMPTS** | 8.15 | 1.9 | Number of attempts to fetch sources running a job | | **GITLAB_CI** | all | all | Mark that job is executed in GitLab CI environment | diff --git a/doc/development/README.md b/doc/development/README.md index 36096842344..0cafc112b6b 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # GitLab development guides ## Get started! diff --git a/doc/development/doc_styleguide.md b/doc/development/doc_styleguide.md index 798f40eef3d..0e4ffbd7910 100644 --- a/doc/development/doc_styleguide.md +++ b/doc/development/doc_styleguide.md @@ -459,11 +459,11 @@ Rendered example: ### cURL commands - Use `https://gitlab.example.com/api/v4/` as an endpoint. -- Wherever needed use this private token: `9koXpg98eAheJpvBs5tK`. +- Wherever needed use this personal access token: `9koXpg98eAheJpvBs5tK`. - Always put the request first. `GET` is the default so you don't have to include it. - Use double quotes to the URL when it includes additional parameters. -- Prefer to use examples using the private token and don't pass data of +- Prefer to use examples using the personal access token and don't pass data of username and password. | Methods | Description | diff --git a/doc/development/ee_features.md b/doc/development/ee_features.md new file mode 100644 index 00000000000..932a44f65e4 --- /dev/null +++ b/doc/development/ee_features.md @@ -0,0 +1,382 @@ +# Guidelines for implementing Enterprise Edition feature + +- **Write the code and the tests.**: As with any code, EE features should have + good test coverage to prevent regressions. +- **Write documentation.**: Add documentation to the `doc/` directory. Describe + the feature and include screenshots, if applicable. +- **Submit a MR to the `www-gitlab-com` project.**: Add the new feature to the + [EE features list][ee-features-list]. + +## Act as CE when unlicensed + +Since the implementation of [GitLab CE features to work with unlicensed EE instance][ee-as-ce] +GitLab Enterprise Edition should work like GitLab Community Edition +when no license is active. So EE features always should be guarded by +`project.feature_available?` or `group.feature_available?` (or +`License.feature_available?` if it is a system-wide feature). + +CE specs should remain untouched as much as possible and extra specs +should be added for EE. Licensed features can be stubbed using the +spec helper `stub_licensed_features` in `EE::LicenseHelpers`. + +[ee-as-ce]: https://gitlab.com/gitlab-org/gitlab-ee/issues/2500 + +## Separation of EE code + +We want a [single code base][] eventually, but before we reach the goal, +we still need to merge changes from GitLab CE to EE. To help us get there, +we should make sure that we no longer edit CE files in place in order to +implement EE features. + +Instead, all EE codes should be put inside the `ee/` top-level directory, and +tests should be put inside `spec/ee/`. We don't use `ee/spec` for now due to +technical limitation. The rest of codes should be as close as to the CE files. + +[single code base]: https://gitlab.com/gitlab-org/gitlab-ee/issues/2952#note_41016454 + +### EE-only features + +If the feature being developed is not present in any form in CE, we don't +need to put the codes under `EE` namespace. For example, an EE model could +go into: `ee/app/models/awesome.rb` using `Awesome` as the class name. This +is applied not only to models. Here's a list of other examples: + +- `ee/app/controllers/foos_controller.rb` +- `ee/app/finders/foos_finder.rb` +- `ee/app/helpers/foos_helper.rb` +- `ee/app/mailers/foos_mailer.rb` +- `ee/app/models/foo.rb` +- `ee/app/policies/foo_policy.rb` +- `ee/app/serializers/foo_entity.rb` +- `ee/app/serializers/foo_serializer.rb` +- `ee/app/services/foo/create_service.rb` +- `ee/app/validators/foo_attr_validator.rb` +- `ee/app/workers/foo_worker.rb` + +### EE features based on CE features + +For features that build on existing CE features, write a module in the +`EE` namespace and `prepend` it in the CE class. This makes conflicts +less likely to happen during CE to EE merges because only one line is +added to the CE class - the `prepend` line. + +Since the module would require an `EE` namespace, the file should also be +put in an `ee/` sub-directory. For example, we want to extend the user model +in EE, so we have a module called `::EE::User` put inside +`ee/app/models/ee/user.rb`. + +This is also not just applied to models. Here's a list of other examples: + +- `ee/app/controllers/ee/foos_controller.rb` +- `ee/app/finders/ee/foos_finder.rb` +- `ee/app/helpers/ee/foos_helper.rb` +- `ee/app/mailers/ee/foos_mailer.rb` +- `ee/app/models/ee/foo.rb` +- `ee/app/policies/ee/foo_policy.rb` +- `ee/app/serializers/ee/foo_entity.rb` +- `ee/app/serializers/ee/foo_serializer.rb` +- `ee/app/services/ee/foo/create_service.rb` +- `ee/app/validators/ee/foo_attr_validator.rb` +- `ee/app/workers/ee/foo_worker.rb` + +#### Overriding CE methods + +To override a method present in the CE codebase, use `prepend`. It +lets you override a method in a class with a method from a module, while +still having access the class's implementation with `super`. + +There are a few gotchas with it: + +- you should always add a `raise NotImplementedError unless defined?(super)` + guard clause in the "overrider" method to ensure that if the method gets + renamed in CE, the EE override won't be silently forgotten. +- when the "overrider" would add a line in the middle of the CE + implementation, you should refactor the CE method and split it in + smaller methods. Or create a "hook" method that is empty in CE, + and with the EE-specific implementation in EE. +- when the original implementation contains a guard clause (e.g. + `return unless condition`), we cannot easily extend the behaviour by + overriding the method, because we can't know when the overridden method + (i.e. calling `super` in the overriding method) would want to stop early. + In this case, we shouldn't just override it, but update the original method + to make it call the other method we want to extend, like a [template method + pattern](https://en.wikipedia.org/wiki/Template_method_pattern). + For example, given this base: + ``` ruby + class Base + def execute + return unless enabled? + + # ... + # ... + end + end + ``` + Instead of just overriding `Base#execute`, we should update it and extract + the behaviour into another method: + ``` ruby + class Base + def execute + return unless enabled? + + do_something + end + + private + + def do_something + # ... + # ... + end + end + ``` + Then we're free to override that `do_something` without worrying about the + guards: + ``` ruby + module EE::Base + def do_something + # Follow the above pattern to call super and extend it + end + end + ``` + This would require updating CE first, or make sure this is back ported to CE. + +When prepending, place them in the `ee/` specific sub-directory, and +wrap class or module in `module EE` to avoid naming conflicts. + +For example to override the CE implementation of +`ApplicationController#after_sign_out_path_for`: + +```ruby +def after_sign_out_path_for(resource) + current_application_settings.after_sign_out_path.presence || new_user_session_path +end +``` + +Instead of modifying the method in place, you should add `prepend` to +the existing file: + +```ruby +class ApplicationController < ActionController::Base + prepend EE::ApplicationController + # ... + + def after_sign_out_path_for(resource) + current_application_settings.after_sign_out_path.presence || new_user_session_path + end + + # ... +end +``` + +And create a new file in the `ee/` sub-directory with the altered +implementation: + +```ruby +module EE + class ApplicationController + def after_sign_out_path_for(resource) + raise NotImplementedError unless defined?(super) + + if Gitlab::Geo.secondary? + Gitlab::Geo.primary_node.oauth_logout_url(@geo_logout_state) + else + super + end + end + end +end +``` + +#### Use self-descriptive wrapper methods + +When it's not possible/logical to modify the implementation of a +method. Wrap it in a self-descriptive method and use that method. + +For example, in CE only an `admin` is allowed to access all private +projects/groups, but in EE also an `auditor` has full private +access. It would be incorrect to override the implementation of +`User#admin?`, so instead add a method `full_private_access?` to +`app/models/users.rb`. The implementation in CE will be: + +```ruby +def full_private_access? + admin? +end +``` + +In EE, the implementation `ee/app/models/ee/users.rb` would be: + +```ruby +def full_private_access? + raise NotImplementedError unless defined?(super) + super || auditor? +end +``` + +In `lib/gitlab/visibility_level.rb` this method is used to return the +allowed visibilty levels: + +```ruby +def levels_for_user(user = nil) + if user.full_private_access? + [PRIVATE, INTERNAL, PUBLIC] + elsif # ... +end +``` + +See [CE MR][ce-mr-full-private] and [EE MR][ee-mr-full-private] for +full implementation details. + +[ce-mr-full-private]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12373 +[ee-mr-full-private]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/2199 + +### Code in `app/controllers/` + +In controllers, the most common type of conflict is with `before_action` that +has a list of actions in CE but EE adds some actions to that list. + +The same problem often occurs for `params.require` / `params.permit` calls. + +**Mitigations** + +Separate CE and EE actions/keywords. For instance for `params.require` in +`ProjectsController`: + +```ruby +def project_params + params.require(:project).permit(project_params_attributes) +end + +# Always returns an array of symbols, created however best fits the use case. +# It _should_ be sorted alphabetically. +def project_params_attributes + %i[ + description + name + path + ] +end + +``` + +In the `EE::ProjectsController` module: + +```ruby +def project_params_attributes + super + project_params_attributes_ee +end + +def project_params_attributes_ee + %i[ + approvals_before_merge + approver_group_ids + approver_ids + ... + ] +end +``` + +### Code in `app/models/` + +EE-specific models should `extend EE::Model`. + +For example, if EE has a specific `Tanuki` model, you would +place it in `ee/app/models/ee/tanuki.rb`. + +### Code in `app/views/` + +It's a very frequent problem that EE is adding some specific view code in a CE +view. For instance the approval code in the project's settings page. + +**Mitigations** + +Blocks of code that are EE-specific should be moved to partials. This +avoids conflicts with big chunks of HAML code that that are not fun to +resolve when you add the indentation to the equation. + +EE-specific views should be placed in `ee/app/views/ee/`, using extra +sub-directories if appropriate. + +### Code in `lib/` + +Place EE-specific logic in the top-level `EE` module namespace. Namespace the +class beneath the `EE` module just as you would normally. + +For example, if CE has LDAP classes in `lib/gitlab/ldap/` then you would place +EE-specific LDAP classes in `ee/lib/ee/gitlab/ldap`. + +### Code in `spec/` + +When you're testing EE-only features, avoid adding examples to the +existing CE specs. Also do no change existing CE examples, since they +should remain working as-is when EE is running without a license. + +Instead place EE specs in the `spec/ee/spec` folder. + +## JavaScript code in `assets/javascripts/` + +To separate EE-specific JS-files we can also move the files into an `ee` folder. + +For example there can be an +`app/assets/javascripts/protected_branches/protected_branches_bundle.js` and an +EE counterpart +`ee/app/assets/javascripts/protected_branches/protected_branches_bundle.js`. + +That way we can create a separate webpack bundle in `webpack.config.js`: + +```javascript + protected_branches: '~/protected_branches', + ee_protected_branches: 'ee/protected_branches/protected_branches_bundle.js', +``` + +With the separate bundle in place, we can decide which bundle to load inside the +view, using the `page_specific_javascript_bundle_tag` helper. + +```haml +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('protected_branches') +``` + +## SCSS code in `assets/stylesheets` + +To separate EE-specific styles in SCSS files, if a component you're adding styles for +is limited to only EE, it is better to have a separate SCSS file in appropriate directory +within `app/assets/stylesheets`. + +In some cases, this is not entirely possible or creating dedicated SCSS file is an overkill, +e.g. a text style of some component is different for EE. In such cases, +styles are usually kept in stylesheet that is common for both CE and EE, and it is wise +to isolate such ruleset from rest of CE rules (along with adding comment describing the same) +to avoid conflicts during CE to EE merge. + +#### Bad +```scss +.section-body { + .section-title { + background: $gl-header-color; + } + + &.ee-section-body { + .section-title { + background: $gl-header-color-cyan; + } + } +} +``` + +#### Good +```scss +.section-body { + .section-title { + background: $gl-header-color; + } +} + +/* EE-specific styles */ +.section-body.ee-section-body { + .section-title { + background: $gl-header-color-cyan; + } +} +``` diff --git a/doc/development/fe_guide/index.md b/doc/development/fe_guide/index.md index 73366eb9f3f..8f956681693 100644 --- a/doc/development/fe_guide/index.md +++ b/doc/development/fe_guide/index.md @@ -106,6 +106,10 @@ Frontend security practices. ## [Accessibility](accessibility.md) Our accessibility standards and resources. +## [Internationalization (i18n) and Translations](../i18n/externalization.md) +Frontend internationalization support is described in [this document](../i18n/). +The [externalization part of the guide](../i18n/externalization.md) explains the helpers/methods available. + [rails]: http://rubyonrails.org/ [haml]: http://haml.info/ diff --git a/doc/development/i18n/externalization.md b/doc/development/i18n/externalization.md index 167260b6e0e..7c38260406d 100644 --- a/doc/development/i18n/externalization.md +++ b/doc/development/i18n/externalization.md @@ -180,15 +180,43 @@ aren't in the message with id `1 pipeline`. ## Working with special content + +### Just marking content for parsing + +- In Ruby/HAML: + + ```ruby + _('Subscribe') + ``` + +- In JavaScript: + + ```js + import { __ } from '../../../locale'; + const label = __('Subscribe'); + ``` + + +Sometimes there are some dynamic translations that can't be found by the +parser when running `bundle exec rake gettext:find`. For these scenarios you can +use the [`_N` method](https://github.com/grosser/gettext_i18n_rails/blob/c09e38d481e0899ca7d3fc01786834fa8e7aab97/Readme.md#unfound-translations-with-rake-gettextfind). + +There is also and alternative method to [translate messages from validation errors](https://github.com/grosser/gettext_i18n_rails/blob/c09e38d481e0899ca7d3fc01786834fa8e7aab97/Readme.md#option-a). + ### Interpolation - In Ruby/HAML: ```ruby - _("Hello %{name}") % { name: 'Joe' } + _("Hello %{name}") % { name: 'Joe' } => 'Hello Joe' ``` -- In JavaScript: Not supported at this moment. +- In JavaScript: + + ```js + import { __, sprintf } from '../../../locale'; + sprintf(__('Hello %{username}'), { username: 'Joe' }) => 'Hello Joe' + ``` ### Plurals @@ -234,14 +262,6 @@ Sometimes you need to add some context to the text that you want to translate s__('OpenedNDaysAgo|Opened') ``` -### Just marking content for parsing - -Sometimes there are some dynamic translations that can't be found by the -parser when running `bundle exec rake gettext:find`. For these scenarios you can -use the [`_N` method](https://github.com/grosser/gettext_i18n_rails/blob/c09e38d481e0899ca7d3fc01786834fa8e7aab97/Readme.md#unfound-translations-with-rake-gettextfind). - -There is also and alternative method to [translate messages from validation errors](https://github.com/grosser/gettext_i18n_rails/blob/c09e38d481e0899ca7d3fc01786834fa8e7aab97/Readme.md#option-a). - ## Adding a new language Let's suppose you want to add translations for a new language, let's say French. diff --git a/doc/development/testing_guide/best_practices.md b/doc/development/testing_guide/best_practices.md index 7ddd02e6c73..8b7b015427f 100644 --- a/doc/development/testing_guide/best_practices.md +++ b/doc/development/testing_guide/best_practices.md @@ -60,6 +60,35 @@ writing one](testing_levels.md#consider-not-writing-a-system-test)! - It's ok to look for DOM elements but don't abuse it since it makes the tests more brittle +#### Debugging Capybara + +Sometimes you may need to debug Capybara tests by observing browser behavior. + +You can pause Capybara and view the website on the browser by using the +`live_debug` method in your spec. The current page will be automatically opened +in your default browser. +You may need to sign in first (the current user's credentials are displayed in +the terminal). + +To resume the test run, press any key. + +For example: + +``` +$ bin/rspec spec/features/auto_deploy_spec.rb:34 +Running via Spring preloader in process 8999 +Run options: include {:locations=>{"./spec/features/auto_deploy_spec.rb"=>[34]}} + +Current example is paused for live debugging +The current user credentials are: user2 / 12345678 +Press any key to resume the execution of the example! +Back to the example! +. + +Finished in 34.51 seconds (files took 0.76702 seconds to load) +1 example, 0 failures +``` + ### `let` variables GitLab's RSpec suite has made extensive use of `let` variables to reduce diff --git a/doc/development/testing_guide/testing_levels.md b/doc/development/testing_guide/testing_levels.md index 9b9ba0baa71..1cbd4350284 100644 --- a/doc/development/testing_guide/testing_levels.md +++ b/doc/development/testing_guide/testing_levels.md @@ -126,7 +126,7 @@ always in-sync with the codebase. [GitLab Workhorse]: https://gitlab.com/gitlab-org/gitlab-workhorse [Gitaly]: https://gitlab.com/gitlab-org/gitaly [GitLab Pages]: https://gitlab.com/gitlab-org/gitlab-pages -[GitLab Runner]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner +[GitLab Runner]: https://gitlab.com/gitlab-org/gitlab-runner [GitLab Omnibus]: https://gitlab.com/gitlab-org/omnibus-gitlab [GitLab QA]: https://gitlab.com/gitlab-org/gitlab-qa [part of GitLab Rails]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/qa diff --git a/doc/development/testing_guide/testing_rake_tasks.md b/doc/development/testing_guide/testing_rake_tasks.md index 5bf185dd7b5..60163f1a230 100644 --- a/doc/development/testing_guide/testing_rake_tasks.md +++ b/doc/development/testing_guide/testing_rake_tasks.md @@ -1,4 +1,4 @@ -## Testing Rake tasks +# Testing Rake tasks To make testing Rake tasks a little easier, there is a helper that can be included in lieu of the standard Spec helper. Instead of `require 'spec_helper'`, use diff --git a/doc/development/ux_guide/users.md b/doc/development/ux_guide/users.md index cbd7c17de41..fce882a45f1 100644 --- a/doc/development/ux_guide/users.md +++ b/doc/development/ux_guide/users.md @@ -1,4 +1,5 @@ -## UX Personas +# UX Personas + * [Nazim Ramesh](#nazim-ramesh) - Small to medium size organisations using GitLab CE * [James Mackey](#james-mackey) @@ -7,16 +8,16 @@ * [Karolina Plaskaty](#karolina-plaskaty) - Using GitLab.com for personal/hobby projects - Would like to use GitLab at work - - Working for a medium to large size organisation + - Working for a medium to large size organisation -<hr> +--- -### Nazim Ramesh +## Nazim Ramesh - Small to medium size organisations using GitLab CE <img src="img/nazim-ramesh.png" width="300px"> -#### Demographics +### Demographics - **Age**<br>32 years old - **Location**<br>Germany @@ -26,7 +27,7 @@ - **Frequently used programming languages**<br>JavaScript, SQL, PHP - **Hobbies / interests**<br>Functional programming, open source, gaming, web development and web security. -#### Motivations +### Motivations Nazim works for a software development company which currently hires around 80 people. When Nazim first joined the company, the engineering team were using Subversion (SVN) as their primary form of source control. However, Nazim felt SVN was not flexible enough to work with many feature branches and noticed that developers with less experience of source control struggled with the central-repository nature of SVN. Armed with a wishlist of features, Nazim began comparing source control tools. A search for “self-hosted Git server repository management†returned GitLab. In his own words, Nazim explains why he wanted the engineering team to start using GitLab: > @@ -39,48 +40,48 @@ In his role as a full-stack web developer, Nazim could recommend products that h “The biggest challenge...why should we change anything at all from the status quo? We needed to switch from SVN to Git. They knew they needed to learn Git and a Git workflow...using Git was scary to my colleagues...they thought it was more complex than SVN to use.†> -Undeterred, Nazim decided to migrate a couple of projects across to GitLab. +Undeterred, Nazim decided to migrate a couple of projects across to GitLab. > “Old SVN users couldn’t see the benefits of Git at first. It took a month or two to convince them.†> -Slowly, by showing his colleagues how easy it was to use Git, the majority of the team’s projects were migrated to GitLab. +Slowly, by showing his colleagues how easy it was to use Git, the majority of the team’s projects were migrated to GitLab. -The engineering team have been using GitLab CE for around 2 years now. Nazim credits himself as being entirely responsible for his company’s decision to move to GitLab. +The engineering team have been using GitLab CE for around 2 years now. Nazim credits himself as being entirely responsible for his company’s decision to move to GitLab. -#### Frustrations -##### Adoption to GitLab has been slow +### Frustrations +#### Adoption to GitLab has been slow Not only has the engineering team had to get to grips with Git, they’ve also had to adapt to using GitLab. Due to lack of training and existing skills in other tools, the full feature set of GitLab CE is not being utilised. Nazim sold GitLab to his manager as an ‘all in one’ tool which would replace multiple tools used within the company, thus saving costs. Nazim hasn’t had the time to integrate the legacy tools to GitLab and he’s struggling to convince his peers to change their habits. -##### Missing Features +#### Missing Features Nazim’s company want GitLab to be able to do everything. There isn’t a large budget for software, so they’re selective about what tools are implemented. It needs to add real value to the company. In order for GitLab to be widely adopted and to meet the requirements of different roles within the company, it needs a host of features. When an individual within Nazim’s company wants to know if GitLab has a specific feature or does a particular thing, Nazim is the person to ask. He becomes the point of contact to investigate, build or sometimes just raise the feature request. Nazim gets frustrated when GitLab isn’t able to do what he or his colleagues need it to do. -##### Regressions and bugs +#### Regressions and bugs Nazim often has to calm down his colleagues, when a release contains regressions or new bugs. As he puts it “every new version adds something awesome, but breaks somethingâ€. He feels that “old issues for "minor" annoyances get quickly buried in the mass of open issues and linger for a very long time. More generally, I have the feeling that GitLab focus on adding new functionalities, but overlook a bunch of annoying minor regressions or introduced bugs.†Due to limited resource and expertise within the team, not only is it difficult to remain up-to-date with the frequent release cycle, it’s also counterproductive to fix workflows every month. -##### Uses too much RAM and CPU +#### Uses too much RAM and CPU > “Memory usages mean that if we host it from a cloud based host like AWS, we spend almost as much on the instance as what we would pay GitHub†> -##### UI/UX +#### UI/UX GitLab’s interface initially attracted Nazim when he was comparing version control software. He thought it would help his less technical colleagues to adapt to using Git and perhaps, GitLab could be rolled out to other areas of the business, beyond engineering. However, using GitLab’s interface daily has left him frustrated at the lack of personalisation / control over his user experience. He’s also regularly lost in a maze of navigation. Whilst he acknowledges that GitLab listens to its users and that the interface is improving, he becomes annoyed when the changes are too progressive. “Too frequent UI changes. Most of them tend to turn out great after a few cycles of fixes, but the frequency is still far too high for me to feel comfortable to always stay on the current release.†-#### Goals +### Goals * To convince his colleagues to fully adopt GitLab CE, thus improving workflow and collaboration. * To use a feature rich version control platform that covers all stages of the development lifecycle, in order to reduce dependencies on other tools. * To use an intuitive and stable product, so he can spend more time on his core job responsibilities and less time bug-fixing, guiding colleagues, etc. -<hr> +--- -### James Mackey +## James Mackey - Medium to large size organisations using CE or EE - Small organisations using EE <img src="img/james-mackey.png" width="300px"> -#### Demographics +### Demographics - **Age**<br>36 years old - **Location**<br>US @@ -90,7 +91,7 @@ GitLab’s interface initially attracted Nazim when he was comparing version con - **Frequently used programming languages**<br>JavaScript, SQL, Node.js, Java, PHP, Python - **Hobbies / interests**<br>DevOps, open source, web development, science, automation and electronics. -#### Motivations +### Motivations James works for a research company which currently hires around 800 staff. He began using GitLab.com back in 2013 for his own open source, hobby projects and loved “the simplicity of installation, administration and useâ€. After using GitLab for over a year, he began to wonder about using it at work. James explains: > @@ -99,7 +100,7 @@ James works for a research company which currently hires around 800 staff. He be James and his colleagues also reviewed competitor products including GitHub Enterprise, but they found it “less innovative and with considerable costs...GitLab had the features we wanted at a much lower cost per head than GitHubâ€. -The company James works for provides employees with a discretionary budget to spend how they want on software, so James and his team decided to upgrade to EE. +The company James works for provides employees with a discretionary budget to spend how they want on software, so James and his team decided to upgrade to EE. James feels partially responsible for his organisation’s decision to start using GitLab. @@ -107,33 +108,33 @@ James feels partially responsible for his organisation’s decision to start usi “It's still up to the teams themselves [to decide] which tools to use. We just had a great experience moving our daily development to GitLab, so other teams have followed the path or are thinking about switching.†> -#### Frustrations -##### Third Party Integration +### Frustrations +#### Third Party Integration Some of GitLab EE’s features are too basic, in particular, issues boards which do not have the level of reporting that James and his team need. Subsequently, they still need to use GitLab EE in conjunction with other tools, such as JIRA. Whilst James feels it isn’t essential for GitLab to meet all his needs (his company are happy for him to use, and pay for, multiple tools), he sometimes isn’t sure what is/isn’t possible with plugins and what level of custom development he and his team will need to do. -##### UX/UI +#### UX/UI James and his team use CI quite heavily for several projects. Whilst they’ve welcomed improvements to the builds and pipelines interface, they still have some difficulty following build process on the different tabs under Pipelines. Some confusion has arisen from not knowing where to find different pieces of information or how to get to the next stages logs from the current stage’s log output screen. They feel more intuitive linking and flow may alleviate the problem. Generally, they feel GitLab’s navigation needs to reviewed and optimised. -##### Permissions +#### Permissions > “There is no granular control over user or group permissions. The permissions for a project are too tightly coupled to the permissions for Gitlab CI/build pipelines.†> -#### Goals +### Goals * To be able to integrate third party tools easily with GitLab EE and to create custom integrations and patches where needed. * To use GitLab EE primarily for code hosting, merge requests, continuous integration and issue management. James and his team want to be able to understand and use these particular features easily. * To able to share one instance of GitLab EE with multiple teams across the business. Advanced user management, the ability to separate permissions on different parts of the source code, etc are important to James. -<hr> +--- -### Karolina Plaskaty +## Karolina Plaskaty - Using GitLab.com for personal/hobby projects - Would like to use GitLab at work -- Working for a medium to large size organisation +- Working for a medium to large size organisation <img src="img/karolina-plaskaty.png" width="300px"> -#### Demographics +### Demographics - **Age**<br>26 years old - **Location**<br>UK @@ -143,22 +144,22 @@ James and his team use CI quite heavily for several projects. Whilst they’ve w - **Frequently used programming languages**<br>JavaScript and SQL - **Hobbies / interests**<br>Web development, mobile development, UX, open source, gaming and travel. -#### Motivations +### Motivations Karolina has been using GitLab.com for around a year. She roughly spends 8 hours every week programming, of that, 2 hours is spent contributing to open source projects. Karolina contributes to open source projects to gain programming experience and to give back to the community. She likes GitLab.com for its free private repositories and range of features which provide her with everything she needs for her personal projects. Karolina is also a massive fan of GitLab’s values and the fact that it isn’t a “behemoth of a companyâ€. She explains that “displaying every single thing (doc, culture, assumptions, development...) in the open gives me greater confidence to choose Gitlab personally and to recommend it at work.†She’s also an avid reader of GitLab’s blog. Karolina works for a software development company which currently hires around 500 people. Karolina would love to use GitLab at work but the company has used GitHub Enterprise for a number of years. She describes management at her company as “old fashioned†and explains that it’s “less of a technical issue and more of a cultural issue†to convince upper management to move to GitLab. Karolina is also relatively new to the company so she’s apprehensive about pushing too hard to change version control platforms. -#### Frustrations -##### Unable to use GitLab at work +### Frustrations +#### Unable to use GitLab at work Karolina wants to use GitLab at work but isn’t sure how to approach the subject with management. In her current role, she doesn’t feel that she has the authority to request GitLab. -##### Performance +#### Performance GitLab.com is frequently slow and unavailable. Karolina has also heard that GitLab is a “memory hog†which has deterred her from running GitLab on her own machine for just hobby / personal projects. -##### UX/UI +#### UX/UI Karolina has an interest in UX and therefore has strong opinions about how GitLab should look and feel. She feels the interface is cluttered, “it has too many links/buttons†and the navigation “feels a bit weird sometimes. I get lost if I don’t pay attention.†As Karolina also enjoys contributing to open-source projects, it’s important to her that GitLab is well designed for public repositories, she doesn’t feel that GitLab currently achieves this. -#### Goals +### Goals * To develop her programming experience and to learn from other developers. * To contribute to both her own and other open source projects. -* To use a fast and intuitive version control platform.
\ No newline at end of file +* To use a fast and intuitive version control platform. diff --git a/doc/gitlab-basics/README.md b/doc/gitlab-basics/README.md index 3d893ba53dd..4e15f7cfd49 100644 --- a/doc/gitlab-basics/README.md +++ b/doc/gitlab-basics/README.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # GitLab basics Step-by-step guides on the basics of working with Git and GitLab. diff --git a/doc/gitlab-basics/add-merge-request.md b/doc/gitlab-basics/add-merge-request.md index bf01fe51dc3..5cc014419ad 100644 --- a/doc/gitlab-basics/add-merge-request.md +++ b/doc/gitlab-basics/add-merge-request.md @@ -3,31 +3,28 @@ Merge requests are useful to integrate separate changes that you've made to a project, on different branches. This is a brief guide on how to create a merge request. For more information, check the -[merge requests documentation](../user/project/merge_requests.md). +[merge requests documentation](../user/project/merge_requests/index.md). --- 1. Before you start, you should have already [created a branch](create-branch.md) and [pushed your changes](basic-git-commands.md) to GitLab. - -1. You can then go to the project where you'd like to merge your changes and - click on the **Merge requests** tab. - - ![Merge requests](img/project_navbar.png) - +1. Go to the project where you'd like to merge your changes and click on the + **Merge requests** tab. 1. Click on **New merge request** on the right side of the screen. - - ![New Merge Request](img/merge_request_new.png) - -1. Select a source branch and click on the **Compare branches and continue** button. +1. From there on, you have the option to select the source branch and the target + branch you'd like to compare to. The default target project is the upstream + repository, but you can choose to compare across any of its forks. ![Select a branch](img/merge_request_select_branch.png) +1. When ready, click on the **Compare branches and continue** button. 1. At a minimum, add a title and a description to your merge request. Optionally, select a user to review your merge request and to accept or close it. You may also select a milestone and labels. ![New merge request page](img/merge_request_page.png) -1. When ready, click on the **Submit merge request** button. Your merge request - will be ready to be approved and published. +1. When ready, click on the **Submit merge request** button. + +Your merge request will be ready to be approved and merged. diff --git a/doc/gitlab-basics/img/merge_request_new.png b/doc/gitlab-basics/img/merge_request_new.png Binary files differdeleted file mode 100644 index 6fcd7bebada..00000000000 --- a/doc/gitlab-basics/img/merge_request_new.png +++ /dev/null diff --git a/doc/gitlab-basics/img/merge_request_select_branch.png b/doc/gitlab-basics/img/merge_request_select_branch.png Binary files differindex 9f6b93943a9..57ea0e65f34 100644 --- a/doc/gitlab-basics/img/merge_request_select_branch.png +++ b/doc/gitlab-basics/img/merge_request_select_branch.png diff --git a/doc/gitlab-basics/img/project_navbar.png b/doc/gitlab-basics/img/project_navbar.png Binary files differdeleted file mode 100644 index be6f38ede32..00000000000 --- a/doc/gitlab-basics/img/project_navbar.png +++ /dev/null diff --git a/doc/install/README.md b/doc/install/README.md index 656f8720361..540cb0d3f38 100644 --- a/doc/install/README.md +++ b/doc/install/README.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Installation GitLab can be installed via various ways. Check the [installation methods][methods] diff --git a/doc/install/relative_url.md b/doc/install/relative_url.md index 713d11b75e4..2f5d4142d04 100644 --- a/doc/install/relative_url.md +++ b/doc/install/relative_url.md @@ -1,11 +1,11 @@ -## Install GitLab under a relative URL +# Install GitLab under a relative URL -_**Note:** +NOTE: **Note:** This document describes how to run GitLab under a relative URL for installations from source. If you are using an Omnibus package, [the steps are different][omnibus-rel]. Use this guide along with the [installation guide](installation.md) if you are installing GitLab for the -first time._ +first time. --- @@ -33,7 +33,7 @@ serve GitLab under a relative URL is: After all the changes you need to recompile the assets and [restart GitLab]. -### Relative URL requirements +## Relative URL requirements If you configure GitLab with a relative URL, the assets (JavaScript, CSS, fonts, images, etc.) will need to be recompiled, which is a task which consumes a lot @@ -43,11 +43,11 @@ least 2GB of RAM available on your system, while we recommend 4GB RAM, and 4 or See the [requirements](requirements.md) document for more information. -### Enable relative URL in GitLab +## Enable relative URL in GitLab -_**Note:** +NOTE: **Note:** Do not make any changes to your web server configuration file regarding -relative URL. The relative URL support is implemented by GitLab Workhorse._ +relative URL. The relative URL support is implemented by GitLab Workhorse. --- @@ -115,7 +115,7 @@ Make sure to follow all steps below: 1. [Restart GitLab][] for the changes to take effect. -### Disable relative URL in GitLab +## Disable relative URL in GitLab To disable the relative URL: diff --git a/doc/install/requirements.md b/doc/install/requirements.md index 3d7becd18fc..7bf126eec5d 100644 --- a/doc/install/requirements.md +++ b/doc/install/requirements.md @@ -121,7 +121,7 @@ Existing users using GitLab with MySQL/MariaDB are advised to ### PostgreSQL Requirements -As of GitLab 10.0, PostgreSQL 9.6 or newer is required, and earlier versions are +As of GitLab 10.0, PostgreSQL 9.6 or newer (but less than 10) is required, and earlier versions are not supported. We highly recommend users to use PostgreSQL 9.6 as this is the PostgreSQL version used for development and testing. @@ -184,7 +184,7 @@ Runner. We recommend using a separate machine for each GitLab Runner, if you plan to use the CI features. -[security reasons]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/blob/master/docs/security/index.md +[security reasons]: https://gitlab.com/gitlab-org/gitlab-runner/blob/master/docs/security/index.md ## Supported web browsers diff --git a/doc/integration/README.md b/doc/integration/README.md index 09d96bdd338..54e78bdef54 100644 --- a/doc/integration/README.md +++ b/doc/integration/README.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # GitLab Integration GitLab integrates with multiple third-party services to allow external issue diff --git a/doc/intro/README.md b/doc/intro/README.md index 7485912d1a2..d9acc5bdeac 100644 --- a/doc/intro/README.md +++ b/doc/intro/README.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Get started with GitLab ## Organize diff --git a/doc/legal/README.md b/doc/legal/README.md index 56d72ae3859..6413f1d645f 100644 --- a/doc/legal/README.md +++ b/doc/legal/README.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Legal - [Corporate contributor license agreement](corporate_contributor_license_agreement.md) diff --git a/doc/legal/corporate_contributor_license_agreement.md b/doc/legal/corporate_contributor_license_agreement.md index 7f08188bd65..ebb24ba0a7f 100644 --- a/doc/legal/corporate_contributor_license_agreement.md +++ b/doc/legal/corporate_contributor_license_agreement.md @@ -1,29 +1,2 @@ -# Corporate contributor license agreement - -You accept and agree to the following terms and conditions for Your present and future Contributions submitted to GitLab B.V.. Except for the license granted herein to GitLab B.V. and recipients of software distributed by GitLab B.V., You reserve all right, title, and interest in and to Your Contributions. - -1. Definitions. - - "You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with GitLab B.V.. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. - - "Contribution" shall mean the code, documentation or other original works of authorship, including any modifications or additions to an existing work, that is submitted by You to GitLab B.V. for inclusion in, or documentation of, any of the products owned or managed by GitLab B.V. (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to GitLab B.V. or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, GitLab B.V. for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." - -2. Grant of Copyright License. - -Subject to the terms and conditions of this Agreement, You hereby grant to GitLab B.V. and to recipients of software distributed by GitLab B.V. a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. - -3. Grant of Patent License. - -Subject to the terms and conditions of this Agreement, You hereby grant to GitLab B.V. and to recipients of software distributed by GitLab B.V. a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. - -4. You represent that You are legally entitled to grant the above license. You represent further that each of Your employees is authorized to submit Contributions on Your behalf, but excluding employees that are designated in writing by You as "Not authorized to submit Contributions on behalf of [name of Your corporation here]." Such designations of exclusion for unauthorized employees are to be submitted via email to legal@gitlab.com. - -5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). - -6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. - -7. Should You wish to submit work that is not Your original creation, You may submit it to GitLab B.V. separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". - -8. It is Your responsibility to notify GitLab.com when any change is required to the list of designated employees excluded from submitting Contributions on Your behalf per Section 4. Such notification should be sent via email to legal@gitlab.com. - -This text is licensed under the [Creative Commons Attribution 3.0 License](https://creativecommons.org/licenses/by/3.0/) and the original source is the Google Open Source Programs Office. +This document has been replaced by a Developer Certificate of Origin and License, +as described in [Contributing.md](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md).
\ No newline at end of file diff --git a/doc/legal/individual_contributor_license_agreement.md b/doc/legal/individual_contributor_license_agreement.md index 59803aea080..ebb24ba0a7f 100644 --- a/doc/legal/individual_contributor_license_agreement.md +++ b/doc/legal/individual_contributor_license_agreement.md @@ -1,25 +1,2 @@ -# Individual contributor license agreement - -You accept and agree to the following terms and conditions for Your present and future Contributions submitted to GitLab B.V.. Except for the license granted herein to GitLab B.V. and recipients of software distributed by GitLab B.V., You reserve all right, title, and interest in and to Your Contributions. - -1. Definitions. - - "You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with GitLab B.V.. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. - - "Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to GitLab B.V. for inclusion in, or documentation of, any of the products owned or managed by GitLab B.V. (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to GitLab B.V. or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, GitLab B.V. for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." - -2. Grant of Copyright License. Subject to the terms and conditions of this Agreement, You hereby grant to GitLab B.V. and to recipients of software distributed by GitLab B.V. a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. - -3. Grant of Patent License. Subject to the terms and conditions of this Agreement, You hereby grant to GitLab B.V. and to recipients of software distributed by GitLab B.V. a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. - -4. You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to GitLab B.V., or that your employer has executed a separate Corporate CLA with GitLab B.V.. - -5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions. - -6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON- INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. - -7. Should You wish to submit work that is not Your original creation, You may submit it to GitLab B.V. separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [insert_name_here]". - -8. You agree to notify GitLab B.V. of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect. - -This text is licensed under the [Creative Commons Attribution 3.0 License](https://creativecommons.org/licenses/by/3.0/) and the original source is the Google Open Source Programs Office. +This document has been replaced by a Developer Certificate of Origin and License, +as described in [Contributing.md](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md).
\ No newline at end of file diff --git a/doc/migrate_ci_to_ce/README.md b/doc/migrate_ci_to_ce/README.md index 2e7782736ff..9347a834510 100644 --- a/doc/migrate_ci_to_ce/README.md +++ b/doc/migrate_ci_to_ce/README.md @@ -372,8 +372,10 @@ CREATE TABLE ``` To fix that you need to apply this SQL statement before doing final backup: -``` -# Omnibus + +```sql +## Omnibus GitLab + gitlab-ci-rails dbconsole <<EOF -- ALTER TABLES - DROP DEFAULTS ALTER TABLE ONLY ci_application_settings ALTER COLUMN id DROP DEFAULT; @@ -427,7 +429,8 @@ ALTER TABLE ONLY ci_variables ALTER COLUMN id SET DEFAULT nextval('ci_variables_ ALTER TABLE ONLY ci_web_hooks ALTER COLUMN id SET DEFAULT nextval('ci_web_hooks_id_seq'::regclass); EOF -# Source +## Source installations + cd /home/gitlab_ci/gitlab-ci sudo -u gitlab_ci -H bundle exec rails dbconsole production <<EOF ... COPY SQL STATEMENTS FROM ABOVE ... diff --git a/doc/policy/maintenance.md b/doc/policy/maintenance.md index 7ab56c89014..8d0afa9e692 100644 --- a/doc/policy/maintenance.md +++ b/doc/policy/maintenance.md @@ -19,24 +19,30 @@ For example, for GitLab version 10.5.7: * `5` represents minor version * `7` represents patch number -## Security releases +## Patch releases -The current stable release will receive security patches and bug fixes -(eg. `8.9.0` -> `8.9.1`). +Patch releases usually only include bug fixes and are only done for the current +stable release. That said, in some cases, we may backport it to previous stable +release, depending on the severity of the bug. -Feature releases will mark the next supported stable -release where the minor version is increased numerically by increments of one -(eg. `8.9 -> 8.10`). +For instance, if we release `10.1.1` with a fix for a severe bug introduced in +`10.0.0`, we could backport the fix to a new `10.0.x` patch release. -Our current policy is to support one stable release at any given time. -For medium-level security issues, we may consider backporting to the previous two +### Security releases + +Security releases are a special kind of patch release that only include security +fixes and patches (see below). + +Our current policy is to support one stable release at any given time, but for +medium-level security issues, we may backport security fixes to the previous two monthly releases. -For very serious security issues, there is [precedent](https://about.gitlab.com/2016/05/02/cve-2016-4340-patches/) -to backport security fixes to even more monthly releases of GitLab. This decision -is made on a case-by-case basis. +For very serious security issues, there is +[precedent](https://about.gitlab.com/2016/05/02/cve-2016-4340-patches/) +to backport security fixes to even more monthly releases of GitLab. +This decision is made on a case-by-case basis. -## Version support +## Upgrade recommendations We encourage everyone to run the latest stable release to ensure that you can easily upgrade to the most secure and feature-rich GitLab experience. In order @@ -70,7 +76,6 @@ Please see the table below for some examples: | -------------- | ------------ | ------------------------ | ---------------- | | 9.4.5 | 8.13.4 | `8.13.4` -> `8.17.7` -> `9.4.5` | `8.17.7` is the last version in version `8` | | 10.1.4 | 8.13.4 | `8.13.4` -> `8.17.7` -> `9.5.8` -> `10.1.4` | `8.17.7` is the last version in version `8`, `9.5.8` is the last version in version `9` | -| More information about the release procedures can be found in our [release-tools documentation][rel]. You may also want to read our diff --git a/doc/raketasks/README.md b/doc/raketasks/README.md index 2b81ebc9c59..2f916f5dea7 100644 --- a/doc/raketasks/README.md +++ b/doc/raketasks/README.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Rake tasks - [Backup restore](backup_restore.md) diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md index e4c09b2b507..54c3e20d61d 100644 --- a/doc/raketasks/backup_restore.md +++ b/doc/raketasks/backup_restore.md @@ -136,44 +136,54 @@ In the example below we use Amazon S3 for storage, but Fog also lets you use for AWS, Google, OpenStack Swift, Rackspace and Aliyun as well. A local driver is [also available](#uploading-to-locally-mounted-shares). -For omnibus packages, add the following to `/etc/gitlab/gitlab.rb`: +#### Using Amazon S3 -```ruby -gitlab_rails['backup_upload_connection'] = { - 'provider' => 'AWS', - 'region' => 'eu-west-1', - 'aws_access_key_id' => 'AKIAKIAKI', - 'aws_secret_access_key' => 'secret123' - # If using an IAM Profile, don't configure aws_access_key_id & aws_secret_access_key - # 'use_iam_profile' => true -} -gitlab_rails['backup_upload_remote_directory'] = 'my.s3.bucket' -``` +For Omnibus GitLab packages: + +1. Add the following to `/etc/gitlab/gitlab.rb`: + + ```ruby + gitlab_rails['backup_upload_connection'] = { + 'provider' => 'AWS', + 'region' => 'eu-west-1', + 'aws_access_key_id' => 'AKIAKIAKI', + 'aws_secret_access_key' => 'secret123' + # If using an IAM Profile, don't configure aws_access_key_id & aws_secret_access_key + # 'use_iam_profile' => true + } + gitlab_rails['backup_upload_remote_directory'] = 'my.s3.bucket' + ``` + +1. [Reconfigure GitLab] for the changes to take effect -Make sure to run `sudo gitlab-ctl reconfigure` after editing `/etc/gitlab/gitlab.rb` to reflect the changes. +--- For installations from source: -```yaml - backup: - # snip - upload: - # Fog storage connection settings, see http://fog.io/storage/ . - connection: - provider: AWS - region: eu-west-1 - aws_access_key_id: AKIAKIAKI - aws_secret_access_key: 'secret123' - # If using an IAM Profile, leave aws_access_key_id & aws_secret_access_key empty - # ie. aws_access_key_id: '' - # use_iam_profile: 'true' - # The remote 'directory' to store your backups. For S3, this would be the bucket name. - remote_directory: 'my.s3.bucket' - # Turns on AWS Server-Side Encryption with Amazon S3-Managed Keys for backups, this is optional - # encryption: 'AES256' - # Specifies Amazon S3 storage class to use for backups, this is optional - # storage_class: 'STANDARD' -``` +1. Edit `home/git/gitlab/config/gitlab.yml`: + + ```yaml + backup: + # snip + upload: + # Fog storage connection settings, see http://fog.io/storage/ . + connection: + provider: AWS + region: eu-west-1 + aws_access_key_id: AKIAKIAKI + aws_secret_access_key: 'secret123' + # If using an IAM Profile, leave aws_access_key_id & aws_secret_access_key empty + # ie. aws_access_key_id: '' + # use_iam_profile: 'true' + # The remote 'directory' to store your backups. For S3, this would be the bucket name. + remote_directory: 'my.s3.bucket' + # Turns on AWS Server-Side Encryption with Amazon S3-Managed Keys for backups, this is optional + # encryption: 'AES256' + # Specifies Amazon S3 storage class to use for backups, this is optional + # storage_class: 'STANDARD' + ``` + +1. [Restart GitLab] for the changes to take effect If you are uploading your backups to S3 you will probably want to create a new IAM user with restricted access rights. To give the upload user access only for @@ -226,6 +236,50 @@ with the name of your bucket: } ``` +#### Using Google Cloud Storage + +If you want to use Google Cloud Storage to save backups, you'll have to create +an access key from the Google console first: + +1. Go to the storage settings page https://console.cloud.google.com/storage/settings +1. Select "Interoperability" and create an access key +1. Make note of the "Access Key" and "Secret" and replace them in the + configurations below +1. Make sure you already have a bucket created + +For Omnibus GitLab packages: + +1. Edit `/etc/gitlab/gitlab.rb`: + + ```ruby + gitlab_rails['backup_upload_connection'] = { + 'provider' => 'Google', + 'google_storage_access_key_id' => 'Access Key', + 'google_storage_secret_access_key' => 'Secret' + } + gitlab_rails['backup_upload_remote_directory'] = 'my.google.bucket' + ``` + +1. [Reconfigure GitLab] for the changes to take effect + +--- + +For installations from source: + +1. Edit `home/git/gitlab/config/gitlab.yml`: + + ```yaml + backup: + upload: + connection: + provider: 'Google' + google_storage_access_key_id: 'Access Key' + google_storage_secret_access_key: 'Secret' + remote_directory: 'my.google.bucket' + ``` + +1. [Restart GitLab] for the changes to take effect + ### Uploading to locally mounted shares You may also send backups to a mounted share (`NFS` / `CIFS` / `SMB` / etc.) by @@ -554,3 +608,6 @@ The rake task runs this as the `gitlab` user which does not have the superuser a Those objects have no influence on the database backup/restore but they give this annoying warning. For more information see similar questions on postgresql issue tracker[here](http://www.postgresql.org/message-id/201110220712.30886.adrian.klaver@gmail.com) and [here](http://www.postgresql.org/message-id/2039.1177339749@sss.pgh.pa.us) as well as [stack overflow](http://stackoverflow.com/questions/4368789/error-must-be-owner-of-language-plpgsql). + +[reconfigure GitLab]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure +[restart GitLab]: ../administration/restart_gitlab.md#installations-from-source diff --git a/doc/raketasks/user_management.md b/doc/raketasks/user_management.md index 3ae46019daf..5554a0c8b78 100644 --- a/doc/raketasks/user_management.md +++ b/doc/raketasks/user_management.md @@ -149,18 +149,3 @@ cp config/secrets.yml.bak config/secrets.yml sudo /etc/init.d/gitlab start ``` - -## Clear authentication tokens for all users. Important! Data loss! - -Clear authentication tokens for all users in the GitLab database. This -task is useful if your users' authentication tokens might have been exposed in -any way. All the existing tokens will become invalid, and new tokens are -automatically generated upon sign-in or user modification. - -``` -# omnibus-gitlab -sudo gitlab-rake gitlab:users:clear_all_authentication_tokens - -# installation from source -bundle exec rake gitlab:users:clear_all_authentication_tokens RAILS_ENV=production -``` diff --git a/doc/security/README.md b/doc/security/README.md index 0fea6be8b55..d397ff104ab 100644 --- a/doc/security/README.md +++ b/doc/security/README.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Security - [Password length limits](password_length_limits.md) diff --git a/doc/ssh/README.md b/doc/ssh/README.md index 793de9d777c..33a2d7a88a7 100644 --- a/doc/ssh/README.md +++ b/doc/ssh/README.md @@ -1,4 +1,4 @@ -# SSH +# GitLab and SSH keys Git is a distributed version control system, which means you can work locally but you can also share or "push" your changes to other servers. @@ -114,7 +114,7 @@ custom name continue onto the next step. If you manually copied your public SSH key make sure you copied the entire key starting with `ssh-rsa` and ending with your email. - + 1. Optionally you can test your setup by running `ssh -T git@example.com` (replacing `example.com` with your GitLab domain) and verifying that you receive a `Welcome to GitLab` message. @@ -172,7 +172,7 @@ dummy user account. If you are a project master or owner, you can add a deploy key in the project settings under the section 'Repository'. Specify a title for the new deploy key and paste a public SSH key. After this, the machine that uses -the corresponding private SSH key has read-only or read-write (if enabled) +the corresponding private SSH key has read-only or read-write (if enabled) access to the project. You can't add the same deploy key twice using the form. @@ -232,7 +232,7 @@ something is wrong with your SSH setup. - Ensure that you generated your SSH key pair correctly and added the public SSH key to your GitLab profile -- Try manually registering your private SSH key using `ssh-agent` as documented +- Try manually registering your private SSH key using `ssh-agent` as documented earlier in this document - Try to debug the connection by running `ssh -Tv git@example.com` (replacing `example.com` with your GitLab domain) diff --git a/doc/system_hooks/system_hooks.md b/doc/system_hooks/system_hooks.md index a45a4eb9e49..f2a9b1d769b 100644 --- a/doc/system_hooks/system_hooks.md +++ b/doc/system_hooks/system_hooks.md @@ -1,6 +1,24 @@ # System hooks -Your GitLab instance can perform HTTP POST requests on the following events: `project_create`, `project_destroy`, `project_rename`, `project_transfer`, `project_update`, `user_add_to_team`, `user_remove_from_team`, `user_create`, `user_destroy`, `key_create`, `key_destroy`, `group_create`, `group_destroy`, `user_add_to_group` and `user_remove_from_group`. +Your GitLab instance can perform HTTP POST requests on the following events: + +- `project_create` +- `project_destroy` +- `project_rename` +- `project_transfer` +- `project_update` +- `user_add_to_team` +- `user_remove_from_team` +- `user_create` +- `user_destroy` +- `user_rename` +- `key_create` +- `key_destroy` +- `group_create` +- `group_destroy` +- `group_rename` +- `user_add_to_group` +- `user_remove_from_group` The triggers for most of these are self-explanatory, but `project_update` and `project_rename` deserve some clarification: `project_update` is fired any time an attribute of a project is changed (name, description, tags, etc.) *unless* the `path` attribute is also changed. In that case, a `project_rename` is triggered instead (so that, for instance, if all you care about is the repo URL, you can just listen for `project_rename`). @@ -72,6 +90,9 @@ X-Gitlab-Event: System Hook } ``` +Note that `project_rename` is not triggered if the namespace changes. +Please refer to `group_rename` and `user_rename` for that case. + **Project transferred:** ```json @@ -175,6 +196,21 @@ X-Gitlab-Event: System Hook } ``` +**User renamed:** + +```json +{ + "event_name": "user_rename", + "created_at": "2017-11-01T11:21:04Z", + "updated_at": "2017-11-01T14:04:47Z", + "name": "new-name", + "email": "best-email@example.tld", + "user_id": 58, + "username": "new-exciting-name", + "old_username": "old-boring-name" +} +``` + **Key added** ```json @@ -209,13 +245,15 @@ X-Gitlab-Event: System Hook "updated_at": "2012-07-21T07:38:22Z", "event_name": "group_create", "name": "StoreCloud", - "owner_email": "johnsmith@gmail.com", - "owner_name": "John Smith", + "owner_email": null, + "owner_name": null, "path": "storecloud", "group_id": 78 } ``` +`owner_name` and `owner_email` are always `null`. Please see https://gitlab.com/gitlab-org/gitlab-ce/issues/39675. + **Group removed:** ```json @@ -224,13 +262,35 @@ X-Gitlab-Event: System Hook "updated_at": "2012-07-21T07:38:22Z", "event_name": "group_destroy", "name": "StoreCloud", - "owner_email": "johnsmith@gmail.com", - "owner_name": "John Smith", + "owner_email": null, + "owner_name": null, "path": "storecloud", "group_id": 78 } ``` +`owner_name` and `owner_email` are always `null`. Please see https://gitlab.com/gitlab-org/gitlab-ce/issues/39675. + +**Group renamed:** + +```json +{ + "event_name": "group_rename", + "created_at": "2017-10-30T15:09:00Z", + "updated_at": "2017-11-01T10:23:52Z", + "name": "Better Name", + "path": "better-name", + "full_path": "parent-group/better-name", + "group_id": 64, + "owner_name": null, + "owner_email": null, + "old_path": "old-name", + "old_full_path": "parent-group/old-name" +} +``` + +`owner_name` and `owner_email` are always `null`. Please see https://gitlab.com/gitlab-org/gitlab-ce/issues/39675. + **New Group Member:** ```json diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index 5561784ed0b..1cfdabac248 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -144,6 +144,12 @@ has a `.gitlab-ci.yml` or not: All you need to do is remove your existing `.gitlab-ci.yml`, and you can even do that in a branch to test Auto DevOps before committing to `master`. +NOTE: **Note:** +If you are a GitLab Administrator, you can enable Auto DevOps instance wide +in **Admin Area > Settings > Continuous Integration and Deployment**. Doing that, +all the projects that haven't explicitly set an option will have Auto DevOps +enabled by default. + ## Stages of Auto DevOps The following sections describe the stages of Auto DevOps. Read them carefully @@ -517,7 +523,7 @@ Feature.get(:auto_devops_banner_disabled).enable Or through the HTTP API with an admin access token: ```sh -curl --data "value=true" --header "PRIVATE-TOKEN: private_token" https://gitlab.example.com/api/v4/features/auto_devops_banner_disabled +curl --data "value=true" --header "PRIVATE-TOKEN: personal_access_token" https://gitlab.example.com/api/v4/features/auto_devops_banner_disabled ``` [ce-37115]: https://gitlab.com/gitlab-org/gitlab-ce/issues/37115 diff --git a/doc/university/README.md b/doc/university/README.md index 170582bcd0c..55865ac23e8 100644 --- a/doc/university/README.md +++ b/doc/university/README.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # GitLab University GitLab University is the best place to learn about **Version Control with Git and GitLab**. @@ -51,10 +55,10 @@ The curriculum is composed of GitLab videos, screencasts, presentations, project #### 1.5. Migrating from other Source Control -1. [Migrating from BitBucket/Stash](https://docs.gitlab.com/ee/workflow/importing/import_projects_from_bitbucket.html) -1. [Migrating from GitHub](https://docs.gitlab.com/ee/workflow/importing/import_projects_from_github.html) -1. [Migrating from SVN](https://docs.gitlab.com/ee/workflow/importing/migrating_from_svn.html) -1. [Migrating from Fogbugz](https://docs.gitlab.com/ee/workflow/importing/import_projects_from_fogbugz.html) +1. [Migrating from BitBucket/Stash](https://docs.gitlab.com/ee/user/project/import/bitbucket.html) +1. [Migrating from GitHub](https://docs.gitlab.com/ee/user/project/import/github.html) +1. [Migrating from SVN](https://docs.gitlab.com/ee/user/project/import/svn.html) +1. [Migrating from Fogbugz](https://docs.gitlab.com/ee/user/project/import/fogbugz.html) #### 1.6. GitLab Inc. @@ -76,13 +80,13 @@ The curriculum is composed of GitLab videos, screencasts, presentations, project - Being part of our Great Community and Contributing to GitLab 1. [Getting Started with the GitLab Development Kit (GDK)](https://about.gitlab.com/2016/06/08/getting-started-with-gitlab-development-kit/) 1. [Contributing Technical Articles to the GitLab Blog](https://about.gitlab.com/2016/01/26/call-for-writers/) -1. [GitLab Training Workshops](https://about.gitlab.com/training) +1. [GitLab Training Workshops](https://docs.gitlab.com/ce/university/training/end-user/) +1. [GitLab Professional Services](https://about.gitlab.com/services/) #### 1.8 GitLab Training Material 1. [Git and GitLab Terminology](glossary/README.md) 1. [Git and GitLab Workshop - Slides](https://docs.google.com/presentation/d/1JzTYD8ij9slejV2-TO-NzjCvlvj6mVn9BORePXNJoMI/edit?usp=drive_web) -1. [Git and GitLab Revision](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/university/training/end-user) --- diff --git a/doc/university/bookclub/booklist.md b/doc/university/bookclub/booklist.md index c4229832e9f..26c3851276b 100644 --- a/doc/university/bookclub/booklist.md +++ b/doc/university/bookclub/booklist.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Books List of books and resources, that may be worth reading. diff --git a/doc/university/bookclub/index.md b/doc/university/bookclub/index.md index 022a61f4429..63238685b2b 100644 --- a/doc/university/bookclub/index.md +++ b/doc/university/bookclub/index.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # The GitLab Book Club The Book Club is a casual meet-up to read and discuss books we like. diff --git a/doc/university/glossary/README.md b/doc/university/glossary/README.md index 9544de41b9a..c6a91c8d5c2 100644 --- a/doc/university/glossary/README.md +++ b/doc/university/glossary/README.md @@ -1,4 +1,8 @@ -## What is the Glossary +--- +comments: false +--- + +# What is the Glossary This contains a simplified list and definitions of some of the terms that you will encounter in your day to day activities when working with GitLab. Please add any terms that you discover that you think would be useful for others. @@ -456,7 +460,7 @@ A route table contains rules (called routes) that determine where network traffi ### Runners -Actual build machines/containers that [run and execute tests](https://gitlab.com/gitlab-org/gitlab-ci-multi-runner) you have specified to be run on GitLab CI. +Actual build machines/containers that [run and execute tests](https://gitlab.com/gitlab-org/gitlab-runner) you have specified to be run on GitLab CI. ### Sidekiq diff --git a/doc/university/high-availability/aws/README.md b/doc/university/high-availability/aws/README.md index 6b8f3cd3d1d..54625996dff 100644 --- a/doc/university/high-availability/aws/README.md +++ b/doc/university/high-availability/aws/README.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # High Availability on AWS diff --git a/doc/university/process/README.md b/doc/university/process/README.md index 04f2d52514f..fdf6224f7f6 100644 --- a/doc/university/process/README.md +++ b/doc/university/process/README.md @@ -1,8 +1,12 @@ --- +comments: false +--- + +--- title: University | Process --- -## Suggesting improvements +# Suggesting improvements If you would like to teach a class or participate or help in any way please submit a merge request and assign it to [Job](https://gitlab.com/u/JobV). diff --git a/doc/university/support/README.md b/doc/university/support/README.md index 567dadb3b47..25d5fe351ca 100644 --- a/doc/university/support/README.md +++ b/doc/university/support/README.md @@ -1,5 +1,9 @@ +--- +comments: false +--- -## Support Boot Camp + +# Support Boot Camp **Goal:** Prepare new Service Engineers at GitLab diff --git a/doc/university/training/end-user/README.md b/doc/university/training/end-user/README.md index 03c62a81b10..a882bf0eb48 100644 --- a/doc/university/training/end-user/README.md +++ b/doc/university/training/end-user/README.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Training diff --git a/doc/university/training/gitlab_flow.md b/doc/university/training/gitlab_flow.md index a7db1f2e069..02a6ad48a38 100644 --- a/doc/university/training/gitlab_flow.md +++ b/doc/university/training/gitlab_flow.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # GitLab Flow - A simplified branching strategy diff --git a/doc/university/training/index.md b/doc/university/training/index.md index 03179ff5a77..14f096b130f 100644 --- a/doc/university/training/index.md +++ b/doc/university/training/index.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # GitLab Training Material All GitLab training material is stored in markdown format. Slides are diff --git a/doc/university/training/topics/additional_resources.md b/doc/university/training/topics/additional_resources.md index 3ed601625cf..d01634df744 100644 --- a/doc/university/training/topics/additional_resources.md +++ b/doc/university/training/topics/additional_resources.md @@ -1,4 +1,8 @@ -## Additional Resources +--- +comments: false +--- + +# Additional Resources 1. GitLab Documentation [http://docs.gitlab.com](http://docs.gitlab.com/) 2. GUI Clients [http://git-scm.com/downloads/guis](http://git-scm.com/downloads/guis) diff --git a/doc/university/training/topics/agile_git.md b/doc/university/training/topics/agile_git.md index e6e4fea9b51..251af99bed7 100644 --- a/doc/university/training/topics/agile_git.md +++ b/doc/university/training/topics/agile_git.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Agile and Git ---------- diff --git a/doc/university/training/topics/bisect.md b/doc/university/training/topics/bisect.md index a60c4365e0c..2d5ab107fe6 100644 --- a/doc/university/training/topics/bisect.md +++ b/doc/university/training/topics/bisect.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Bisect ---------- diff --git a/doc/university/training/topics/cherry_picking.md b/doc/university/training/topics/cherry_picking.md index af7a70a2818..df23024b6ee 100644 --- a/doc/university/training/topics/cherry_picking.md +++ b/doc/university/training/topics/cherry_picking.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Cherry Pick ---------- diff --git a/doc/university/training/topics/env_setup.md b/doc/university/training/topics/env_setup.md index 8149379b36f..b7bec83ed8a 100644 --- a/doc/university/training/topics/env_setup.md +++ b/doc/university/training/topics/env_setup.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Configure your environment ---------- diff --git a/doc/university/training/topics/explore_gitlab.md b/doc/university/training/topics/explore_gitlab.md index b65457728c0..84a1879cd92 100644 --- a/doc/university/training/topics/explore_gitlab.md +++ b/doc/university/training/topics/explore_gitlab.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Explore GitLab projects ---------- diff --git a/doc/university/training/topics/feature_branching.md b/doc/university/training/topics/feature_branching.md index 4b34406ea75..0df5f26dbea 100644 --- a/doc/university/training/topics/feature_branching.md +++ b/doc/university/training/topics/feature_branching.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Feature branching ---------- diff --git a/doc/university/training/topics/getting_started.md b/doc/university/training/topics/getting_started.md index ec7bb2631aa..153b45fb4da 100644 --- a/doc/university/training/topics/getting_started.md +++ b/doc/university/training/topics/getting_started.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Getting Started ---------- diff --git a/doc/university/training/topics/git_add.md b/doc/university/training/topics/git_add.md index 9ffb4b9c859..651366e0d49 100644 --- a/doc/university/training/topics/git_add.md +++ b/doc/university/training/topics/git_add.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Git Add ---------- diff --git a/doc/university/training/topics/git_intro.md b/doc/university/training/topics/git_intro.md index ca1ff29d93b..7e502d6dad4 100644 --- a/doc/university/training/topics/git_intro.md +++ b/doc/university/training/topics/git_intro.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Git introduction ---------- diff --git a/doc/university/training/topics/git_log.md b/doc/university/training/topics/git_log.md index 32ebceff491..f2709ae3890 100644 --- a/doc/university/training/topics/git_log.md +++ b/doc/university/training/topics/git_log.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Git Log ---------- @@ -49,8 +53,8 @@ git log --since=1.month.ago --until=3.weeks.ago ``` cd ~/workspace -git clone git@gitlab.com:gitlab-org/gitlab-ci-multi-runner.git -cd gitlab-ci-multi-runner +git clone git@gitlab.com:gitlab-org/gitlab-runner.git +cd gitlab-runner git log --author="Travis" git log --since=1.month.ago --until=3.weeks.ago git log --since=1.month.ago --until=1.day.ago --author="Travis" diff --git a/doc/university/training/topics/gitlab_flow.md b/doc/university/training/topics/gitlab_flow.md index 8e5d3baf959..b8049b5c80e 100644 --- a/doc/university/training/topics/gitlab_flow.md +++ b/doc/university/training/topics/gitlab_flow.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # GitLab Flow ---------- diff --git a/doc/university/training/topics/merge_conflicts.md b/doc/university/training/topics/merge_conflicts.md index 77807b3e7ef..9a1ce550868 100644 --- a/doc/university/training/topics/merge_conflicts.md +++ b/doc/university/training/topics/merge_conflicts.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Merge conflicts ---------- diff --git a/doc/university/training/topics/merge_requests.md b/doc/university/training/topics/merge_requests.md index 5b446f02f63..4e8c9de85a1 100644 --- a/doc/university/training/topics/merge_requests.md +++ b/doc/university/training/topics/merge_requests.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Merge requests ---------- diff --git a/doc/university/training/topics/rollback_commits.md b/doc/university/training/topics/rollback_commits.md index cf647284604..0db1d93d1dc 100644 --- a/doc/university/training/topics/rollback_commits.md +++ b/doc/university/training/topics/rollback_commits.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Rollback Commits ---------- diff --git a/doc/university/training/topics/stash.md b/doc/university/training/topics/stash.md index c1bdda32645..5b27ac12f77 100644 --- a/doc/university/training/topics/stash.md +++ b/doc/university/training/topics/stash.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Git Stash ---------- diff --git a/doc/university/training/topics/subtree.md b/doc/university/training/topics/subtree.md index 5d869af64c1..b5a892dc17b 100644 --- a/doc/university/training/topics/subtree.md +++ b/doc/university/training/topics/subtree.md @@ -1,8 +1,8 @@ -## Subtree +--- +comments: false +--- ----------- - -## Subtree +# Subtree * Used when there are nested repositories. * Not recommended when the amount of dependencies is too large diff --git a/doc/university/training/topics/tags.md b/doc/university/training/topics/tags.md index e9607b5a875..ab48d52d3c3 100644 --- a/doc/university/training/topics/tags.md +++ b/doc/university/training/topics/tags.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Tags ---------- diff --git a/doc/university/training/topics/unstage.md b/doc/university/training/topics/unstage.md index 17dbb64b9e6..fc72949ade9 100644 --- a/doc/university/training/topics/unstage.md +++ b/doc/university/training/topics/unstage.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Unstage ---------- diff --git a/doc/university/training/user_training.md b/doc/university/training/user_training.md index 9e38df26b6a..90e1d2ba5e8 100644 --- a/doc/university/training/user_training.md +++ b/doc/university/training/user_training.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # GitLab Git Workshop --- diff --git a/doc/update/10.0-to-10.1.md b/doc/update/10.0-to-10.1.md index dc14c779026..af815d26a74 100644 --- a/doc/update/10.0-to-10.1.md +++ b/doc/update/10.0-to-10.1.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 10.0 to 10.1 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/2.6-to-3.0.md b/doc/update/2.6-to-3.0.md index 97cd277b424..8f18bd93cea 100644 --- a/doc/update/2.6-to-3.0.md +++ b/doc/update/2.6-to-3.0.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 2.6 to 3.0 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/2.6-to-3.0.md) for the most up to date instructions.* diff --git a/doc/update/2.9-to-3.0.md b/doc/update/2.9-to-3.0.md index a890aa885d5..6a3c2387683 100644 --- a/doc/update/2.9-to-3.0.md +++ b/doc/update/2.9-to-3.0.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 2.9 to 3.0 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/2.9-to-3.0.md) for the most up to date instructions.* diff --git a/doc/update/3.0-to-3.1.md b/doc/update/3.0-to-3.1.md index e32508745a2..1f25b8265c9 100644 --- a/doc/update/3.0-to-3.1.md +++ b/doc/update/3.0-to-3.1.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 3.0 to 3.1 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/3.0-to-3.1.md) for the most up to date instructions.* diff --git a/doc/update/3.1-to-4.0.md b/doc/update/3.1-to-4.0.md index b370464390e..1a53ddeb4bd 100644 --- a/doc/update/3.1-to-4.0.md +++ b/doc/update/3.1-to-4.0.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 3.1 to 4.0 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/3.1-to-4.0.md) for the most up to date instructions.* diff --git a/doc/update/4.0-to-4.1.md b/doc/update/4.0-to-4.1.md index 7124424bb60..40a133e796e 100644 --- a/doc/update/4.0-to-4.1.md +++ b/doc/update/4.0-to-4.1.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 4.0 to 4.1 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/4.0-to-4.1.md) for the most up to date instructions.* diff --git a/doc/update/4.1-to-4.2.md b/doc/update/4.1-to-4.2.md index 8ed5b333a2e..1fd6c58bda7 100644 --- a/doc/update/4.1-to-4.2.md +++ b/doc/update/4.1-to-4.2.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 4.1 to 4.2 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/4.1-to-4.2.md) for the most up to date instructions.* diff --git a/doc/update/4.2-to-5.0.md b/doc/update/4.2-to-5.0.md index 1ec39218ba8..311664b2bc1 100644 --- a/doc/update/4.2-to-5.0.md +++ b/doc/update/4.2-to-5.0.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 4.2 to 5.0 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/4.2-to-5.0.md) for the most up to date instructions.* diff --git a/doc/update/5.0-to-5.1.md b/doc/update/5.0-to-5.1.md index 9c9950fb2c6..7067ea4c40c 100644 --- a/doc/update/5.0-to-5.1.md +++ b/doc/update/5.0-to-5.1.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 5.0 to 5.1 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/5.0-to-5.1.md) for the most up to date instructions.* diff --git a/doc/update/5.1-to-5.2.md b/doc/update/5.1-to-5.2.md index 2aab47d2d7c..4faf5fa549e 100644 --- a/doc/update/5.1-to-5.2.md +++ b/doc/update/5.1-to-5.2.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 5.1 to 5.2 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/5.1-to-5.2.md) for the most up to date instructions.* diff --git a/doc/update/5.1-to-5.4.md b/doc/update/5.1-to-5.4.md index e80f1b89c63..212343bac3f 100644 --- a/doc/update/5.1-to-5.4.md +++ b/doc/update/5.1-to-5.4.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 5.1 to 5.4 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/5.1-to-5.4.md) for the most up to date instructions.* diff --git a/doc/update/5.1-to-6.0.md b/doc/update/5.1-to-6.0.md index 1ee175383da..865d38e0ca4 100644 --- a/doc/update/5.1-to-6.0.md +++ b/doc/update/5.1-to-6.0.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 5.1 to 6.0 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/5.1-to-6.0.md) for the most up to date instructions.* diff --git a/doc/update/5.2-to-5.3.md b/doc/update/5.2-to-5.3.md index 2ae50510f63..ed4f3ebdd53 100644 --- a/doc/update/5.2-to-5.3.md +++ b/doc/update/5.2-to-5.3.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 5.2 to 5.3 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/5.2-to-5.3.md) for the most up to date instructions.* diff --git a/doc/update/5.3-to-5.4.md b/doc/update/5.3-to-5.4.md index 842e3bb6791..7277250eb32 100644 --- a/doc/update/5.3-to-5.4.md +++ b/doc/update/5.3-to-5.4.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 5.3 to 5.4 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/5.3-to-5.4.md) for the most up to date instructions.* diff --git a/doc/update/5.4-to-6.0.md b/doc/update/5.4-to-6.0.md index 44715984f0c..dacdf05cc9c 100644 --- a/doc/update/5.4-to-6.0.md +++ b/doc/update/5.4-to-6.0.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 5.4 to 6.0 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/5.4-to-6.0.md) for the most up to date instructions.* diff --git a/doc/update/6.0-to-6.1.md b/doc/update/6.0-to-6.1.md index 0c672abeb05..a3c52a1cfb4 100644 --- a/doc/update/6.0-to-6.1.md +++ b/doc/update/6.0-to-6.1.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 6.0 to 6.1 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/6.0-to-6.1.md) for the most up to date instructions.* diff --git a/doc/update/6.1-to-6.2.md b/doc/update/6.1-to-6.2.md index d3760cf0619..36a395bf01e 100644 --- a/doc/update/6.1-to-6.2.md +++ b/doc/update/6.1-to-6.2.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 6.1 to 6.2 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/6.1-to-6.2.md) for the most up to date instructions.* diff --git a/doc/update/6.2-to-6.3.md b/doc/update/6.2-to-6.3.md index 91105de2e29..02e87a08b8f 100644 --- a/doc/update/6.2-to-6.3.md +++ b/doc/update/6.2-to-6.3.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 6.2 to 6.3 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/6.2-to-6.3.md) for the most up to date instructions.* diff --git a/doc/update/6.3-to-6.4.md b/doc/update/6.3-to-6.4.md index 20b58ed8b25..285ed06bdad 100644 --- a/doc/update/6.3-to-6.4.md +++ b/doc/update/6.3-to-6.4.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 6.3 to 6.4 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/6.3-to-6.4.md) for the most up to date instructions.* diff --git a/doc/update/6.4-to-6.5.md b/doc/update/6.4-to-6.5.md index 5ee0f040b5d..e07c98a5ad4 100644 --- a/doc/update/6.4-to-6.5.md +++ b/doc/update/6.4-to-6.5.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 6.4 to 6.5 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/6.4-to-6.5.md) for the most up to date instructions.* diff --git a/doc/update/6.5-to-6.6.md b/doc/update/6.5-to-6.6.md index fa3712f83ad..3f79b19644e 100644 --- a/doc/update/6.5-to-6.6.md +++ b/doc/update/6.5-to-6.6.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 6.5 to 6.6 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/6.5-to-6.6.md) for the most up to date instructions.* diff --git a/doc/update/6.6-to-6.7.md b/doc/update/6.6-to-6.7.md index 9c85ed091c5..a0542d20d49 100644 --- a/doc/update/6.6-to-6.7.md +++ b/doc/update/6.6-to-6.7.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 6.6 to 6.7 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/6.6-to-6.7.md) for the most up to date instructions.* diff --git a/doc/update/6.7-to-6.8.md b/doc/update/6.7-to-6.8.md index 687c1265d9b..acf004577f1 100644 --- a/doc/update/6.7-to-6.8.md +++ b/doc/update/6.7-to-6.8.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 6.7 to 6.8 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/6.7-to-6.8.md) for the most up to date instructions.* diff --git a/doc/update/6.8-to-6.9.md b/doc/update/6.8-to-6.9.md index 0205b0c896a..3d7b1e5346b 100644 --- a/doc/update/6.8-to-6.9.md +++ b/doc/update/6.8-to-6.9.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 6.8 to 6.9 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/6.8-to-6.9.md) for the most up to date instructions.* diff --git a/doc/update/6.9-to-7.0.md b/doc/update/6.9-to-7.0.md index 4b6e3989893..27063948028 100644 --- a/doc/update/6.9-to-7.0.md +++ b/doc/update/6.9-to-7.0.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 6.9 to 7.0 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/6.9-to-7.0.md) for the most up to date instructions.* diff --git a/doc/update/6.x-or-7.x-to-7.14.md b/doc/update/6.x-or-7.x-to-7.14.md index 1e39fe47ef9..41d0e78b7d8 100644 --- a/doc/update/6.x-or-7.x-to-7.14.md +++ b/doc/update/6.x-or-7.x-to-7.14.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 6.x or 7.x to 7.14 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/6.x-or-7.x-to-7.14.md) for the most up to date instructions.* diff --git a/doc/update/7.0-to-7.1.md b/doc/update/7.0-to-7.1.md index 2e9457aa142..308e8aeb985 100644 --- a/doc/update/7.0-to-7.1.md +++ b/doc/update/7.0-to-7.1.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 7.0 to 7.1 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/7.0-to-7.1.md) for the most up to date instructions.* diff --git a/doc/update/7.1-to-7.2.md b/doc/update/7.1-to-7.2.md index e5045b5570f..07f92ac3af6 100644 --- a/doc/update/7.1-to-7.2.md +++ b/doc/update/7.1-to-7.2.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 7.1 to 7.2 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/7.1-to-7.2.md) for the most up to date instructions.* diff --git a/doc/update/7.10-to-7.11.md b/doc/update/7.10-to-7.11.md index 89213ba7178..39eeefc0e32 100644 --- a/doc/update/7.10-to-7.11.md +++ b/doc/update/7.10-to-7.11.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 7.10 to 7.11 ### 0. Stop server diff --git a/doc/update/7.11-to-7.12.md b/doc/update/7.11-to-7.12.md index 3865186918c..530066e5fdb 100644 --- a/doc/update/7.11-to-7.12.md +++ b/doc/update/7.11-to-7.12.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 7.11 to 7.12 ### 0. Double-check your Git version diff --git a/doc/update/7.12-to-7.13.md b/doc/update/7.12-to-7.13.md index 4c8d8f1f741..8f413a2079a 100644 --- a/doc/update/7.12-to-7.13.md +++ b/doc/update/7.12-to-7.13.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 7.12 to 7.13 ### 0. Double-check your Git version diff --git a/doc/update/7.13-to-7.14.md b/doc/update/7.13-to-7.14.md index 934898da5a1..a8980662855 100644 --- a/doc/update/7.13-to-7.14.md +++ b/doc/update/7.13-to-7.14.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 7.13 to 7.14 ### 0. Double-check your Git version diff --git a/doc/update/7.14-to-8.0.md b/doc/update/7.14-to-8.0.md index 25fa6d93f06..513afccff50 100644 --- a/doc/update/7.14-to-8.0.md +++ b/doc/update/7.14-to-8.0.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 7.14 to 8.0 ### 0. Double-check your Git version diff --git a/doc/update/7.2-to-7.3.md b/doc/update/7.2-to-7.3.md index d3391ddd225..a16f9de54e4 100644 --- a/doc/update/7.2-to-7.3.md +++ b/doc/update/7.2-to-7.3.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 7.2 to 7.3 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/7.2-to-7.3.md) for the most up to date instructions.* diff --git a/doc/update/7.3-to-7.4.md b/doc/update/7.3-to-7.4.md index 6d632dc3c8e..734c655f1d1 100644 --- a/doc/update/7.3-to-7.4.md +++ b/doc/update/7.3-to-7.4.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 7.3 to 7.4 *Make sure you view this [upgrade guide from the `master` branch](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update/7.3-to-7.4.md) for the most up to date instructions.* diff --git a/doc/update/7.4-to-7.5.md b/doc/update/7.4-to-7.5.md index ec50706d421..7a3a49ff948 100644 --- a/doc/update/7.4-to-7.5.md +++ b/doc/update/7.4-to-7.5.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 7.4 to 7.5 ### 0. Stop server diff --git a/doc/update/7.5-to-7.6.md b/doc/update/7.5-to-7.6.md index 331f5de080e..f0dfb177b79 100644 --- a/doc/update/7.5-to-7.6.md +++ b/doc/update/7.5-to-7.6.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 7.5 to 7.6 ### 0. Stop server diff --git a/doc/update/7.6-to-7.7.md b/doc/update/7.6-to-7.7.md index 918b10fbd95..85de6b0c546 100644 --- a/doc/update/7.6-to-7.7.md +++ b/doc/update/7.6-to-7.7.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 7.6 to 7.7 ### 0. Stop server diff --git a/doc/update/7.7-to-7.8.md b/doc/update/7.7-to-7.8.md index 84e0464a824..7cee5f79a13 100644 --- a/doc/update/7.7-to-7.8.md +++ b/doc/update/7.7-to-7.8.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 7.7 to 7.8 ### 0. Stop server diff --git a/doc/update/7.8-to-7.9.md b/doc/update/7.8-to-7.9.md index b0dc2ba1dbb..5a8b689dbc1 100644 --- a/doc/update/7.8-to-7.9.md +++ b/doc/update/7.8-to-7.9.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 7.8 to 7.9 ### 0. Stop server diff --git a/doc/update/7.9-to-7.10.md b/doc/update/7.9-to-7.10.md index 8f7f84b41ba..99df51dbb99 100644 --- a/doc/update/7.9-to-7.10.md +++ b/doc/update/7.9-to-7.10.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 7.9 to 7.10 ### 0. Stop server diff --git a/doc/update/8.0-to-8.1.md b/doc/update/8.0-to-8.1.md index 6ee0c0656ee..f612606af68 100644 --- a/doc/update/8.0-to-8.1.md +++ b/doc/update/8.0-to-8.1.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 8.0 to 8.1 **NOTE:** GitLab 8.0 introduced several significant changes related to diff --git a/doc/update/8.1-to-8.2.md b/doc/update/8.1-to-8.2.md index 4c9ff5c5c0a..2d0b19abd74 100644 --- a/doc/update/8.1-to-8.2.md +++ b/doc/update/8.1-to-8.2.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 8.1 to 8.2 **NOTE:** GitLab 8.0 introduced several significant changes related to diff --git a/doc/update/8.10-to-8.11.md b/doc/update/8.10-to-8.11.md index e538983e603..df3e34f5cc6 100644 --- a/doc/update/8.10-to-8.11.md +++ b/doc/update/8.10-to-8.11.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 8.10 to 8.11 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/8.11-to-8.12.md b/doc/update/8.11-to-8.12.md index 604166beb56..9d6a1f42375 100644 --- a/doc/update/8.11-to-8.12.md +++ b/doc/update/8.11-to-8.12.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 8.11 to 8.12 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/8.12-to-8.13.md b/doc/update/8.12-to-8.13.md index d83965131f5..6225dee9802 100644 --- a/doc/update/8.12-to-8.13.md +++ b/doc/update/8.12-to-8.13.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 8.12 to 8.13 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/8.13-to-8.14.md b/doc/update/8.13-to-8.14.md index aaadcec8ac0..d2508e3f980 100644 --- a/doc/update/8.13-to-8.14.md +++ b/doc/update/8.13-to-8.14.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 8.13 to 8.14 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/8.14-to-8.15.md b/doc/update/8.14-to-8.15.md index a68fe3bb605..daf8d0f2ca6 100644 --- a/doc/update/8.14-to-8.15.md +++ b/doc/update/8.14-to-8.15.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 8.14 to 8.15 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/8.15-to-8.16.md b/doc/update/8.15-to-8.16.md index 9f8f0f714d4..3668142edd2 100644 --- a/doc/update/8.15-to-8.16.md +++ b/doc/update/8.15-to-8.16.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 8.15 to 8.16 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/8.16-to-8.17.md b/doc/update/8.16-to-8.17.md index 74ffe0bc846..ee2e31c2aec 100644 --- a/doc/update/8.16-to-8.17.md +++ b/doc/update/8.16-to-8.17.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 8.16 to 8.17 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/8.17-to-9.0.md b/doc/update/8.17-to-9.0.md index baab217b6b7..2e0c26a9092 100644 --- a/doc/update/8.17-to-9.0.md +++ b/doc/update/8.17-to-9.0.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 8.17 to 9.0 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/8.2-to-8.3.md b/doc/update/8.2-to-8.3.md index 4b3c5bf6d64..3a0d647cbfe 100644 --- a/doc/update/8.2-to-8.3.md +++ b/doc/update/8.2-to-8.3.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 8.2 to 8.3 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/8.3-to-8.4.md b/doc/update/8.3-to-8.4.md index 8b89455ca87..f5162dd5ff5 100644 --- a/doc/update/8.3-to-8.4.md +++ b/doc/update/8.3-to-8.4.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 8.3 to 8.4 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/8.4-to-8.5.md b/doc/update/8.4-to-8.5.md index 0eedfaee2db..9e2f98add8d 100644 --- a/doc/update/8.4-to-8.5.md +++ b/doc/update/8.4-to-8.5.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 8.4 to 8.5 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/8.5-to-8.6.md b/doc/update/8.5-to-8.6.md index 851056161bb..55d8178c407 100644 --- a/doc/update/8.5-to-8.6.md +++ b/doc/update/8.5-to-8.6.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 8.5 to 8.6 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/8.6-to-8.7.md b/doc/update/8.6-to-8.7.md index 34c727260aa..49db6f2967c 100644 --- a/doc/update/8.6-to-8.7.md +++ b/doc/update/8.6-to-8.7.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 8.6 to 8.7 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/8.7-to-8.8.md b/doc/update/8.7-to-8.8.md index 6feeb1919de..ee7ec6f7614 100644 --- a/doc/update/8.7-to-8.8.md +++ b/doc/update/8.7-to-8.8.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 8.7 to 8.8 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/8.8-to-8.9.md b/doc/update/8.8-to-8.9.md index 61cdf8854d4..7508443c30a 100644 --- a/doc/update/8.8-to-8.9.md +++ b/doc/update/8.8-to-8.9.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 8.8 to 8.9 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/8.9-to-8.10.md b/doc/update/8.9-to-8.10.md index 42132f690d8..915e7db819a 100644 --- a/doc/update/8.9-to-8.10.md +++ b/doc/update/8.9-to-8.10.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 8.9 to 8.10 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/9.0-to-9.1.md b/doc/update/9.0-to-9.1.md index 6f1870a1366..f60bd92e236 100644 --- a/doc/update/9.0-to-9.1.md +++ b/doc/update/9.0-to-9.1.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 9.0 to 9.1 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/9.1-to-9.2.md b/doc/update/9.1-to-9.2.md index ce72b313031..2fff6544797 100644 --- a/doc/update/9.1-to-9.2.md +++ b/doc/update/9.1-to-9.2.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 9.1 to 9.2 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/9.2-to-9.3.md b/doc/update/9.2-to-9.3.md index 779ced0cf75..1b36cf53f4c 100644 --- a/doc/update/9.2-to-9.3.md +++ b/doc/update/9.2-to-9.3.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 9.2 to 9.3 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/9.3-to-9.4.md b/doc/update/9.3-to-9.4.md index 78d8a6c7de5..210b6eb607d 100644 --- a/doc/update/9.3-to-9.4.md +++ b/doc/update/9.3-to-9.4.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 9.3 to 9.4 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/9.4-to-9.5.md b/doc/update/9.4-to-9.5.md index a7255142ef5..1bfc1167c36 100644 --- a/doc/update/9.4-to-9.5.md +++ b/doc/update/9.4-to-9.5.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 9.4 to 9.5 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/9.5-to-10.0.md b/doc/update/9.5-to-10.0.md index 8581e6511f2..8d1cf0f737b 100644 --- a/doc/update/9.5-to-10.0.md +++ b/doc/update/9.5-to-10.0.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # From 9.5 to 10.0 Make sure you view this update guide from the tag (version) of GitLab you would diff --git a/doc/update/patch_versions.md b/doc/update/patch_versions.md index b2679d1ff22..e1857ce99c6 100644 --- a/doc/update/patch_versions.md +++ b/doc/update/patch_versions.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Universal update guide for patch versions ## Select Version to Install diff --git a/doc/update/upgrader.md b/doc/update/upgrader.md index eb7f14a96d5..746d6bf93e7 100644 --- a/doc/update/upgrader.md +++ b/doc/update/upgrader.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # GitLab Upgrader (deprecated) *DEPRECATED* We recommend to [switch to the Omnibus package and repository server](https://about.gitlab.com/update/) instead of using this script. diff --git a/doc/user/profile/index.md b/doc/user/profile/index.md index 5ebb88bf324..5fcc0501dc1 100644 --- a/doc/user/profile/index.md +++ b/doc/user/profile/index.md @@ -52,7 +52,7 @@ You can edit your account settings by navigating from the up-right corner menu b From there, you can: - Update your personal information -- Manage [private tokens](../../api/README.md#private-tokens), email tokens, [2FA](account/two_factor_authentication.md) +- Manage [2FA](account/two_factor_authentication.md) - Change your username and [delete your account](account/delete_account.md) - Manage applications that can [use GitLab as an OAuth provider](../../integration/oauth_provider.md#introduction-to-oauth) diff --git a/doc/user/profile/personal_access_tokens.md b/doc/user/profile/personal_access_tokens.md index f28c034e74c..9b4fdd65e2f 100644 --- a/doc/user/profile/personal_access_tokens.md +++ b/doc/user/profile/personal_access_tokens.md @@ -2,17 +2,15 @@ > [Introduced][ce-3749] in GitLab 8.8. -Personal access tokens are useful if you need access to the [GitLab API][api]. -Instead of using your private token which grants full access to your account, -personal access tokens could be a better fit because of their -[granular permissions](#limiting-scopes-of-a-personal-access-token). +Personal access tokens are the preferred way for third party applications and scripts to +authenticate with the [GitLab API][api], if using [OAuth2](../../api/oauth2.md) is not practical. You can also use them to authenticate against Git over HTTP. They are the only accepted method of authentication when you have [Two-Factor Authentication (2FA)][2fa] enabled. Once you have your token, [pass it to the API][usage] using either the -`private_token` parameter or the `PRIVATE-TOKEN` header. +`private_token` parameter or the `Private-Token` header. The expiration of personal access tokens happens on the date you define, at midnight UTC. @@ -49,12 +47,14 @@ the following table. |`read_user` | Allows access to the read-only endpoints under `/users`. Essentially, any of the `GET` requests in the [Users API][users] are allowed ([introduced][ce-5951] in GitLab 8.15). | | `api` | Grants complete access to the API (read/write) ([introduced][ce-5951] in GitLab 8.15). Required for accessing Git repositories over HTTP when 2FA is enabled. | | `read_registry` | Allows to read [container registry] images if a project is private and authorization is required ([introduced][ce-11845] in GitLab 9.3). | +| `sudo` | Allows performing API actions as any user in the system (if the authenticated user is an admin) ([introduced][ce-14838] in GitLab 10.2). | [2fa]: ../account/two_factor_authentication.md [api]: ../../api/README.md [ce-3749]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/3749 [ce-5951]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5951 [ce-11845]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11845 +[ce-14838]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14838 [container registry]: ../project/container_registry.md [users]: ../../api/users.md -[usage]: ../../api/README.md#basic-usage +[usage]: ../../api/README.md#personal-access-tokens diff --git a/doc/user/project/integrations/img/webhook_logs.png b/doc/user/project/integrations/img/webhook_logs.png Binary files differindex 917068d9398..803678db6b6 100644 --- a/doc/user/project/integrations/img/webhook_logs.png +++ b/doc/user/project/integrations/img/webhook_logs.png diff --git a/doc/user/project/integrations/project_services.md b/doc/user/project/integrations/project_services.md index 51989ccaaea..a0405161495 100644 --- a/doc/user/project/integrations/project_services.md +++ b/doc/user/project/integrations/project_services.md @@ -43,6 +43,7 @@ Click on the service links to see further configuration instructions and details | [Mattermost slash commands](mattermost_slash_commands.md) | Mattermost chat and ChatOps slash commands | | [Mattermost Notifications](mattermost.md) | Receive event notifications in Mattermost | | [Microsoft teams](microsoft_teams.md) | Receive notifications for actions that happen on GitLab into a room on Microsoft Teams using Office 365 Connectors | +| Packagist | Update your project on Packagist, the main Composer repository | | Pipelines emails | Email the pipeline status to a list of recipients | | [Slack Notifications](slack.md) | Send GitLab events (e.g. issue created) to Slack as notifications | | [Slack slash commands](slack_slash_commands.md) | Use slash commands in Slack to control GitLab | diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index 7abc600a680..5896f8f72a0 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -76,6 +76,7 @@ X-Gitlab-Event: Push Hook "user_avatar": "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80", "project_id": 15, "project":{ + "id": 15, "name":"Diaspora", "description":"", "web_url":"http://example.com/mike/diaspora", @@ -156,6 +157,7 @@ X-Gitlab-Event: Tag Push Hook "user_avatar": "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80", "project_id": 1, "project":{ + "id": 1, "name":"Example", "description":"", "web_url":"http://example.com/jsmith/example", @@ -206,6 +208,7 @@ X-Gitlab-Event: Issue Hook "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" }, "project": { + "id": 1, "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlabhq/gitlab-test", @@ -335,6 +338,7 @@ X-Gitlab-Event: Note Hook }, "project_id": 5, "project":{ + "id": 5, "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlabhq/gitlab-test", @@ -414,6 +418,7 @@ X-Gitlab-Event: Note Hook }, "project_id": 5, "project":{ + "id": 5, "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlab-org/gitlab-test", @@ -540,6 +545,7 @@ X-Gitlab-Event: Note Hook }, "project_id": 5, "project":{ + "id": 5, "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlab-org/gitlab-test", @@ -618,6 +624,7 @@ X-Gitlab-Event: Note Hook }, "project_id": 5, "project":{ + "id": 5, "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlab-org/gitlab-test", @@ -692,6 +699,7 @@ X-Gitlab-Event: Merge Request Hook "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" }, "project": { + "id": 1, "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlabhq/gitlab-test", @@ -848,6 +856,7 @@ X-Gitlab-Event: Wiki Page Hook "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon" }, "project": { + "id": 1, "name": "awesome-project", "description": "This is awesome", "web_url": "http://example.com/root/awesome-project", @@ -919,6 +928,7 @@ X-Gitlab-Event: Pipeline Hook "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" }, "project":{ + "id": 1, "name": "Gitlab Test", "description": "Atque in sunt eos similique dolores voluptatem.", "web_url": "http://192.168.64.1:3005/gitlab-org/gitlab-test", @@ -1130,6 +1140,18 @@ From this page, you can repeat delivery with the same data by clicking `Resend R >**Note:** If URL or secret token of the webhook were updated, data will be delivered to the new address. +### Receiving duplicate or multiple web hook requests triggered by one event + +When GitLab sends a webhook it expects a response in 10 seconds (set default value). If it does not receive one, it'll retry the webhook. +If the endpoint doesn't send its HTTP response within those 10 seconds, GitLab may decide the hook failed and retry it. + +If you are receiving multiple requests, you can try increasing the default value to wait for the HTTP response after sending the webhook +by uncommenting or adding the following setting to your `/etc/gitlab/gitlab.rb`: + +``` +gitlab_rails['webhook_timeout'] = 10 +``` + ## Example webhook receiver If you want to see GitLab's webhooks in action for testing purposes you can use diff --git a/doc/user/project/milestones/index.md b/doc/user/project/milestones/index.md index 876b98a4dc5..83adbd8cce2 100644 --- a/doc/user/project/milestones/index.md +++ b/doc/user/project/milestones/index.md @@ -29,7 +29,8 @@ In addition to that you will be able to filter issues or merge requests by group ## Milestone promotion -You will be able to promote a project milestone to a group milestone [in the future](https://gitlab.com/gitlab-org/gitlab-ce/issues/35833). +Project milestones can be promoted to group milestones if its project belongs to a group. When a milestone is promoted all other milestones across the group projects with the same title will be merged into it, which means all milestone's children like issues, merge requests and boards will be moved into the new promoted milestone. +The promote button can be found in the milestone view or milestones list. ## Special milestone filters diff --git a/doc/user/project/pipelines/job_artifacts.md b/doc/user/project/pipelines/job_artifacts.md index 9ef6f9185c9..f9a268fb789 100644 --- a/doc/user/project/pipelines/job_artifacts.md +++ b/doc/user/project/pipelines/job_artifacts.md @@ -52,7 +52,8 @@ directly in the job artifacts browser without the need to download them. >**Note:** With [GitLab 10.1][ce-14399], HTML files in a public project can be previewed -directly in a new tab without the need to download them. +directly in a new tab without the need to download them when +[GitLab Pages](../../../administration/pages/index.md) is enabled After a job finishes, if you visit the job's specific page, there are three buttons. You can download the artifacts archive or browse its contents, whereas @@ -69,7 +70,8 @@ browse inside them. Below you can see how browsing looks like. In this case we have browsed inside the archive and at this point there is one directory, a couple files, and -one HTML file that you can view directly online (opens in a new tab). +one HTML file that you can view directly online when +[GitLab Pages](../../../administration/pages/index.md) is enabled (opens in a new tab). ![Job artifacts browser](img/job_artifacts_browser.png) diff --git a/doc/workflow/README.md b/doc/workflow/README.md index 6b2aba47f54..272f7807ac0 100644 --- a/doc/workflow/README.md +++ b/doc/workflow/README.md @@ -1,3 +1,7 @@ +--- +comments: false +--- + # Workflow - [Automatic issue closing](../user/project/issues/automatic_issue_closing.md) diff --git a/doc/workflow/gitlab_flow.md b/doc/workflow/gitlab_flow.md index 9d466ae1971..23b67310d25 100644 --- a/doc/workflow/gitlab_flow.md +++ b/doc/workflow/gitlab_flow.md @@ -1,6 +1,6 @@ ![GitLab Flow](gitlab_flow.png) -## Introduction +# Introduction to GitLab Flow Version management with git makes branching and merging much easier than older versioning systems such as SVN. This allows a wide variety of branching strategies and workflows. diff --git a/doc/workflow/shortcuts.md b/doc/workflow/shortcuts.md index 87416008e98..2e1bd6bfe5c 100644 --- a/doc/workflow/shortcuts.md +++ b/doc/workflow/shortcuts.md @@ -9,7 +9,7 @@ You can see GitLab's keyboard shortcuts by using 'shift + ?' | <kbd>n</kbd> | Main navigation | | <kbd>s</kbd> | Focus search | | <kbd>f</kbd> | Focus filter | -| <kbd>p b</kbd> | Show/hide the Performance Bar | +| <kbd>p</kbd> + <kbd>b</kbd> | Show/hide the Performance Bar | | <kbd>?</kbd> | Show/hide this dialog | | <kbd>⌘</kbd> + <kbd>shift</kbd> + <kbd>p</kbd> | Toggle markdown preview | | <kbd>↑</kbd> | Edit last comment (when focused on an empty textarea) | diff --git a/features/steps/profile/notifications.rb b/features/steps/profile/notifications.rb index 7e339443b75..f8eb0f01de8 100644 --- a/features/steps/profile/notifications.rb +++ b/features/steps/profile/notifications.rb @@ -11,7 +11,7 @@ class Spinach::Features::ProfileNotifications < Spinach::FeatureSteps end step 'I select Mention setting from dropdown' do - first(:link, "On mention").trigger('click') + first(:link, "On mention").click end step 'I should see Notification saved message' do diff --git a/features/steps/project/commits/branches.rb b/features/steps/project/commits/branches.rb index ccaf3237815..c3ae33d2aa9 100644 --- a/features/steps/project/commits/branches.rb +++ b/features/steps/project/commits/branches.rb @@ -40,6 +40,7 @@ class Spinach::Features::ProjectCommitsBranches < Spinach::FeatureSteps step 'I submit new branch form with invalid name' do fill_in 'branch_name', with: '1.0 stable' + page.find("body").click # defocus the branch_name input select_branch('master') click_button 'Create branch' end @@ -70,17 +71,16 @@ class Spinach::Features::ProjectCommitsBranches < Spinach::FeatureSteps step "I click branch 'improve/awesome' delete link" do page.within '.js-branch-improve\/awesome' do - find('.btn-remove').click - sleep 0.05 + accept_alert { find('.btn-remove').click } end end step "I should not see branch 'improve/awesome'" do - expect(page.all(visible: true)).not_to have_content 'improve/awesome' + expect(page).to have_css('.js-branch-improve\\/awesome', visible: :hidden) end def select_branch(branch_name) - click_button 'master' + find('.git-revision-dropdown-toggle').click page.within '#new-branch-form .dropdown-menu' do click_link branch_name diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb index 2c3ef2efd52..3843374678c 100644 --- a/features/steps/project/issues/issues.rb +++ b/features/steps/project/issues/issues.rb @@ -20,11 +20,13 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end step 'I should see that I am subscribed' do - expect(find('.issuable-subscribe-button span')).to have_content 'Unsubscribe' + wait_for_requests + expect(find('.js-issuable-subscribe-button span')).to have_content 'Unsubscribe' end step 'I should see that I am unsubscribed' do - expect(find('.issuable-subscribe-button span')).to have_content 'Subscribe' + wait_for_requests + expect(find('.js-issuable-subscribe-button span')).to have_content 'Subscribe' end step 'I click link "Closed"' do diff --git a/features/steps/project/issues/labels.rb b/features/steps/project/issues/labels.rb index dac18c537ac..196e0fff63a 100644 --- a/features/steps/project/issues/labels.rb +++ b/features/steps/project/issues/labels.rb @@ -16,7 +16,7 @@ class Spinach::Features::ProjectIssuesLabels < Spinach::FeatureSteps step 'I delete all labels' do page.within '.labels' do page.all('.remove-row').each do - first('.remove-row').click + accept_confirm { first('.remove-row').click } end end end diff --git a/features/steps/project/issues/milestones.rb b/features/steps/project/issues/milestones.rb index 16a2d4a6f93..33a24e8913a 100644 --- a/features/steps/project/issues/milestones.rb +++ b/features/steps/project/issues/milestones.rb @@ -3,6 +3,7 @@ class Spinach::Features::ProjectIssuesMilestones < Spinach::FeatureSteps include SharedProject include SharedPaths include SharedMarkdown + include CapybaraHelpers step 'I should see milestone "v2.2"' do milestone = @project.milestones.find_by(title: "v2.2") @@ -65,7 +66,7 @@ class Spinach::Features::ProjectIssuesMilestones < Spinach::FeatureSteps end step 'I click link to remove milestone' do - click_link 'Delete' + confirm_modal_if_present { click_link 'Delete' } end step 'I should see no milestones' do diff --git a/features/steps/shared/diff_note.rb b/features/steps/shared/diff_note.rb index c872bd6f861..aa32528a7ca 100644 --- a/features/steps/shared/diff_note.rb +++ b/features/steps/shared/diff_note.rb @@ -215,7 +215,7 @@ module SharedDiffNote end step 'I click side-by-side diff button' do - find('#parallel-diff-btn').trigger('click') + find('#parallel-diff-btn').click end step 'I see side-by-side diff button' do @@ -227,12 +227,11 @@ module SharedDiffNote end def click_diff_line(code) - find(".line_holder[id='#{code}'] td:nth-of-type(1)").trigger 'mouseover' - find(".line_holder[id='#{code}'] button").trigger 'click' + find(".line_holder[id='#{code}'] button").click end def click_parallel_diff_line(code, line_type) - find(".line_holder.parallel td[id='#{code}']").find(:xpath, 'preceding-sibling::*[1][self::td]').trigger 'mouseover' - find(".line_holder.parallel button[data-line-code='#{code}']").trigger 'click' + find(".line_holder.parallel td[id='#{code}']").find(:xpath, 'preceding-sibling::*[1][self::td]').hover + find(".line_holder.parallel button[data-line-code='#{code}']").click end end diff --git a/features/steps/shared/note.rb b/features/steps/shared/note.rb index 0cd7b506a95..95f0cd2156e 100644 --- a/features/steps/shared/note.rb +++ b/features/steps/shared/note.rb @@ -14,7 +14,7 @@ module SharedNote find('.more-actions').click find('.more-actions .dropdown-menu li', match: :first) - find(".js-note-delete").click + accept_confirm { find(".js-note-delete").click } end end diff --git a/features/support/capybara.rb b/features/support/capybara.rb index f4691647d4b..3c4db8b9601 100644 --- a/features/support/capybara.rb +++ b/features/support/capybara.rb @@ -1,22 +1,21 @@ -require 'capybara/poltergeist' require 'capybara-screenshot/spinach' # Give CI some extra time timeout = (ENV['CI'] || ENV['CI_SERVER']) ? 60 : 30 -Capybara.javascript_driver = :poltergeist -Capybara.register_driver :poltergeist do |app| - Capybara::Poltergeist::Driver.new( - app, - js_errors: true, - timeout: timeout, - window_size: [1366, 768], - url_whitelist: %w[localhost 127.0.0.1], - url_blacklist: %w[.mp4 .png .gif .avi .bmp .jpg .jpeg], - phantomjs_options: [ - '--load-images=yes' - ] +Capybara.javascript_driver = :chrome +Capybara.register_driver :chrome do |app| + extra_args = [] + extra_args << 'headless' unless ENV['CHROME_HEADLESS'] =~ /^(false|no|0)$/i + + capabilities = Selenium::WebDriver::Remote::Capabilities.chrome( + chromeOptions: { + 'args' => %w[no-sandbox disable-gpu --window-size=1240,1400] + extra_args + } ) + + Capybara::Selenium::Driver + .new(app, browser: :chrome, desired_capabilities: capabilities) end Capybara.default_max_wait_time = timeout @@ -24,6 +23,10 @@ Capybara.ignore_hidden_elements = false # Keep only the screenshots generated from the last failing test suite Capybara::Screenshot.prune_strategy = :keep_last_run +# From https://github.com/mattheworiordan/capybara-screenshot/issues/84#issuecomment-41219326 +Capybara::Screenshot.register_driver(:chrome) do |driver, path| + driver.browser.save_screenshot(path) +end Spinach.hooks.before_run do TestEnv.eager_load_driver_server diff --git a/features/support/capybara_helpers.rb b/features/support/capybara_helpers.rb new file mode 100644 index 00000000000..647f8d087c3 --- /dev/null +++ b/features/support/capybara_helpers.rb @@ -0,0 +1,10 @@ +module CapybaraHelpers + def confirm_modal_if_present + if Capybara.current_driver == Capybara.javascript_driver + accept_confirm { yield } + return + end + + yield + end +end diff --git a/lib/additional_email_headers_interceptor.rb b/lib/additional_email_headers_interceptor.rb index 2358fa6bbfd..3cb1694b9f1 100644 --- a/lib/additional_email_headers_interceptor.rb +++ b/lib/additional_email_headers_interceptor.rb @@ -1,8 +1,6 @@ class AdditionalEmailHeadersInterceptor def self.delivering_email(message) - message.headers( - 'Auto-Submitted' => 'auto-generated', - 'X-Auto-Response-Suppress' => 'All' - ) + message.header['Auto-Submitted'] ||= 'auto-generated' + message.header['X-Auto-Response-Suppress'] ||= 'All' end end diff --git a/lib/api/api.rb b/lib/api/api.rb index 7db18e25a5f..c37e596eb9d 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -142,7 +142,6 @@ module API mount ::API::Runner mount ::API::Runners mount ::API::Services - mount ::API::Session mount ::API::Settings mount ::API::SidekiqMetrics mount ::API::Snippets diff --git a/lib/api/api_guard.rb b/lib/api/api_guard.rb index 87b9db66efd..b9c7d443f6c 100644 --- a/lib/api/api_guard.rb +++ b/lib/api/api_guard.rb @@ -42,72 +42,42 @@ module API # Helper Methods for Grape Endpoint module HelperMethods - def find_current_user - user = - find_user_from_private_token || - find_user_from_oauth_token || - find_user_from_warden + def find_current_user! + user = find_user_from_access_token || find_user_from_warden + return unless user - return nil unless user - - raise UnauthorizedError unless Gitlab::UserAccess.new(user).allowed? && user.can?(:access_api) + forbidden!('User is blocked') unless Gitlab::UserAccess.new(user).allowed? && user.can?(:access_api) user end - def private_token - params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER] - end - - private - - def find_user_from_private_token - token_string = private_token.to_s - return nil unless token_string.present? + def access_token + return @access_token if defined?(@access_token) - user = - find_user_by_authentication_token(token_string) || - find_user_by_personal_access_token(token_string) - - raise UnauthorizedError unless user - - user + @access_token = find_oauth_access_token || find_personal_access_token end - # Invokes the doorkeeper guard. - # - # If token is presented and valid, then it sets @current_user. - # - # If the token does not have sufficient scopes to cover the requred scopes, - # then it raises InsufficientScopeError. - # - # If the token is expired, then it raises ExpiredError. - # - # If the token is revoked, then it raises RevokedError. - # - # If the token is not found (nil), then it returns nil - # - # Arguments: - # - # scopes: (optional) scopes required for this guard. - # Defaults to empty array. - # - def find_user_from_oauth_token - access_token = find_oauth_access_token + def validate_access_token!(scopes: []) return unless access_token - find_user_by_access_token(access_token) + case AccessTokenValidationService.new(access_token, request: request).validate(scopes: scopes) + when AccessTokenValidationService::INSUFFICIENT_SCOPE + raise InsufficientScopeError.new(scopes) + when AccessTokenValidationService::EXPIRED + raise ExpiredError + when AccessTokenValidationService::REVOKED + raise RevokedError + end end - def find_user_by_authentication_token(token_string) - User.find_by_authentication_token(token_string) - end + private - def find_user_by_personal_access_token(token_string) - access_token = PersonalAccessToken.find_by_token(token_string) + def find_user_from_access_token return unless access_token - find_user_by_access_token(access_token) + validate_access_token! + + access_token.user || raise(UnauthorizedError) end # Check the Rails session for valid authentication details @@ -125,34 +95,26 @@ module API end def find_oauth_access_token - return @oauth_access_token if defined?(@oauth_access_token) - token = Doorkeeper::OAuth::Token.from_request(doorkeeper_request, *Doorkeeper.configuration.access_token_methods) - return @oauth_access_token = nil unless token + return unless token - @oauth_access_token = OauthAccessToken.by_token(token) - raise UnauthorizedError unless @oauth_access_token + # Expiration, revocation and scopes are verified in `find_user_by_access_token` + access_token = OauthAccessToken.by_token(token) + raise UnauthorizedError unless access_token - @oauth_access_token.revoke_previous_refresh_token! - @oauth_access_token + access_token.revoke_previous_refresh_token! + access_token end - def find_user_by_access_token(access_token) - scopes = scopes_registered_for_endpoint + def find_personal_access_token + token = (params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]).to_s + return unless token.present? - case AccessTokenValidationService.new(access_token, request: request).validate(scopes: scopes) - when AccessTokenValidationService::INSUFFICIENT_SCOPE - raise InsufficientScopeError.new(scopes) - - when AccessTokenValidationService::EXPIRED - raise ExpiredError + # Expiration, revocation and scopes are verified in `find_user_by_access_token` + access_token = PersonalAccessToken.find_by(token: token) + raise UnauthorizedError unless access_token - when AccessTokenValidationService::REVOKED - raise RevokedError - - when AccessTokenValidationService::VALID - access_token.user - end + access_token end def doorkeeper_request @@ -236,7 +198,7 @@ module API class InsufficientScopeError < StandardError attr_reader :scopes def initialize(scopes) - @scopes = scopes + @scopes = scopes.map { |s| s.try(:name) || s } end end end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index efe874b2e6b..67cecb6a7ad 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -57,10 +57,6 @@ module API expose :admin?, as: :is_admin end - class UserWithPrivateDetails < UserWithAdmin - expose :private_token - end - class Email < Grape::Entity expose :id, :email end diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 7a2ec865860..1c12166e434 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -41,6 +41,8 @@ module API sudo! + validate_access_token!(scopes: scopes_registered_for_endpoint) unless sudo? + @current_user end @@ -385,7 +387,7 @@ module API return @initial_current_user if defined?(@initial_current_user) begin - @initial_current_user = Gitlab::Auth::UniqueIpsLimiter.limit_user! { find_current_user } + @initial_current_user = Gitlab::Auth::UniqueIpsLimiter.limit_user! { find_current_user! } rescue APIGuard::UnauthorizedError unauthorized! end @@ -393,24 +395,23 @@ module API def sudo! return unless sudo_identifier - return unless initial_current_user + + unauthorized! unless initial_current_user unless initial_current_user.admin? forbidden!('Must be admin to use sudo') end - # Only private tokens should be used for the SUDO feature - unless private_token == initial_current_user.private_token - forbidden!('Private token must be specified in order to use sudo') + unless access_token + forbidden!('Must be authenticated using an OAuth or Personal Access Token to use sudo') end + validate_access_token!(scopes: [:sudo]) + sudoed_user = find_user(sudo_identifier) + not_found!("User with ID or username '#{sudo_identifier}'") unless sudoed_user - if sudoed_user - @current_user = sudoed_user - else - not_found!("No user id or username for: #{sudo_identifier}") - end + @current_user = sudoed_user end def sudo_identifier diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index be843ec8251..726f09e3669 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -295,7 +295,7 @@ module API unauthorized! unless merge_request.can_cancel_merge_when_pipeline_succeeds?(current_user) - ::MergeRequest::MergeWhenPipelineSucceedsService + ::MergeRequests::MergeWhenPipelineSucceedsService .new(merge_request.target_project, current_user) .cancel(merge_request) end diff --git a/lib/api/services.rb b/lib/api/services.rb index 1e4f7c29633..6454e475036 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -374,6 +374,26 @@ module API desc: 'The Slack token' } ], + 'packagist' => [ + { + required: true, + name: :username, + type: String, + desc: 'The username' + }, + { + required: true, + name: :token, + type: String, + desc: 'The Packagist API token' + }, + { + required: false, + name: :server, + type: String, + desc: 'The server' + } + ], 'pipelines-email' => [ { required: true, @@ -551,6 +571,7 @@ module API KubernetesService, MattermostSlashCommandsService, SlackSlashCommandsService, + PackagistService, PipelinesEmailService, PivotaltrackerService, PrometheusService, diff --git a/lib/api/session.rb b/lib/api/session.rb deleted file mode 100644 index 016415c3023..00000000000 --- a/lib/api/session.rb +++ /dev/null @@ -1,20 +0,0 @@ -module API - class Session < Grape::API - desc 'Login to get token' do - success Entities::UserWithPrivateDetails - end - params do - optional :login, type: String, desc: 'The username' - optional :email, type: String, desc: 'The email of the user' - requires :password, type: String, desc: 'The password of the user' - at_least_one_of :login, :email - end - post "/session" do - user = Gitlab::Auth.find_with_user_password(params[:email] || params[:login], params[:password]) - - return unauthorized! unless user - return render_api_error!('401 Unauthorized. You have 2FA enabled. Please use a personal access token to access the API', 401) if user.two_factor_enabled? - present user, with: Entities::UserWithPrivateDetails - end - end -end diff --git a/lib/api/users.rb b/lib/api/users.rb index b6f97a1eac2..d80b364bd09 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -507,9 +507,7 @@ module API end get do entity = - if sudo? - Entities::UserWithPrivateDetails - elsif current_user.admin? + if current_user.admin? Entities::UserWithAdmin else Entities::UserPublic diff --git a/lib/api/v3/services.rb b/lib/api/v3/services.rb index 2d13d6fabfd..44ed94d2869 100644 --- a/lib/api/v3/services.rb +++ b/lib/api/v3/services.rb @@ -395,6 +395,26 @@ module API desc: 'The Slack token' } ], + 'packagist' => [ + { + required: true, + name: :username, + type: String, + desc: 'The username' + }, + { + required: true, + name: :token, + type: String, + desc: 'The Packagist API token' + }, + { + required: false, + name: :server, + type: String, + desc: 'The server' + } + ], 'pipelines-email' => [ { required: true, diff --git a/lib/banzai/filter/abstract_reference_filter.rb b/lib/banzai/filter/abstract_reference_filter.rb index ef4578aabd6..a0f7e4e5ad5 100644 --- a/lib/banzai/filter/abstract_reference_filter.rb +++ b/lib/banzai/filter/abstract_reference_filter.rb @@ -95,7 +95,7 @@ module Banzai end def call - return doc if project.nil? + return doc unless project || group ref_pattern = object_class.reference_pattern link_pattern = object_class.link_reference_pattern @@ -288,10 +288,14 @@ module Banzai end def current_project_path + return unless project + @current_project_path ||= project.full_path end def current_project_namespace_path + return unless project + @current_project_namespace_path ||= project.namespace.full_path end diff --git a/lib/banzai/filter/reference_filter.rb b/lib/banzai/filter/reference_filter.rb index a6f8650ed3d..c6ae28adf87 100644 --- a/lib/banzai/filter/reference_filter.rb +++ b/lib/banzai/filter/reference_filter.rb @@ -55,6 +55,10 @@ module Banzai context[:project] end + def group + context[:group] + end + def skip_project_check? context[:skip_project_check] end diff --git a/lib/banzai/filter/user_reference_filter.rb b/lib/banzai/filter/user_reference_filter.rb index f3356d6c51e..afb6e25963c 100644 --- a/lib/banzai/filter/user_reference_filter.rb +++ b/lib/banzai/filter/user_reference_filter.rb @@ -24,7 +24,7 @@ module Banzai end def call - return doc if project.nil? && !skip_project_check? + return doc if project.nil? && group.nil? && !skip_project_check? ref_pattern = User.reference_pattern ref_pattern_start = /\A#{ref_pattern}\z/ @@ -101,19 +101,12 @@ module Banzai end def link_to_all(link_content: nil) - project = context[:project] author = context[:author] - if author && !project.team.member?(author) + if author && !team_member?(author) link_content else - url = urls.project_url(project, - only_path: context[:only_path]) - - data = data_attribute(project: project.id, author: author.try(:id)) - content = link_content || User.reference_prefix + 'all' - - link_tag(url, data, content, 'All Project and Group Members') + parent_url(link_content, author) end end @@ -144,6 +137,35 @@ module Banzai def link_tag(url, data, link_content, title) %(<a href="#{url}" #{data} class="#{link_class}" title="#{escape_once(title)}">#{link_content}</a>) end + + def parent + context[:project] || context[:group] + end + + def parent_group? + parent.is_a?(Group) + end + + def team_member?(user) + if parent_group? + parent.member?(user) + else + parent.team.member?(user) + end + end + + def parent_url(link_content, author) + if parent_group? + url = urls.group_url(parent, only_path: context[:only_path]) + data = data_attribute(group: group.id, author: author.try(:id)) + else + url = urls.project_url(parent, only_path: context[:only_path]) + data = data_attribute(project: project.id, author: author.try(:id)) + end + + content = link_content || User.reference_prefix + 'all' + link_tag(url, data, content, 'All Project and Group Members') + end end end end diff --git a/lib/github/import.rb b/lib/github/import.rb index 76612799412..8cabbdec940 100644 --- a/lib/github/import.rb +++ b/lib/github/import.rb @@ -60,7 +60,9 @@ module Github project.repository.set_import_remote_as_mirror('github') project.repository.add_remote_fetch_config('github', '+refs/pull/*/head:refs/merge-requests/*/head') fetch_remote(forced: true) - rescue Gitlab::Git::Repository::NoRepository, Gitlab::Shell::Error => e + rescue Gitlab::Git::Repository::NoRepository, + Gitlab::Git::RepositoryMirroring::RemoteError, + Gitlab::Shell::Error => e error(:project, repo_url, e.message) raise Github::RepositoryFetchError end diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index 87aeb76b66a..0ad9285c0ea 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -1,11 +1,11 @@ module Gitlab module Auth - MissingPersonalTokenError = Class.new(StandardError) + MissingPersonalAccessTokenError = Class.new(StandardError) REGISTRY_SCOPES = [:read_registry].freeze # Scopes used for GitLab API access - API_SCOPES = [:api, :read_user].freeze + API_SCOPES = [:api, :read_user, :sudo].freeze # Scopes used for OpenID Connect OPENID_SCOPES = [:openid].freeze @@ -38,7 +38,7 @@ module Gitlab # If sign-in is disabled and LDAP is not configured, recommend a # personal access token on failed auth attempts - raise Gitlab::Auth::MissingPersonalTokenError + raise Gitlab::Auth::MissingPersonalAccessTokenError end def find_with_user_password(login, password) @@ -106,7 +106,7 @@ module Gitlab user = find_with_user_password(login, password) return unless user - raise Gitlab::Auth::MissingPersonalTokenError if user.two_factor_enabled? + raise Gitlab::Auth::MissingPersonalAccessTokenError if user.two_factor_enabled? Gitlab::Auth::Result.new(user, nil, :gitlab_or_ldap, full_authentication_abilities) end @@ -128,7 +128,7 @@ module Gitlab token = PersonalAccessTokensFinder.new(state: 'active').find_by(token: password) if token && valid_scoped_token?(token, available_scopes) - Gitlab::Auth::Result.new(token.user, nil, :personal_token, abilities_for_scope(token.scopes)) + Gitlab::Auth::Result.new(token.user, nil, :personal_access_token, abilities_for_scope(token.scopes)) end end @@ -226,8 +226,10 @@ module Gitlab [] end - def available_scopes - API_SCOPES + registry_scopes + def available_scopes(current_user = nil) + scopes = API_SCOPES + registry_scopes + scopes.delete(:sudo) if current_user && !current_user.admin? + scopes end # Other available scopes diff --git a/lib/gitlab/ci/status/build/cancelable.rb b/lib/gitlab/ci/status/build/cancelable.rb index 8ad3e57e59d..2d9166d6bdd 100644 --- a/lib/gitlab/ci/status/build/cancelable.rb +++ b/lib/gitlab/ci/status/build/cancelable.rb @@ -8,7 +8,7 @@ module Gitlab end def action_icon - 'icon_action_cancel' + 'cancel' end def action_path diff --git a/lib/gitlab/ci/status/build/failed_allowed.rb b/lib/gitlab/ci/status/build/failed_allowed.rb index e42d3574357..d71e63e73eb 100644 --- a/lib/gitlab/ci/status/build/failed_allowed.rb +++ b/lib/gitlab/ci/status/build/failed_allowed.rb @@ -8,7 +8,7 @@ module Gitlab end def icon - 'icon_status_warning' + 'warning' end def group diff --git a/lib/gitlab/ci/status/build/play.rb b/lib/gitlab/ci/status/build/play.rb index c7726543599..b7b45466d3b 100644 --- a/lib/gitlab/ci/status/build/play.rb +++ b/lib/gitlab/ci/status/build/play.rb @@ -12,7 +12,7 @@ module Gitlab end def action_icon - 'icon_action_play' + 'play' end def action_title diff --git a/lib/gitlab/ci/status/build/retryable.rb b/lib/gitlab/ci/status/build/retryable.rb index 8c8fdc56d75..44ffe783e50 100644 --- a/lib/gitlab/ci/status/build/retryable.rb +++ b/lib/gitlab/ci/status/build/retryable.rb @@ -8,7 +8,7 @@ module Gitlab end def action_icon - 'icon_action_retry' + 'retry' end def action_title diff --git a/lib/gitlab/ci/status/build/stop.rb b/lib/gitlab/ci/status/build/stop.rb index d464738deaf..46e730797e4 100644 --- a/lib/gitlab/ci/status/build/stop.rb +++ b/lib/gitlab/ci/status/build/stop.rb @@ -12,7 +12,7 @@ module Gitlab end def action_icon - 'icon_action_stop' + 'stop' end def action_title diff --git a/lib/gitlab/ci/status/canceled.rb b/lib/gitlab/ci/status/canceled.rb index e5fdc1f8136..e6195a60d4f 100644 --- a/lib/gitlab/ci/status/canceled.rb +++ b/lib/gitlab/ci/status/canceled.rb @@ -11,7 +11,7 @@ module Gitlab end def icon - 'icon_status_canceled' + 'status_canceled' end def favicon diff --git a/lib/gitlab/ci/status/created.rb b/lib/gitlab/ci/status/created.rb index d188bd286a6..846f00b83dd 100644 --- a/lib/gitlab/ci/status/created.rb +++ b/lib/gitlab/ci/status/created.rb @@ -11,7 +11,7 @@ module Gitlab end def icon - 'icon_status_created' + 'status_created' end def favicon diff --git a/lib/gitlab/ci/status/failed.rb b/lib/gitlab/ci/status/failed.rb index 38e45714c22..27ce85bd3ed 100644 --- a/lib/gitlab/ci/status/failed.rb +++ b/lib/gitlab/ci/status/failed.rb @@ -11,7 +11,7 @@ module Gitlab end def icon - 'icon_status_failed' + 'status_failed' end def favicon diff --git a/lib/gitlab/ci/status/manual.rb b/lib/gitlab/ci/status/manual.rb index a4a7edadac9..fc387e2fd25 100644 --- a/lib/gitlab/ci/status/manual.rb +++ b/lib/gitlab/ci/status/manual.rb @@ -11,7 +11,7 @@ module Gitlab end def icon - 'icon_status_manual' + 'status_manual' end def favicon diff --git a/lib/gitlab/ci/status/pending.rb b/lib/gitlab/ci/status/pending.rb index 5164260b861..6780780db32 100644 --- a/lib/gitlab/ci/status/pending.rb +++ b/lib/gitlab/ci/status/pending.rb @@ -11,7 +11,7 @@ module Gitlab end def icon - 'icon_status_pending' + 'status_pending' end def favicon diff --git a/lib/gitlab/ci/status/running.rb b/lib/gitlab/ci/status/running.rb index 993937e98ca..ee13905e46d 100644 --- a/lib/gitlab/ci/status/running.rb +++ b/lib/gitlab/ci/status/running.rb @@ -11,7 +11,7 @@ module Gitlab end def icon - 'icon_status_running' + 'status_running' end def favicon diff --git a/lib/gitlab/ci/status/skipped.rb b/lib/gitlab/ci/status/skipped.rb index 0c942920b02..0dbdc4de426 100644 --- a/lib/gitlab/ci/status/skipped.rb +++ b/lib/gitlab/ci/status/skipped.rb @@ -11,7 +11,7 @@ module Gitlab end def icon - 'icon_status_skipped' + 'status_skipped' end def favicon diff --git a/lib/gitlab/ci/status/success.rb b/lib/gitlab/ci/status/success.rb index d7af98857b0..731013ec017 100644 --- a/lib/gitlab/ci/status/success.rb +++ b/lib/gitlab/ci/status/success.rb @@ -11,7 +11,7 @@ module Gitlab end def icon - 'icon_status_success' + 'status_success' end def favicon diff --git a/lib/gitlab/ci/status/success_warning.rb b/lib/gitlab/ci/status/success_warning.rb index 4d7d82e04cf..32b4cf43e48 100644 --- a/lib/gitlab/ci/status/success_warning.rb +++ b/lib/gitlab/ci/status/success_warning.rb @@ -15,7 +15,7 @@ module Gitlab end def icon - 'icon_status_warning' + 'status_warning' end def group diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb index 357f16936c6..43a00d6cedb 100644 --- a/lib/gitlab/database.rb +++ b/lib/gitlab/database.rb @@ -4,6 +4,10 @@ module Gitlab # https://www.postgresql.org/docs/9.2/static/datatype-numeric.html # http://dev.mysql.com/doc/refman/5.7/en/integer-types.html MAX_INT_VALUE = 2147483647 + # The max value between MySQL's TIMESTAMP and PostgreSQL's timestampz: + # https://www.postgresql.org/docs/9.1/static/datatype-datetime.html + # https://dev.mysql.com/doc/refman/5.7/en/datetime.html + MAX_TIMESTAMP_VALUE = Time.at((1 << 31) - 1).freeze def self.config ActiveRecord::Base.configurations[Rails.env] @@ -120,6 +124,10 @@ module Gitlab EOF end + def self.sanitize_timestamp(timestamp) + MAX_TIMESTAMP_VALUE > timestamp ? timestamp : MAX_TIMESTAMP_VALUE.dup + end + # pool_size - The size of the DB pool. # host - An optional host name to use instead of the default one. def self.create_connection_pool(pool_size, host = nil) diff --git a/lib/gitlab/diff/position.rb b/lib/gitlab/diff/position.rb index bd0a9502a5e..ccfb908bcca 100644 --- a/lib/gitlab/diff/position.rb +++ b/lib/gitlab/diff/position.rb @@ -94,7 +94,9 @@ module Gitlab end def diff_file(repository) - @diff_file ||= begin + return @diff_file if defined?(@diff_file) + + @diff_file = begin if RequestStore.active? key = { project_id: repository.project.id, @@ -122,8 +124,8 @@ module Gitlab def find_diff_file(repository) return unless diff_refs.complete? - - diff_refs.compare_in(repository.project).diffs(paths: paths, expanded: true).diff_files.first + return unless comparison = diff_refs.compare_in(repository.project) + comparison.diffs(paths: paths, expanded: true).diff_files.first end def get_formatter_class(type) diff --git a/lib/gitlab/ee_compat_check.rb b/lib/gitlab/ee_compat_check.rb index c4c60d1dfee..0ea534a5fd0 100644 --- a/lib/gitlab/ee_compat_check.rb +++ b/lib/gitlab/ee_compat_check.rb @@ -2,8 +2,8 @@ module Gitlab # Checks if a set of migrations requires downtime or not. class EeCompatCheck - DEFAULT_CE_REPO = 'https://gitlab.com/gitlab-org/gitlab-ce.git'.freeze - EE_REPO = 'https://gitlab.com/gitlab-org/gitlab-ee.git'.freeze + DEFAULT_CE_PROJECT_URL = 'https://gitlab.com/gitlab-org/gitlab-ce'.freeze + EE_REPO_URL = 'https://gitlab.com/gitlab-org/gitlab-ee.git'.freeze CHECK_DIR = Rails.root.join('ee_compat_check') IGNORED_FILES_REGEX = /(VERSION|CHANGELOG\.md:\d+)/.freeze PLEASE_READ_THIS_BANNER = %Q{ @@ -17,14 +17,16 @@ module Gitlab ============================================================\n }.freeze - attr_reader :ee_repo_dir, :patches_dir, :ce_repo, :ce_branch, :ee_branch_found - attr_reader :failed_files + attr_reader :ee_repo_dir, :patches_dir, :ce_project_url, :ce_repo_url, :ce_branch, :ee_branch_found + attr_reader :job_id, :failed_files - def initialize(branch:, ce_repo: DEFAULT_CE_REPO) + def initialize(branch:, ce_project_url: DEFAULT_CE_PROJECT_URL, job_id: nil) @ee_repo_dir = CHECK_DIR.join('ee-repo') @patches_dir = CHECK_DIR.join('patches') @ce_branch = branch - @ce_repo = ce_repo + @ce_project_url = ce_project_url + @ce_repo_url = "#{ce_project_url}.git" + @job_id = job_id end def check @@ -59,8 +61,8 @@ module Gitlab step("#{ee_repo_dir} already exists") else step( - "Cloning #{EE_REPO} into #{ee_repo_dir}", - %W[git clone --branch master --single-branch --depth=200 #{EE_REPO} #{ee_repo_dir}] + "Cloning #{EE_REPO_URL} into #{ee_repo_dir}", + %W[git clone --branch master --single-branch --depth=200 #{EE_REPO_URL} #{ee_repo_dir}] ) end end @@ -132,7 +134,7 @@ module Gitlab def check_patch(patch_path) step("Checking out master", %w[git checkout master]) step("Resetting to latest master", %w[git reset --hard origin/master]) - step("Fetching CE/#{ce_branch}", %W[git fetch #{ce_repo} #{ce_branch}]) + step("Fetching CE/#{ce_branch}", %W[git fetch #{ce_repo_url} #{ce_branch}]) step( "Checking if #{patch_path} applies cleanly to EE/master", # Don't use --check here because it can result in a 0-exit status even @@ -237,7 +239,7 @@ module Gitlab end def patch_url - "https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/#{ENV['CI_JOB_ID']}/artifacts/raw/ee_compat_check/patches/#{ce_patch_name}" + "#{ce_project_url}/-/jobs/#{job_id}/artifacts/raw/ee_compat_check/patches/#{ce_patch_name}" end def step(desc, cmd = nil) @@ -304,7 +306,7 @@ module Gitlab # In the EE repo $ git fetch origin $ git checkout -b #{ee_branch_prefix} origin/master - $ git fetch #{ce_repo} #{ce_branch} + $ git fetch #{ce_repo_url} #{ce_branch} $ git cherry-pick SHA # Repeat for all the commits you want to pick You can squash the `#{ce_branch}` commits into a single "Port of #{ce_branch} to EE" commit. diff --git a/lib/gitlab/gcp/model.rb b/lib/gitlab/gcp/model.rb deleted file mode 100644 index 195391f0e3c..00000000000 --- a/lib/gitlab/gcp/model.rb +++ /dev/null @@ -1,13 +0,0 @@ -module Gitlab - module Gcp - module Model - def table_name_prefix - "gcp_" - end - - def model_name - @model_name ||= ActiveModel::Name.new(self, nil, self.name.split("::").last) - end - end - end -end diff --git a/lib/gitlab/git/blob.rb b/lib/gitlab/git/blob.rb index a4336facee5..cc6c7609ec7 100644 --- a/lib/gitlab/git/blob.rb +++ b/lib/gitlab/git/blob.rb @@ -12,6 +12,12 @@ module Gitlab # blob data should use load_all_data!. MAX_DATA_DISPLAY_SIZE = 10.megabytes + # These limits are used as a heuristic to ignore files which can't be LFS + # pointers. The format of these is described in + # https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md#the-pointer + LFS_POINTER_MIN_SIZE = 120.bytes + LFS_POINTER_MAX_SIZE = 200.bytes + attr_accessor :name, :path, :size, :data, :mode, :id, :commit_id, :loaded_size, :binary class << self @@ -30,16 +36,7 @@ module Gitlab if is_enabled Gitlab::GitalyClient::BlobService.new(repository).get_blob(oid: sha, limit: MAX_DATA_DISPLAY_SIZE) else - blob = repository.lookup(sha) - - next unless blob.is_a?(Rugged::Blob) - - new( - id: blob.oid, - size: blob.size, - data: blob.content(MAX_DATA_DISPLAY_SIZE), - binary: blob.binary? - ) + rugged_raw(repository, sha, limit: MAX_DATA_DISPLAY_SIZE) end end end @@ -59,10 +56,25 @@ module Gitlab end end + # Find LFS blobs given an array of sha ids + # Returns array of Gitlab::Git::Blob + # Does not guarantee blob data will be set + def batch_lfs_pointers(repository, blob_ids) + blob_ids.lazy + .select { |sha| possible_lfs_blob?(repository, sha) } + .map { |sha| rugged_raw(repository, sha, limit: LFS_POINTER_MAX_SIZE) } + .select(&:lfs_pointer?) + .force + end + def binary?(data) EncodingHelper.detect_libgit2_binary?(data) end + def size_could_be_lfs?(size) + size.between?(LFS_POINTER_MIN_SIZE, LFS_POINTER_MAX_SIZE) + end + private # Recursive search of blob id by path @@ -167,6 +179,29 @@ module Gitlab end end end + + def rugged_raw(repository, sha, limit:) + blob = repository.lookup(sha) + + return unless blob.is_a?(Rugged::Blob) + + new( + id: blob.oid, + size: blob.size, + data: blob.content(limit), + binary: blob.binary? + ) + end + + # Efficient lookup to determine if object size + # and type make it a possible LFS blob without loading + # blob content into memory with repository.lookup(sha) + def possible_lfs_blob?(repository, sha) + object_header = repository.rugged.read_header(sha) + + object_header[:type] == :blob && + size_could_be_lfs?(object_header[:len]) + end end def initialize(options) @@ -226,7 +261,7 @@ module Gitlab # size # see https://github.com/github/git-lfs/blob/v1.1.0/docs/spec.md#the-pointer def lfs_pointer? - has_lfs_version_key? && lfs_oid.present? && lfs_size.present? + self.class.size_could_be_lfs?(size) && has_lfs_version_key? && lfs_oid.present? && lfs_size.present? end def lfs_oid diff --git a/lib/gitlab/git/branch.rb b/lib/gitlab/git/branch.rb index c53882787f1..3487e099381 100644 --- a/lib/gitlab/git/branch.rb +++ b/lib/gitlab/git/branch.rb @@ -3,6 +3,14 @@ module Gitlab module Git class Branch < Ref + def self.find(repo, branch_name) + if branch_name.is_a?(Gitlab::Git::Branch) + branch_name + else + repo.find_branch(branch_name) + end + end + def initialize(repository, name, target, target_commit) super(repository, name, target, target_commit) end diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index 1957c254c28..d5518814483 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -72,7 +72,8 @@ module Gitlab decorate(repo, commit) if commit rescue Rugged::ReferenceError, Rugged::InvalidError, Rugged::ObjectError, - Gitlab::Git::CommandError, Gitlab::Git::Repository::NoRepository + Gitlab::Git::CommandError, Gitlab::Git::Repository::NoRepository, + Rugged::OdbError, Rugged::TreeError, ArgumentError nil end diff --git a/lib/gitlab/git/lfs_changes.rb b/lib/gitlab/git/lfs_changes.rb new file mode 100644 index 00000000000..2749e2e69e2 --- /dev/null +++ b/lib/gitlab/git/lfs_changes.rb @@ -0,0 +1,36 @@ +module Gitlab + module Git + class LfsChanges + def initialize(repository, newrev) + @repository = repository + @newrev = newrev + end + + def new_pointers(object_limit: nil, not_in: nil) + @new_pointers ||= begin + object_ids = new_objects(not_in: not_in) + object_ids = object_ids.take(object_limit) if object_limit + + Gitlab::Git::Blob.batch_lfs_pointers(@repository, object_ids) + end + end + + def all_pointers + object_ids = rev_list.all_objects(require_path: true) + + Gitlab::Git::Blob.batch_lfs_pointers(@repository, object_ids) + end + + private + + def new_objects(not_in:) + rev_list.new_objects(require_path: true, lazy: true, not_in: not_in) + end + + def rev_list + ::Gitlab::Git::RevList.new(path_to_repo: @repository.path_to_repo, + newrev: @newrev) + end + end + end +end diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 408616d174b..182ffc96ef9 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -6,6 +6,7 @@ require "rubygems/package" module Gitlab module Git class Repository + include Gitlab::Git::RepositoryMirroring include Gitlab::Git::Popen ALLOWED_OBJECT_DIRECTORIES_VARIABLES = %w[ @@ -290,6 +291,14 @@ module Gitlab end end + def batch_existence(object_ids, existing: true) + filter_method = existing ? :select : :reject + + object_ids.public_send(filter_method) do |oid| # rubocop:disable GitlabSecurity/PublicSend + rugged.exists?(oid) + end + end + # Returns an Array of branch and tag names def ref_names branch_names + tag_names @@ -511,6 +520,10 @@ module Gitlab gitaly_commit_client.ancestor?(from, to) end + def merged_branch_names(branch_names = []) + Set.new(git_merged_branch_names(branch_names)) + end + # Return an array of Diff objects that represent the diff # between +from+ and +to+. See Diff::filter_diff_options for the allowed # diff options. The +options+ hash can also include :break_rewrites to @@ -746,13 +759,13 @@ module Gitlab end def ff_merge(user, source_sha, target_branch) - OperationService.new(user, self).with_branch(target_branch) do |our_commit| - raise ArgumentError, 'Invalid merge target' unless our_commit - - source_sha + gitaly_migrate(:operation_user_ff_branch) do |is_enabled| + if is_enabled + gitaly_ff_merge(user, source_sha, target_branch) + else + rugged_ff_merge(user, source_sha, target_branch) + end end - rescue Rugged::ReferenceError - raise ArgumentError, 'Invalid merge source' end def revert(user:, commit:, branch_name:, message:, start_branch_name:, start_repository:) @@ -886,16 +899,25 @@ module Gitlab end end - # Delete the specified remote from this repository. - def remote_delete(remote_name) - rugged.remotes.delete(remote_name) - nil + def add_remote(remote_name, url) + rugged.remotes.create(remote_name, url) + rescue Rugged::ConfigError + remote_update(remote_name, url: url) end - # Add a new remote to this repository. - def remote_add(remote_name, url) - rugged.remotes.create(remote_name, url) - nil + def remove_remote(remote_name) + # When a remote is deleted all its remote refs are deleted too, but in + # the case of mirrors we map its refs (that would usualy go under + # [remote_name]/) to the top level namespace. We clean the mapping so + # those don't get deleted. + if rugged.config["remote.#{remote_name}.mirror"] + rugged.config.delete("remote.#{remote_name}.fetch") + end + + rugged.remotes.delete(remote_name) + true + rescue Rugged::ConfigError + false end # Update the specified remote using the values in the +options+ hash @@ -1165,10 +1187,10 @@ module Gitlab Gitlab::GitalyClient.migrate(method, status: status, &block) rescue GRPC::NotFound => e raise NoRepository.new(e) - rescue GRPC::BadStatus => e - raise CommandError.new(e) rescue GRPC::InvalidArgument => e raise ArgumentError.new(e) + rescue GRPC::BadStatus => e + raise CommandError.new(e) end private @@ -1190,6 +1212,13 @@ module Gitlab sort_branches(branches, sort_by) end + def git_merged_branch_names(branch_names = []) + lines = run_git(['branch', '--merged', root_ref] + branch_names) + .first.lines + + lines.map(&:strip) + end + def log_using_shell?(options) options[:path].present? || options[:disable_walk] || @@ -1603,6 +1632,22 @@ module Gitlab run_git(args, env: env) end + + def gitaly_ff_merge(user, source_sha, target_branch) + gitaly_operations_client.user_ff_branch(user, source_sha, target_branch) + rescue GRPC::FailedPrecondition => e + raise CommitError, e + end + + def rugged_ff_merge(user, source_sha, target_branch) + OperationService.new(user, self).with_branch(target_branch) do |our_commit| + raise ArgumentError, 'Invalid merge target' unless our_commit + + source_sha + end + rescue Rugged::ReferenceError + raise ArgumentError, 'Invalid merge source' + end end end end diff --git a/lib/gitlab/git/repository_mirroring.rb b/lib/gitlab/git/repository_mirroring.rb new file mode 100644 index 00000000000..637e7a0659c --- /dev/null +++ b/lib/gitlab/git/repository_mirroring.rb @@ -0,0 +1,95 @@ +module Gitlab + module Git + module RepositoryMirroring + IMPORT_HEAD_REFS = '+refs/heads/*:refs/heads/*'.freeze + IMPORT_TAG_REFS = '+refs/tags/*:refs/tags/*'.freeze + MIRROR_REMOTE = 'mirror'.freeze + + RemoteError = Class.new(StandardError) + + def set_remote_as_mirror(remote_name) + # This is used to define repository as equivalent as "git clone --mirror" + rugged.config["remote.#{remote_name}.fetch"] = 'refs/*:refs/*' + rugged.config["remote.#{remote_name}.mirror"] = true + rugged.config["remote.#{remote_name}.prune"] = true + end + + def set_import_remote_as_mirror(remote_name) + # Add first fetch with Rugged so it does not create its own. + rugged.config["remote.#{remote_name}.fetch"] = IMPORT_HEAD_REFS + + add_remote_fetch_config(remote_name, IMPORT_TAG_REFS) + + rugged.config["remote.#{remote_name}.mirror"] = true + rugged.config["remote.#{remote_name}.prune"] = true + end + + def add_remote_fetch_config(remote_name, refspec) + run_git(%W[config --add remote.#{remote_name}.fetch #{refspec}]) + end + + def fetch_mirror(url) + add_remote(MIRROR_REMOTE, url) + set_remote_as_mirror(MIRROR_REMOTE) + fetch(MIRROR_REMOTE) + remove_remote(MIRROR_REMOTE) + end + + def remote_tags(remote) + # Each line has this format: "dc872e9fa6963f8f03da6c8f6f264d0845d6b092\trefs/tags/v1.10.0\n" + # We want to convert it to: [{ 'v1.10.0' => 'dc872e9fa6963f8f03da6c8f6f264d0845d6b092' }, ...] + list_remote_tags(remote).map do |line| + target, path = line.strip.split("\t") + + # When the remote repo does not have tags. + if target.nil? || path.nil? + Rails.logger.info "Empty or invalid list of tags for remote: #{remote}. Output: #{output}" + return [] + end + + name = path.split('/', 3).last + # We're only interested in tag references + # See: http://stackoverflow.com/questions/15472107/when-listing-git-ls-remote-why-theres-after-the-tag-name + next if name =~ /\^\{\}\Z/ + + target_commit = Gitlab::Git::Commit.find(self, target) + Gitlab::Git::Tag.new(self, name, target, target_commit) + end.compact + end + + def remote_branches(remote_name) + branches = [] + + rugged.references.each("refs/remotes/#{remote_name}/*").map do |ref| + name = ref.name.sub(/\Arefs\/remotes\/#{remote_name}\//, '') + + begin + target_commit = Gitlab::Git::Commit.find(self, ref.target) + branches << Gitlab::Git::Branch.new(self, name, ref.target, target_commit) + rescue Rugged::ReferenceError + # Omit invalid branch + end + end + + branches + end + + private + + def list_remote_tags(remote) + tag_list, exit_code, error = nil + cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{full_path} ls-remote --tags #{remote}) + + Open3.popen3(*cmd) do |stdin, stdout, stderr, wait_thr| + tag_list = stdout.read + error = stderr.read + exit_code = wait_thr.value.exitstatus + end + + raise RemoteError, error unless exit_code.zero? + + tag_list.split('\n') + end + end + end +end diff --git a/lib/gitlab/git/rev_list.rb b/lib/gitlab/git/rev_list.rb index 60b2a4ec411..e0c884aceaa 100644 --- a/lib/gitlab/git/rev_list.rb +++ b/lib/gitlab/git/rev_list.rb @@ -13,11 +13,31 @@ module Gitlab @path_to_repo = path_to_repo end - # This method returns an array of new references + # This method returns an array of new commit references def new_refs execute([*base_args, newrev, '--not', '--all']) end + # Finds newly added objects + # Returns an array of shas + # + # Can skip objects which do not have a path using required_path: true + # This skips commit objects and root trees, which might not be needed when + # looking for blobs + # + # Can return a lazy enumerator to limit work done on megabytes of data + def new_objects(require_path: nil, lazy: false, not_in: nil) + object_output = execute([*base_args, newrev, *not_in_refs(not_in), '--objects']) + + objects_from_output(object_output, require_path: require_path, lazy: lazy) + end + + def all_objects(require_path: nil) + object_output = execute([*base_args, '--all', '--objects']) + + objects_from_output(object_output, require_path: require_path, lazy: true) + end + # This methods returns an array of missed references # # Should become obsolete after https://gitlab.com/gitlab-org/gitaly/issues/348. @@ -27,6 +47,13 @@ module Gitlab private + def not_in_refs(references) + return ['--not', '--all'] unless references + return [] if references.empty? + + references.prepend('--not') + end + def execute(args) output, status = popen(args, nil, Gitlab::Git::Env.to_env_hash) @@ -44,6 +71,22 @@ module Gitlab 'rev-list' ] end + + def objects_from_output(object_output, require_path: nil, lazy: nil) + objects = object_output.lazy.map do |output_line| + sha, path = output_line.split(' ', 2) + + next if require_path && path.blank? + + sha + end.reject(&:nil?) + + if lazy + objects + else + objects.force + end + end end end end diff --git a/lib/gitlab/git/wiki.rb b/lib/gitlab/git/wiki.rb index e7b2f52a552..fe901d049d4 100644 --- a/lib/gitlab/git/wiki.rb +++ b/lib/gitlab/git/wiki.rb @@ -8,6 +8,9 @@ module Gitlab { name: name, email: email, message: message } end end + PageBlob = Struct.new(:name) + + attr_reader :repository def self.default_ref 'master' @@ -34,10 +37,14 @@ module Gitlab end def delete_page(page_path, commit_details) - assert_type!(commit_details, CommitDetails) - - gollum_wiki.delete_page(gollum_page_by_path(page_path), commit_details.to_h) - nil + @repository.gitaly_migrate(:wiki_delete_page) do |is_enabled| + if is_enabled + gitaly_delete_page(page_path, commit_details) + gollum_wiki.clear_cache + else + gollum_delete_page(page_path, commit_details) + end + end end def update_page(page_path, title, format, content, commit_details) @@ -53,22 +60,23 @@ module Gitlab end def page(title:, version: nil, dir: nil) - if version - version = Gitlab::Git::Commit.find(@repository, version).id + @repository.gitaly_migrate(:wiki_find_page) do |is_enabled| + if is_enabled + gitaly_find_page(title: title, version: version, dir: dir) + else + gollum_find_page(title: title, version: version, dir: dir) + end end - - gollum_page = gollum_wiki.page(title, version, dir) - return unless gollum_page - - new_page(gollum_page) end def file(name, version) - version ||= self.class.default_ref - gollum_file = gollum_wiki.file(name, version) - return unless gollum_file - - Gitlab::Git::WikiFile.new(gollum_file) + @repository.gitaly_migrate(:wiki_find_file) do |is_enabled| + if is_enabled + gitaly_find_file(name, version) + else + gollum_find_file(name, version) + end + end end def page_versions(page_path) @@ -80,7 +88,15 @@ module Gitlab end def preview_slug(title, format) - gollum_wiki.preview_page(title, '', format).url_path + # Adapted from gollum gem (Gollum::Wiki#preview_page) to avoid + # using Rugged through a Gollum::Wiki instance + page_class = Gollum::Page + page = page_class.new(nil) + ext = page_class.format_to_ext(format.to_sym) + name = page_class.cname(title) + '.' + ext + blob = PageBlob.new(name) + page.populate(blob) + page.url_path end private @@ -126,9 +142,53 @@ module Gitlab raise Gitlab::Git::Wiki::DuplicatePageError, e.message end + def gollum_delete_page(page_path, commit_details) + assert_type!(commit_details, CommitDetails) + + gollum_wiki.delete_page(gollum_page_by_path(page_path), commit_details.to_h) + nil + end + + def gollum_find_page(title:, version: nil, dir: nil) + if version + version = Gitlab::Git::Commit.find(@repository, version).id + end + + gollum_page = gollum_wiki.page(title, version, dir) + return unless gollum_page + + new_page(gollum_page) + end + + def gollum_find_file(name, version) + version ||= self.class.default_ref + gollum_file = gollum_wiki.file(name, version) + return unless gollum_file + + Gitlab::Git::WikiFile.new(gollum_file) + end + def gitaly_write_page(name, format, content, commit_details) gitaly_wiki_client.write_page(name, format, content, commit_details) end + + def gitaly_delete_page(page_path, commit_details) + gitaly_wiki_client.delete_page(page_path, commit_details) + end + + def gitaly_find_page(title:, version: nil, dir: nil) + wiki_page, version = gitaly_wiki_client.find_page(title: title, version: version, dir: dir) + return unless wiki_page + + Gitlab::Git::WikiPage.new(wiki_page, version) + end + + def gitaly_find_file(name, version) + wiki_file = gitaly_wiki_client.find_file(name, version) + return unless wiki_file + + Gitlab::Git::WikiFile.new(wiki_file) + end end end end diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb index 6868be26758..0b35a787e07 100644 --- a/lib/gitlab/gitaly_client.rb +++ b/lib/gitlab/gitaly_client.rb @@ -34,10 +34,11 @@ module Gitlab private_constant :MUTEX class << self - attr_accessor :query_time + attr_accessor :query_time, :migrate_histogram end self.query_time = 0 + self.migrate_histogram = Gitlab::Metrics.histogram(:gitaly_migrate_call_duration, "Gitaly migration call execution timings") def self.stub(name, storage) MUTEX.synchronize do @@ -171,8 +172,11 @@ module Gitlab feature_stack = Thread.current[:gitaly_feature_stack] ||= [] feature_stack.unshift(feature) begin + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) yield is_enabled ensure + total_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + migrate_histogram.observe({ gitaly_enabled: is_enabled, feature: feature }, total_time) feature_stack.shift Thread.current[:gitaly_feature_stack] = nil if feature_stack.empty? end diff --git a/lib/gitlab/gitaly_client/commit_service.rb b/lib/gitlab/gitaly_client/commit_service.rb index a2b50f2507e..da5505cb2fe 100644 --- a/lib/gitlab/gitaly_client/commit_service.rb +++ b/lib/gitlab/gitaly_client/commit_service.rb @@ -18,7 +18,7 @@ module Gitlab response = GitalyClient.call(@repository.storage, :commit_service, :list_files, request) response.flat_map do |msg| - msg.paths.map { |d| d.dup.force_encoding(Encoding::UTF_8) } + msg.paths.map { |d| EncodingHelper.encode!(d.dup) } end end diff --git a/lib/gitlab/gitaly_client/operation_service.rb b/lib/gitlab/gitaly_client/operation_service.rb index adaf255f24b..526d44a8b77 100644 --- a/lib/gitlab/gitaly_client/operation_service.rb +++ b/lib/gitlab/gitaly_client/operation_service.rb @@ -105,6 +105,23 @@ module Gitlab ensure request_enum.close end + + def user_ff_branch(user, source_sha, target_branch) + request = Gitaly::UserFFBranchRequest.new( + repository: @gitaly_repo, + user: Gitlab::Git::User.from_gitlab(user).to_gitaly, + commit_id: source_sha, + branch: GitalyClient.encode(target_branch) + ) + + branch_update = GitalyClient.call( + @repository.storage, + :operation_service, + :user_ff_branch, + request + ).branch_update + Gitlab::Git::OperationService::BranchUpdate.from_gitaly(branch_update) + end end end end diff --git a/lib/gitlab/gitaly_client/wiki_file.rb b/lib/gitlab/gitaly_client/wiki_file.rb new file mode 100644 index 00000000000..a2e415864e6 --- /dev/null +++ b/lib/gitlab/gitaly_client/wiki_file.rb @@ -0,0 +1,17 @@ +module Gitlab + module GitalyClient + class WikiFile + FIELDS = %i(name mime_type path raw_data).freeze + + attr_accessor(*FIELDS) + + def initialize(params) + params = params.with_indifferent_access + + FIELDS.each do |field| + instance_variable_set("@#{field}", params[field]) + end + end + end + end +end diff --git a/lib/gitlab/gitaly_client/wiki_page.rb b/lib/gitlab/gitaly_client/wiki_page.rb new file mode 100644 index 00000000000..8226278d5f6 --- /dev/null +++ b/lib/gitlab/gitaly_client/wiki_page.rb @@ -0,0 +1,25 @@ +module Gitlab + module GitalyClient + class WikiPage + FIELDS = %i(title format url_path path name historical raw_data).freeze + + attr_accessor(*FIELDS) + + def initialize(params) + params = params.with_indifferent_access + + FIELDS.each do |field| + instance_variable_set("@#{field}", params[field]) + end + end + + def historical? + @historical + end + + def format + @format.to_sym + end + end + end +end diff --git a/lib/gitlab/gitaly_client/wiki_service.rb b/lib/gitlab/gitaly_client/wiki_service.rb index 03afcce81f0..15f0f30d303 100644 --- a/lib/gitlab/gitaly_client/wiki_service.rb +++ b/lib/gitlab/gitaly_client/wiki_service.rb @@ -15,11 +15,7 @@ module Gitlab repository: @gitaly_repo, name: GitalyClient.encode(name), format: format.to_s, - commit_details: Gitaly::WikiCommitDetails.new( - name: GitalyClient.encode(commit_details.name), - email: GitalyClient.encode(commit_details.email), - message: GitalyClient.encode(commit_details.message) - ) + commit_details: gitaly_commit_details(commit_details) ) strio = StringIO.new(content) @@ -40,6 +36,85 @@ module Gitlab raise Gitlab::Git::Wiki::DuplicatePageError, error end end + + def delete_page(page_path, commit_details) + request = Gitaly::WikiDeletePageRequest.new( + repository: @gitaly_repo, + page_path: GitalyClient.encode(page_path), + commit_details: gitaly_commit_details(commit_details) + ) + + GitalyClient.call(@repository.storage, :wiki_service, :wiki_delete_page, request) + end + + def find_page(title:, version: nil, dir: nil) + request = Gitaly::WikiFindPageRequest.new( + repository: @gitaly_repo, + title: GitalyClient.encode(title), + revision: GitalyClient.encode(version), + directory: GitalyClient.encode(dir) + ) + + response = GitalyClient.call(@repository.storage, :wiki_service, :wiki_find_page, request) + wiki_page = version = nil + + response.each do |message| + page = message.page + next unless page + + if wiki_page + wiki_page.raw_data << page.raw_data + else + wiki_page = GitalyClient::WikiPage.new(page.to_h) + # All gRPC strings in a response are frozen, so we get + # an unfrozen version here so appending in the else clause below doesn't blow up. + wiki_page.raw_data = wiki_page.raw_data.dup + + version = Gitlab::Git::WikiPageVersion.new( + Gitlab::Git::Commit.decorate(@repository, page.version.commit), + page.version.format + ) + end + end + + [wiki_page, version] + end + + def find_file(name, revision) + request = Gitaly::WikiFindFileRequest.new( + repository: @gitaly_repo, + name: GitalyClient.encode(name), + revision: GitalyClient.encode(revision) + ) + + response = GitalyClient.call(@repository.storage, :wiki_service, :wiki_find_file, request) + wiki_file = nil + + response.each do |message| + next unless message.name.present? + + if wiki_file + wiki_file.raw_data << message.raw_data + else + wiki_file = GitalyClient::WikiFile.new(message.to_h) + # All gRPC strings in a response are frozen, so we get + # an unfrozen version here so appending in the else clause below doesn't blow up. + wiki_file.raw_data = wiki_file.raw_data.dup + end + end + + wiki_file + end + + private + + def gitaly_commit_details(commit_details) + Gitaly::WikiCommitDetails.new( + name: GitalyClient.encode(commit_details.name), + email: GitalyClient.encode(commit_details.email), + message: GitalyClient.encode(commit_details.message) + ) + end end end end diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index dec8b4c5acd..561779182bc 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -19,6 +19,7 @@ project_tree: - milestone: - events: - :push_event_payload + - :issue_assignees - snippets: - :award_emoji - notes: @@ -113,6 +114,7 @@ excluded_attributes: - :milestone_id - :ref_fetched - :merge_jid + - :latest_merge_request_diff_id award_emoji: - :awardable_id statuses: diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index 469b230377d..a790dcfe8a6 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -8,8 +8,8 @@ module Gitlab triggers: 'Ci::Trigger', pipeline_schedules: 'Ci::PipelineSchedule', builds: 'Ci::Build', - cluster: 'Gcp::Cluster', - clusters: 'Gcp::Cluster', + cluster: 'Clusters::Cluster', + clusters: 'Clusters::Cluster', hooks: 'ProjectHook', merge_access_levels: 'ProtectedBranch::MergeAccessLevel', push_access_levels: 'ProtectedBranch::PushAccessLevel', diff --git a/lib/gitlab/ldap/auth_hash.rb b/lib/gitlab/ldap/auth_hash.rb index 3123da17fd9..1bd0965679a 100644 --- a/lib/gitlab/ldap/auth_hash.rb +++ b/lib/gitlab/ldap/auth_hash.rb @@ -4,7 +4,7 @@ module Gitlab module LDAP class AuthHash < Gitlab::OAuth::AuthHash def uid - Gitlab::LDAP::Person.normalize_dn(super) + @uid ||= Gitlab::LDAP::Person.normalize_dn(super) end private diff --git a/lib/gitlab/ldap/user.rb b/lib/gitlab/ldap/user.rb index 1793097363e..4d5c67ed892 100644 --- a/lib/gitlab/ldap/user.rb +++ b/lib/gitlab/ldap/user.rb @@ -9,10 +9,11 @@ module Gitlab class User < Gitlab::OAuth::User class << self def find_by_uid_and_provider(uid, provider) - # LDAP distinguished name is case-insensitive + uid = Gitlab::LDAP::Person.normalize_dn(uid) + identity = ::Identity .where(provider: provider) - .iwhere(extern_uid: uid).last + .where(extern_uid: uid).last identity && identity.user end end diff --git a/lib/gitlab/metrics/sidekiq_middleware.rb b/lib/gitlab/metrics/sidekiq_middleware.rb index f9dd8e41912..b983a40611f 100644 --- a/lib/gitlab/metrics/sidekiq_middleware.rb +++ b/lib/gitlab/metrics/sidekiq_middleware.rb @@ -11,6 +11,8 @@ module Gitlab # Old gitlad-shell messages don't provide enqueued_at/created_at attributes trans.set(:sidekiq_queue_duration, Time.now.to_f - (message['enqueued_at'] || message['created_at'] || 0)) trans.run { yield } + + worker.metrics_tags.each { |tag, value| trans.add_tag(tag, value) } if worker.respond_to?(:metrics_tags) rescue Exception => error # rubocop: disable Lint/RescueException trans.add_event(:sidekiq_exception) diff --git a/lib/gitlab/metrics/system.rb b/lib/gitlab/metrics/system.rb index aba3e0df382..c2cbd3c16a1 100644 --- a/lib/gitlab/metrics/system.rb +++ b/lib/gitlab/metrics/system.rb @@ -46,14 +46,14 @@ module Gitlab # Returns the current real time in a given precision. # - # Returns the time as a Float. + # Returns the time as a Fixnum. def self.real_time(precision = :millisecond) Process.clock_gettime(Process::CLOCK_REALTIME, precision) end # Returns the current monotonic clock time in a given precision. # - # Returns the time as a Float. + # Returns the time as a Fixnum. def self.monotonic_time(precision = :millisecond) Process.clock_gettime(Process::CLOCK_MONOTONIC, precision) end diff --git a/lib/gitlab/middleware/go.rb b/lib/gitlab/middleware/go.rb index f42168c720e..cfc6b2a2029 100644 --- a/lib/gitlab/middleware/go.rb +++ b/lib/gitlab/middleware/go.rb @@ -4,6 +4,7 @@ module Gitlab module Middleware class Go include ActionView::Helpers::TagHelper + include Gitlab::CurrentSettings PROJECT_PATH_REGEX = %r{\A(#{Gitlab::PathRegex.full_namespace_route_regex}/#{Gitlab::PathRegex.project_route_regex})/}.freeze @@ -37,10 +38,20 @@ module Gitlab end def go_body(path) - project_url = URI.join(Gitlab.config.gitlab.url, path) + config = Gitlab.config + project_url = URI.join(config.gitlab.url, path) import_prefix = strip_url(project_url.to_s) - meta_tag = tag :meta, name: 'go-import', content: "#{import_prefix} git #{project_url}.git" + repository_url = case current_application_settings.enabled_git_access_protocol + when 'ssh' + shell = config.gitlab_shell + port = ":#{shell.ssh_port}" unless shell.ssh_port == 22 + "ssh://#{shell.ssh_user}@#{shell.ssh_host}#{port}/#{path}.git" + when 'http', nil + "#{project_url}.git" + end + + meta_tag = tag :meta, name: 'go-import', content: "#{import_prefix} git #{repository_url}" head_tag = content_tag :head, meta_tag content_tag :html, head_tag end diff --git a/lib/gitlab/middleware/read_only.rb b/lib/gitlab/middleware/read_only.rb index 0de0cddcce4..8853dfa3d2d 100644 --- a/lib/gitlab/middleware/read_only.rb +++ b/lib/gitlab/middleware/read_only.rb @@ -12,6 +12,7 @@ module Gitlab def call(env) @env = env + @route_hash = nil if disallowed_request? && Gitlab::Database.read_only? Rails.logger.debug('GitLab ReadOnly: preventing possible non read-only operation') @@ -77,11 +78,11 @@ module Gitlab end def grack_route - request.path.end_with?('.git/git-upload-pack') + route_hash[:controller] == 'projects/git_http' && route_hash[:action] == 'git_upload_pack' end def lfs_route - request.path.end_with?('/info/lfs/objects/batch') + route_hash[:controller] == 'projects/lfs_api' && route_hash[:action] == 'batch' end end end diff --git a/lib/gitlab/performance_bar/peek_query_tracker.rb b/lib/gitlab/performance_bar/peek_query_tracker.rb index 69e117f1da9..f2825db59ae 100644 --- a/lib/gitlab/performance_bar/peek_query_tracker.rb +++ b/lib/gitlab/performance_bar/peek_query_tracker.rb @@ -36,7 +36,7 @@ module Gitlab end def track_query(raw_query, bindings, start, finish) - duration = finish - start + duration = (finish - start) * 1000.0 query_info = { duration: duration.round(3), sql: raw_query } PEEK_DB_CLIENT.query_details << query_info diff --git a/lib/gitlab/sherlock/transaction.rb b/lib/gitlab/sherlock/transaction.rb index 3489fb251b6..400a552bf99 100644 --- a/lib/gitlab/sherlock/transaction.rb +++ b/lib/gitlab/sherlock/transaction.rb @@ -89,7 +89,9 @@ module Gitlab ActiveSupport::Notifications.subscribe('sql.active_record') do |_, start, finish, _, data| next unless same_thread? - track_query(data[:sql].strip, data[:binds], start, finish) + unless data.fetch(:cached, data[:name] == 'CACHE') + track_query(data[:sql].strip, data[:binds], start, finish) + end end end diff --git a/lib/gitlab/sidekiq_middleware/memory_killer.rb b/lib/gitlab/sidekiq_middleware/memory_killer.rb index d7d24eeb37b..2bfb7caefd9 100644 --- a/lib/gitlab/sidekiq_middleware/memory_killer.rb +++ b/lib/gitlab/sidekiq_middleware/memory_killer.rb @@ -7,7 +7,6 @@ module Gitlab GRACE_TIME = (ENV['SIDEKIQ_MEMORY_KILLER_GRACE_TIME'] || 15 * 60).to_s.to_i # Wait 30 seconds for running jobs to finish during graceful shutdown SHUTDOWN_WAIT = (ENV['SIDEKIQ_MEMORY_KILLER_SHUTDOWN_WAIT'] || 30).to_s.to_i - SHUTDOWN_SIGNAL = (ENV['SIDEKIQ_MEMORY_KILLER_SHUTDOWN_SIGNAL'] || 'SIGKILL').to_s # Create a mutex used to ensure there will be only one thread waiting to # shut Sidekiq down @@ -15,6 +14,7 @@ module Gitlab def call(worker, job, queue) yield + current_rss = get_rss return unless MAX_RSS > 0 && current_rss > MAX_RSS @@ -23,32 +23,45 @@ module Gitlab # Return if another thread is already waiting to shut Sidekiq down return unless MUTEX.try_lock - Sidekiq.logger.warn "current RSS #{current_rss} exceeds maximum RSS "\ - "#{MAX_RSS}" - Sidekiq.logger.warn "this thread will shut down PID #{Process.pid} - Worker #{worker.class} - JID-#{job['jid']} "\ - "in #{GRACE_TIME} seconds" - sleep(GRACE_TIME) + Sidekiq.logger.warn "Sidekiq worker PID-#{pid} current RSS #{current_rss}"\ + " exceeds maximum RSS #{MAX_RSS} after finishing job #{worker.class} JID-#{job['jid']}" + Sidekiq.logger.warn "Sidekiq worker PID-#{pid} will stop fetching new jobs in #{GRACE_TIME} seconds, and will be shut down #{SHUTDOWN_WAIT} seconds later" - Sidekiq.logger.warn "sending SIGTERM to PID #{Process.pid} - Worker #{worker.class} - JID-#{job['jid']}" - Process.kill('SIGTERM', Process.pid) + # Wait `GRACE_TIME` to give the memory intensive job time to finish. + # Then, tell Sidekiq to stop fetching new jobs. + wait_and_signal(GRACE_TIME, 'SIGSTP', 'stop fetching new jobs') - Sidekiq.logger.warn "waiting #{SHUTDOWN_WAIT} seconds before sending "\ - "#{SHUTDOWN_SIGNAL} to PID #{Process.pid} - Worker #{worker.class} - JID-#{job['jid']}" - sleep(SHUTDOWN_WAIT) + # Wait `SHUTDOWN_WAIT` to give already fetched jobs time to finish. + # Then, tell Sidekiq to gracefully shut down by giving jobs a few more + # moments to finish, killing and requeuing them if they didn't, and + # then terminating itself. + wait_and_signal(SHUTDOWN_WAIT, 'SIGTERM', 'gracefully shut down') - Sidekiq.logger.warn "sending #{SHUTDOWN_SIGNAL} to PID #{Process.pid} - Worker #{worker.class} - JID-#{job['jid']}" - Process.kill(SHUTDOWN_SIGNAL, Process.pid) + # Wait for Sidekiq to shutdown gracefully, and kill it if it didn't. + wait_and_signal(Sidekiq.options[:timeout] + 2, 'SIGKILL', 'die') end end private def get_rss - output, status = Gitlab::Popen.popen(%W(ps -o rss= -p #{Process.pid})) + output, status = Gitlab::Popen.popen(%W(ps -o rss= -p #{pid})) return 0 unless status.zero? output.to_i end + + def wait_and_signal(time, signal, explanation) + Sidekiq.logger.warn "waiting #{time} seconds before sending Sidekiq worker PID-#{pid} #{signal} (#{explanation})" + sleep(time) + + Sidekiq.logger.warn "sending Sidekiq worker PID-#{pid} #{signal} (#{explanation})" + Process.kill(signal, pid) + end + + def pid + Process.pid + end end end end diff --git a/lib/gitlab/testing/request_blocker_middleware.rb b/lib/gitlab/testing/request_blocker_middleware.rb index aa67fa08577..4a8e3c2eee0 100644 --- a/lib/gitlab/testing/request_blocker_middleware.rb +++ b/lib/gitlab/testing/request_blocker_middleware.rb @@ -7,6 +7,7 @@ module Gitlab class RequestBlockerMiddleware @@num_active_requests = Concurrent::AtomicFixnum.new(0) @@block_requests = Concurrent::AtomicBoolean.new(false) + @@slow_requests = Concurrent::AtomicBoolean.new(false) # Returns the number of requests the server is currently processing. def self.num_active_requests @@ -19,9 +20,15 @@ module Gitlab @@block_requests.value = true end + # Slows down incoming requests (useful for race conditions). + def self.slow_requests! + @@slow_requests.value = true + end + # Allows the server to accept requests again. def self.allow_requests! @@block_requests.value = false + @@slow_requests.value = false end def initialize(app) @@ -33,6 +40,7 @@ module Gitlab if block_requests? block_request(env) else + sleep 0.2 if slow_requests? @app.call(env) end ensure @@ -45,6 +53,10 @@ module Gitlab @@block_requests.true? end + def slow_requests? + @@slow_requests.true? + end + def block_request(env) [503, {}, []] end diff --git a/lib/gitlab/testing/request_inspector_middleware.rb b/lib/gitlab/testing/request_inspector_middleware.rb new file mode 100644 index 00000000000..e387667480d --- /dev/null +++ b/lib/gitlab/testing/request_inspector_middleware.rb @@ -0,0 +1,71 @@ +# rubocop:disable Style/ClassVars + +module Gitlab + module Testing + class RequestInspectorMiddleware + @@log_requests = Concurrent::AtomicBoolean.new(false) + @@logged_requests = Concurrent::Array.new + @@inject_headers = Concurrent::Hash.new + + # Resets the current request log and starts logging requests + def self.log_requests!(headers = {}) + @@inject_headers.replace(headers) + @@logged_requests.replace([]) + @@log_requests.value = true + end + + # Stops logging requests + def self.stop_logging! + @@log_requests.value = false + end + + def self.requests + @@logged_requests + end + + def initialize(app) + @app = app + end + + def call(env) + return @app.call(env) unless @@log_requests.true? + + url = env['REQUEST_URI'] + env.merge! http_headers_env(@@inject_headers) if @@inject_headers.any? + request_headers = env_http_headers(env) + status, headers, body = @app.call(env) + + request = OpenStruct.new( + url: url, + status_code: status, + request_headers: request_headers, + response_headers: headers + ) + log_request request + + [status, headers, body] + end + + private + + def env_http_headers(env) + Hash[*env.select { |k, v| k.start_with? 'HTTP_' } + .collect { |k, v| [k.sub(/^HTTP_/, ''), v] } + .collect { |k, v| [k.split('_').collect(&:capitalize).join('-'), v] } + .sort + .flatten] + end + + def http_headers_env(headers) + Hash[*headers + .collect { |k, v| [k.split('-').collect(&:upcase).join('_'), v] } + .collect { |k, v| [k.prepend('HTTP_'), v] } + .flatten] + end + + def log_request(response) + @@logged_requests.push(response) + end + end + end +end diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index 70a403652e7..112d4939582 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -48,9 +48,9 @@ module Gitlab deploy_keys: DeployKey.count, deployments: Deployment.count, environments: ::Environment.count, - gcp_clusters: ::Gcp::Cluster.count, - gcp_clusters_enabled: ::Gcp::Cluster.enabled.count, - gcp_clusters_disabled: ::Gcp::Cluster.disabled.count, + clusters: ::Clusters::Cluster.count, + clusters_enabled: ::Clusters::Cluster.enabled.count, + clusters_disabled: ::Clusters::Cluster.disabled.count, in_review_folder: ::Environment.in_review_folder.count, groups: Group.count, issues: Issue.count, diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index 58d5b0da1c4..e1219df1b25 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -16,14 +16,15 @@ module Gitlab SECRET_LENGTH = 32 class << self - def git_http_ok(repository, is_wiki, user, action) + def git_http_ok(repository, is_wiki, user, action, show_all_refs: false) project = repository.project repo_path = repository.path_to_repo params = { GL_ID: Gitlab::GlId.gl_id(user), GL_REPOSITORY: Gitlab::GlRepository.gl_repository(project, is_wiki), GL_USERNAME: user&.username, - RepoPath: repo_path + RepoPath: repo_path, + ShowAllRefs: show_all_refs } server = { address: Gitlab::GitalyClient.address(project.repository_storage), diff --git a/lib/google_api/cloud_platform/client.rb b/lib/google_api/cloud_platform/client.rb index a440a3e3562..9242cbe840c 100644 --- a/lib/google_api/cloud_platform/client.rb +++ b/lib/google_api/cloud_platform/client.rb @@ -3,7 +3,6 @@ require 'google/apis/container_v1' module GoogleApi module CloudPlatform class Client < GoogleApi::Auth - DEFAULT_MACHINE_TYPE = 'n1-standard-1'.freeze SCOPE = 'https://www.googleapis.com/auth/cloud-platform'.freeze LEAST_TOKEN_LIFE_TIME = 10.minutes diff --git a/lib/system_check/app/git_user_default_ssh_config_check.rb b/lib/system_check/app/git_user_default_ssh_config_check.rb index 9af21078403..ad41760dff2 100644 --- a/lib/system_check/app/git_user_default_ssh_config_check.rb +++ b/lib/system_check/app/git_user_default_ssh_config_check.rb @@ -11,10 +11,10 @@ module SystemCheck ].freeze set_name 'Git user has default SSH configuration?' - set_skip_reason 'skipped (GitLab read-only, or git user is not present / configured)' + set_skip_reason 'skipped (git user is not present / configured)' def skip? - Gitlab::Database.read_only? || !home_dir || !File.directory?(home_dir) + !home_dir || !File.directory?(home_dir) end def check? diff --git a/lib/system_check/app/ruby_version_check.rb b/lib/system_check/app/ruby_version_check.rb index 08a2c495bd4..57bbabece1f 100644 --- a/lib/system_check/app/ruby_version_check.rb +++ b/lib/system_check/app/ruby_version_check.rb @@ -5,7 +5,7 @@ module SystemCheck set_check_pass -> { "yes (#{self.current_version})" } def self.required_version - @required_version ||= Gitlab::VersionInfo.new(2, 3, 3) + @required_version ||= Gitlab::VersionInfo.new(2, 3, 5) end def self.current_version diff --git a/lib/tasks/gitlab/dev.rake b/lib/tasks/gitlab/dev.rake index 930b4bc13e2..ba221e44e5d 100644 --- a/lib/tasks/gitlab/dev.rake +++ b/lib/tasks/gitlab/dev.rake @@ -5,10 +5,9 @@ namespace :gitlab do opts = if ENV['CI'] { - # We don't use CI_REPOSITORY_URL since it includes `gitlab-ci-token:xxxxxxxxxxxxxxxxxxxx@` - # which is confusing in the steps suggested in the job's output. - ce_repo: "#{ENV['CI_PROJECT_URL']}.git", - branch: ENV['CI_COMMIT_REF_NAME'] + ce_project_url: ENV['CI_PROJECT_URL'], + branch: ENV['CI_COMMIT_REF_NAME'], + job_id: ENV['CI_JOB_ID'] } else unless args[:branch] diff --git a/lib/tasks/gitlab/users.rake b/lib/tasks/gitlab/users.rake deleted file mode 100644 index 3a16ace60bd..00000000000 --- a/lib/tasks/gitlab/users.rake +++ /dev/null @@ -1,11 +0,0 @@ -namespace :gitlab do - namespace :users do - desc "GitLab | Clear the authentication token for all users" - task clear_all_authentication_tokens: :environment do |t, args| - # Do small batched updates because these updates will be slow and locking - User.select(:id).find_in_batches(batch_size: 100) do |batch| - User.where(id: batch.map(&:id)).update_all(authentication_token: nil) - end - end - end -end diff --git a/lib/tasks/tokens.rake b/lib/tasks/tokens.rake index ad1818ff1fa..693597afdf8 100644 --- a/lib/tasks/tokens.rake +++ b/lib/tasks/tokens.rake @@ -1,12 +1,7 @@ require_relative '../../app/models/concerns/token_authenticatable.rb' namespace :tokens do - desc "Reset all GitLab user auth tokens" - task reset_all_auth: :environment do - reset_all_users_token(:reset_authentication_token!) - end - - desc "Reset all GitLab email tokens" + desc "Reset all GitLab incoming email tokens" task reset_all_email: :environment do reset_all_users_token(:reset_incoming_email_token!) end @@ -31,11 +26,6 @@ class TmpUser < ActiveRecord::Base self.table_name = 'users' - def reset_authentication_token! - write_new_token(:authentication_token) - save!(validate: false) - end - def reset_incoming_email_token! write_new_token(:incoming_email_token) save!(validate: false) diff --git a/package.json b/package.json index 057cd8f7bc7..e607981143d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "webpack-prod": "NODE_ENV=production webpack --config config/webpack.config.js" }, "dependencies": { + "autosize": "^4.0.0", "axios": "^0.16.2", "babel-core": "^6.22.1", "babel-eslint": "^7.2.1", @@ -35,6 +36,7 @@ "eslint-plugin-html": "^2.0.1", "exports-loader": "^0.6.4", "file-loader": "^0.11.1", + "fuzzaldrin-plus": "^0.5.0", "imports-loader": "^0.7.1", "jed": "^1.1.1", "jquery": "^2.2.1", @@ -62,10 +64,10 @@ "underscore": "^1.8.3", "url-loader": "^0.5.8", "visibilityjs": "^1.2.4", - "vue": "^2.2.6", + "vue": "^2.5.2", "vue-loader": "^11.3.4", "vue-resource": "^1.3.4", - "vue-template-compiler": "^2.2.6", + "vue-template-compiler": "^2.5.2", "vuex": "^3.0.0", "webpack": "^3.5.5", "webpack-bundle-analyzer": "^2.8.2", @@ -8,6 +8,7 @@ module QA autoload :Release, 'qa/runtime/release' autoload :User, 'qa/runtime/user' autoload :Namespace, 'qa/runtime/namespace' + autoload :Scenario, 'qa/runtime/scenario' end ## @@ -80,6 +81,11 @@ module QA module Admin autoload :Menu, 'qa/page/admin/menu' end + + module Mattermost + autoload :Main, 'qa/page/mattermost/main' + autoload :Login, 'qa/page/mattermost/login' + end end ## diff --git a/qa/qa/page/mattermost/login.rb b/qa/qa/page/mattermost/login.rb new file mode 100644 index 00000000000..2001dc5b230 --- /dev/null +++ b/qa/qa/page/mattermost/login.rb @@ -0,0 +1,19 @@ +module QA + module Page + module Mattermost + class Login < Page::Base + def initialize + visit(Runtime::Scenario.mattermost + '/login') + end + + def sign_in_using_oauth + click_link class: 'btn btn-custom-login gitlab' + + if page.has_content?('Authorize GitLab Mattermost to use your account?') + click_button 'Authorize' + end + end + end + end + end +end diff --git a/qa/qa/page/mattermost/main.rb b/qa/qa/page/mattermost/main.rb new file mode 100644 index 00000000000..e636d7676f4 --- /dev/null +++ b/qa/qa/page/mattermost/main.rb @@ -0,0 +1,11 @@ +module QA + module Page + module Mattermost + class Main < Page::Base + def initialize + visit(Runtime::Scenario.mattermost) + end + end + end + end +end diff --git a/qa/qa/runtime/scenario.rb b/qa/qa/runtime/scenario.rb new file mode 100644 index 00000000000..0c5e9787e17 --- /dev/null +++ b/qa/qa/runtime/scenario.rb @@ -0,0 +1,8 @@ +module QA + module Runtime + module Scenario + extend self + attr_accessor :mattermost + end + end +end diff --git a/qa/qa/scenario/test/integration/mattermost.rb b/qa/qa/scenario/test/integration/mattermost.rb index 4732f2b635b..9a84e5c8fd8 100644 --- a/qa/qa/scenario/test/integration/mattermost.rb +++ b/qa/qa/scenario/test/integration/mattermost.rb @@ -8,6 +8,11 @@ module QA # class Mattermost < Scenario::Entrypoint tags :core, :mattermost + + def perform(address, mattermost, *files) + Runtime::Scenario.mattermost = mattermost + super(address, files) + end end end end diff --git a/qa/qa/specs/features/mattermost/login_spec.rb b/qa/qa/specs/features/mattermost/login_spec.rb new file mode 100644 index 00000000000..a89a6a3d1cf --- /dev/null +++ b/qa/qa/specs/features/mattermost/login_spec.rb @@ -0,0 +1,12 @@ +module QA + feature 'logging in to Mattermost', :mattermost do + scenario 'can use gitlab oauth' do + Page::Main::Entry.act { sign_in_using_credentials } + Page::Mattermost::Login.act { sign_in_using_oauth } + + Page::Mattermost::Main.perform do |page| + expect(page).to have_content(/(Welcome to: Mattermost|Logout GitLab Mattermost)/) + end + end + end +end diff --git a/qa/spec/scenario/entrypoint_spec.rb b/qa/spec/scenario/entrypoint_spec.rb new file mode 100644 index 00000000000..3fd068b641c --- /dev/null +++ b/qa/spec/scenario/entrypoint_spec.rb @@ -0,0 +1,46 @@ +describe QA::Scenario::Entrypoint do + subject do + Class.new(QA::Scenario::Entrypoint) do + tags :rspec + end + end + + context '#perform' do + let(:config) { spy('Specs::Config') } + let(:release) { spy('Runtime::Release') } + let(:runner) { spy('Specs::Runner') } + + before do + allow(config).to receive(:perform) { |&block| block.call config } + allow(runner).to receive(:perform) { |&block| block.call runner } + + stub_const('QA::Specs::Config', config) + stub_const('QA::Runtime::Release', release) + stub_const('QA::Specs::Runner', runner) + end + + it 'should set address' do + subject.perform("hello") + + expect(config).to have_received(:address=).with("hello") + end + + context 'no paths' do + it 'should call runner with default arguments' do + subject.perform("test") + + expect(runner).to have_received(:rspec) + .with(hash_including(files: 'qa/specs/features')) + end + end + + context 'specifying paths' do + it 'should call runner with paths' do + subject.perform('test', 'path1', 'path2') + + expect(runner).to have_received(:rspec) + .with(hash_including(files: %w(path1 path2))) + end + end + end +end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 6802b839eaa..b73ca0c2346 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -50,70 +50,36 @@ describe ApplicationController do end end - describe "#authenticate_user_from_token!" do - describe "authenticating a user from a private token" do - controller(described_class) do - def index - render text: "authenticated" - end - end - - context "when the 'private_token' param is populated with the private token" do - it "logs the user in" do - get :index, private_token: user.private_token - expect(response).to have_gitlab_http_status(200) - expect(response.body).to eq("authenticated") - end - end - - context "when the 'PRIVATE-TOKEN' header is populated with the private token" do - it "logs the user in" do - @request.headers['PRIVATE-TOKEN'] = user.private_token - get :index - expect(response).to have_gitlab_http_status(200) - expect(response.body).to eq("authenticated") - end - end - - it "doesn't log the user in otherwise" do - @request.headers['PRIVATE-TOKEN'] = "token" - get :index, private_token: "token", authenticity_token: "token" - expect(response.status).not_to eq(200) - expect(response.body).not_to eq("authenticated") + describe "#authenticate_user_from_personal_access_token!" do + controller(described_class) do + def index + render text: 'authenticated' end end - describe "authenticating a user from a personal access token" do - controller(described_class) do - def index - render text: 'authenticated' - end - end - - let(:personal_access_token) { create(:personal_access_token, user: user) } + let(:personal_access_token) { create(:personal_access_token, user: user) } - context "when the 'personal_access_token' param is populated with the personal access token" do - it "logs the user in" do - get :index, private_token: personal_access_token.token - expect(response).to have_gitlab_http_status(200) - expect(response.body).to eq('authenticated') - end + context "when the 'personal_access_token' param is populated with the personal access token" do + it "logs the user in" do + get :index, private_token: personal_access_token.token + expect(response).to have_gitlab_http_status(200) + expect(response.body).to eq('authenticated') end + end - context "when the 'PERSONAL_ACCESS_TOKEN' header is populated with the personal access token" do - it "logs the user in" do - @request.headers["PRIVATE-TOKEN"] = personal_access_token.token - get :index - expect(response).to have_gitlab_http_status(200) - expect(response.body).to eq('authenticated') - end + context "when the 'PERSONAL_ACCESS_TOKEN' header is populated with the personal access token" do + it "logs the user in" do + @request.headers["PRIVATE-TOKEN"] = personal_access_token.token + get :index + expect(response).to have_gitlab_http_status(200) + expect(response.body).to eq('authenticated') end + end - it "doesn't log the user in otherwise" do - get :index, private_token: "token" - expect(response.status).not_to eq(200) - expect(response.body).not_to eq('authenticated') - end + it "doesn't log the user in otherwise" do + get :index, private_token: "token" + expect(response.status).not_to eq(200) + expect(response.body).not_to eq('authenticated') end end @@ -152,11 +118,15 @@ describe ApplicationController do end end + before do + sign_in user + end + context 'when format is handled' do let(:requested_format) { :json } it 'returns 200 response' do - get :index, private_token: user.private_token, format: requested_format + get :index, format: requested_format expect(response).to have_gitlab_http_status 200 end @@ -164,7 +134,7 @@ describe ApplicationController do context 'when format is not handled' do it 'returns 404 response' do - get :index, private_token: user.private_token + get :index expect(response).to have_gitlab_http_status 404 end diff --git a/spec/controllers/concerns/lfs_request_spec.rb b/spec/controllers/concerns/lfs_request_spec.rb new file mode 100644 index 00000000000..33b23db302a --- /dev/null +++ b/spec/controllers/concerns/lfs_request_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' + +describe LfsRequest do + include ProjectForksHelper + + controller(Projects::GitHttpClientController) do + # `described_class` is not available in this context + include LfsRequest # rubocop:disable RSpec/DescribedClass + + def show + storage_project + + render nothing: true + end + + def project + @project ||= Project.find(params[:id]) + end + + def download_request? + true + end + + def ci? + false + end + end + + let(:project) { create(:project, :public) } + + before do + stub_lfs_setting(enabled: true) + end + + describe '#storage_project' do + it 'assigns the project as storage project' do + get :show, id: project.id + + expect(assigns(:storage_project)).to eq(project) + end + + it 'assigns the source of a forked project' do + forked_project = fork_project(project) + + get :show, id: forked_project.id + + expect(assigns(:storage_project)).to eq(project) + end + end +end diff --git a/spec/controllers/metrics_controller_spec.rb b/spec/controllers/metrics_controller_spec.rb index 7b0976e3e67..4aed2a25baa 100644 --- a/spec/controllers/metrics_controller_spec.rb +++ b/spec/controllers/metrics_controller_spec.rb @@ -59,17 +59,6 @@ describe MetricsController do expect(response.body).to match(/^redis_shared_state_ping_latency_seconds [0-9\.]+$/) end - it 'returns file system check metrics' do - get :index - - expect(response.body).to match(/^filesystem_access_latency_seconds{shard="default"} [0-9\.]+$/) - expect(response.body).to match(/^filesystem_accessible{shard="default"} 1$/) - expect(response.body).to match(/^filesystem_write_latency_seconds{shard="default"} [0-9\.]+$/) - expect(response.body).to match(/^filesystem_writable{shard="default"} 1$/) - expect(response.body).to match(/^filesystem_read_latency_seconds{shard="default"} [0-9\.]+$/) - expect(response.body).to match(/^filesystem_readable{shard="default"} 1$/) - end - context 'prometheus metrics are disabled' do before do allow(Gitlab::Metrics).to receive(:prometheus_metrics_enabled?).and_return(false) diff --git a/spec/controllers/projects/clusters_controller_spec.rb b/spec/controllers/projects/clusters_controller_spec.rb index 5da4be2d319..f64b493c283 100644 --- a/spec/controllers/projects/clusters_controller_spec.rb +++ b/spec/controllers/projects/clusters_controller_spec.rb @@ -1,68 +1,108 @@ require 'spec_helper' describe Projects::ClustersController do - set(:user) { create(:user) } - set(:project) { create(:project) } - let(:role) { :master } + include AccessMatchersForController + include GoogleApi::CloudPlatformHelpers - before do - project.team << [user, role] + describe 'GET index' do + describe 'functionality' do + let(:user) { create(:user) } - sign_in(user) - end + before do + project.add_master(user) + sign_in(user) + end - describe 'GET index' do - subject do - get :index, namespace_id: project.namespace, - project_id: project - end + context 'when project has a cluster' do + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } - context 'when cluster is already created' do - let!(:cluster) { create(:gcp_cluster, :created_on_gke, project: project) } + it { expect(go).to redirect_to(project_cluster_path(project, project.cluster)) } + end - it 'redirects to show a cluster' do - subject + context 'when project does not have a cluster' do + let(:project) { create(:project) } - expect(response).to redirect_to(project_cluster_path(project, cluster)) + it { expect(go).to redirect_to(new_project_cluster_path(project)) } end end - context 'when we do not have cluster' do - it 'redirects to create a cluster' do - subject + describe 'security' do + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } + + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end - expect(response).to redirect_to(new_project_cluster_path(project)) - end + def go + get :index, namespace_id: project.namespace.to_param, project_id: project end end describe 'GET login' do - render_views + let(:project) { create(:project) } - subject do - get :login, namespace_id: project.namespace, - project_id: project - end - - context 'when we do have omniauth configured' do - it 'shows login button' do - subject + describe 'functionality' do + let(:user) { create(:user) } - expect(response.body).to include('auth_buttons/signin_with_google') + before do + project.add_master(user) + sign_in(user) end - end - context 'when we do not have omniauth configured' do - before do - stub_omniauth_setting(providers: []) + context 'when omniauth has been configured' do + let(:key) { 'secere-key' } + + let(:session_key_for_redirect_uri) do + GoogleApi::CloudPlatform::Client.session_key_for_redirect_uri(key) + end + + before do + allow(SecureRandom).to receive(:hex).and_return(key) + end + + it 'has authorize_url' do + go + + expect(assigns(:authorize_url)).to include(key) + expect(session[session_key_for_redirect_uri]).to eq(providers_gcp_new_project_clusters_url(project)) + end end - it 'shows notice message' do - subject + context 'when omniauth has not configured' do + before do + stub_omniauth_setting(providers: []) + end - expect(response.body).to include('Ask your GitLab administrator if you want to use this service.') + it 'does not have authorize_url' do + go + + expect(assigns(:authorize_url)).to be_nil + end end end + + describe 'security' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end + + def go + get :login, namespace_id: project.namespace, project_id: project + end end shared_examples 'requires to login' do @@ -74,235 +114,416 @@ describe Projects::ClustersController do end describe 'GET new_gcp' do - render_views + let(:project) { create(:project) } - subject do - get :new_gcp, namespace_id: project.namespace, - project_id: project - end + describe 'functionality' do + let(:user) { create(:user) } - context 'when logged' do before do - make_logged_in + project.add_master(user) + sign_in(user) end - it 'shows a creation form' do - subject + context 'when access token is valid' do + before do + stub_google_api_validate_token + end + + it 'has new object' do + go - expect(response.body).to include('Create cluster') + expect(assigns(:cluster)).to be_an_instance_of(Clusters::Cluster) + end + end + + context 'when access token is expired' do + before do + stub_google_api_expired_token + end + + it { expect(go).to redirect_to(login_project_clusters_path(project)) } + end + + context 'when access token is not stored in session' do + it { expect(go).to redirect_to(login_project_clusters_path(project)) } end end - context 'when not logged' do - it_behaves_like 'requires to login' + describe 'security' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end + + def go + get :new_gcp, namespace_id: project.namespace, project_id: project end end describe 'POST create' do - subject do - post :create, params.merge(namespace_id: project.namespace, - project_id: project) + let(:project) { create(:project) } + + let(:params) do + { + cluster: { + name: 'new-cluster', + provider_type: :gcp, + provider_gcp_attributes: { + gcp_project_id: '111' + } + } + } end - context 'when not logged' do - let(:params) { {} } - - it_behaves_like 'requires to login' - end + describe 'functionality' do + let(:user) { create(:user) } - context 'when logged in' do before do - make_logged_in + project.add_master(user) + sign_in(user) end - context 'when all required parameters are set' do - let(:params) do - { - cluster: { - gcp_cluster_name: 'new-cluster', - gcp_project_id: '111' - } - } - end - + context 'when access token is valid' do before do - expect(ClusterProvisionWorker).to receive(:perform_async) { } + stub_google_api_validate_token end - it 'creates a new cluster' do - expect { subject }.to change { Gcp::Cluster.count } - - expect(response).to redirect_to(project_cluster_path(project, project.cluster)) + context 'when creates a cluster on gke' do + it 'creates a new cluster' do + expect(ClusterProvisionWorker).to receive(:perform_async) + expect { go }.to change { Clusters::Cluster.count } + expect(response).to redirect_to(project_cluster_path(project, project.cluster)) + end end - end - context 'when not all required parameters are set' do - render_views + # TODO: Activate in 10.3 + # context 'when adds a cluster manually' do + # let(:params) do + # { + # cluster: { + # name: 'new-cluster', + # platform_type: :kubernetes, + # provider_type: :user, + # platform_kubernetes_attributes: { + # namespace: 'custom-namespace', + # api_url: 'https://111.111.111.111', + # token: 'token' + # } + # } + # } + # end + + # it 'creates a new cluster' do + # expect(ClusterProvisionWorker).to receive(:perform_async) + # expect { go }.to change { Clusters::Cluster.count } + # expect(response).to redirect_to(project_cluster_path(project, project.cluster)) + # end + # end + + # TODO: We should fix this in 10.2 + # Maybe + # - validates :provider_gcp, presence: true, if: :gcp? + # - validates :provider_type, presence: true + # are required in Clusters::Cluster + # context 'when not all required parameters are set' do + # let(:params) do + # { + # cluster: { + # name: 'new-cluster' + # } + # } + # end + + # it 'shows an error message' do + # expect { go }.not_to change { Clusters::Cluster.count } + # expect(assigns(:cluster).errors).not_to be_empty + # expect(response).to render_template(:new) + # end + # end + end - let(:params) do - { - cluster: { - project_namespace: 'some namespace' - } - } + context 'when access token is expired' do + before do + stub_google_api_expired_token end - it 'shows an error message' do - expect { subject }.not_to change { Gcp::Cluster.count } + it 'redirects to login page' do + expect(go).to redirect_to(login_project_clusters_path(project)) + end + end - expect(response).to render_template(:new_gcp) + context 'when access token is not stored in session' do + it 'redirects to login page' do + expect(go).to redirect_to(login_project_clusters_path(project)) end end end + + describe 'security' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end + + def go + post :create, params.merge(namespace_id: project.namespace, project_id: project) + end end describe 'GET status' do - let(:cluster) { create(:gcp_cluster, :created_on_gke, project: project) } + let(:cluster) { create(:cluster, :project, :providing_by_gcp) } + let(:project) { cluster.project } + + describe 'functionality' do + let(:user) { create(:user) } - subject do + before do + project.add_master(user) + sign_in(user) + end + + it "responds with matching schema" do + go + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('cluster_status') + end + end + + describe 'security' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end + + def go get :status, namespace_id: project.namespace, project_id: project, id: cluster, format: :json end - - it "responds with matching schema" do - subject - - expect(response).to have_gitlab_http_status(:ok) - expect(response).to match_response_schema('cluster_status') - end end describe 'GET show' do - render_views - - let(:cluster) { create(:gcp_cluster, :created_on_gke, project: project) } - - subject do - get :show, namespace_id: project.namespace, - project_id: project, - id: cluster - end + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } - context 'when logged as master' do - it "allows to update cluster" do - subject + describe 'functionality' do + let(:user) { create(:user) } - expect(response).to have_gitlab_http_status(:ok) - expect(response.body).to include("Save") + before do + project.add_master(user) + sign_in(user) end - it "allows remove integration" do - subject + it "renders view" do + go expect(response).to have_gitlab_http_status(:ok) - expect(response.body).to include("Remove integration") + expect(assigns(:cluster)).to eq(cluster) end end - context 'when logged as developer' do - let(:role) { :developer } - - it "does not allow to access page" do - subject + describe 'security' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end - expect(response).to have_gitlab_http_status(:not_found) - end + def go + get :show, namespace_id: project.namespace, + project_id: project, + id: cluster end end describe 'PUT update' do - render_views + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } - let(:service) { project.build_kubernetes_service } - let(:cluster) { create(:gcp_cluster, :created_on_gke, project: project, service: service) } - let(:params) { {} } + describe 'functionality' do + let(:user) { create(:user) } - subject do - put :update, params.merge(namespace_id: project.namespace, - project_id: project, - id: cluster) - end + before do + project.add_master(user) + sign_in(user) + end - context 'when logged as master' do - context 'when valid params are used' do + context 'when update enabled' do let(:params) do { cluster: { enabled: false } } end - it "redirects back to show page" do - subject + it "updates and redirects back to show page" do + go + cluster.reload expect(response).to redirect_to(project_cluster_path(project, project.cluster)) expect(flash[:notice]).to eq('Cluster was successfully updated.') + expect(cluster.enabled).to be_falsey end - end - context 'when invalid params are used' do - let(:params) do - { - cluster: { project_namespace: 'my Namespace 321321321 #' } - } - end + context 'when cluster is being created' do + let(:cluster) { create(:cluster, :project, :providing_by_gcp) } - it "rejects changes" do - subject + it "rejects changes" do + go - expect(response).to have_gitlab_http_status(:ok) - expect(response).to render_template(:show) + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template(:show) + expect(cluster.enabled).to be_truthy + end end end + + # TODO: Activate in 10.3 + # context 'when update namespace' do + # let(:namespace) { 'namespace-123' } + + # let(:params) do + # { + # cluster: { + # platform_kubernetes_attributes: { + # namespace: namespace + # } + # } + # } + # end + + # it "updates and redirects back to show page" do + # go + + # cluster.reload + # expect(response).to redirect_to(project_cluster_path(project, project.cluster)) + # expect(flash[:notice]).to eq('Cluster was successfully updated.') + # expect(cluster.platform.namespace).to eq(namespace) + # end + + # context 'when namespace is invalid' do + # let(:namespace) { 'my Namespace 321321321 #' } + + # it "rejects changes" do + # go + + # expect(response).to have_gitlab_http_status(:ok) + # expect(response).to render_template(:show) + # expect(cluster.platform.namespace).not_to eq(namespace) + # end + # end + # end end - context 'when logged as developer' do - let(:role) { :developer } + describe 'security' do + let(:params) do + { + cluster: { enabled: false } + } + end - it "does not allow to update cluster" do - subject + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end - expect(response).to have_gitlab_http_status(:not_found) - end + def go + put :update, params.merge(namespace_id: project.namespace, + project_id: project, + id: cluster) end end describe 'delete update' do - let(:cluster) { create(:gcp_cluster, :created_on_gke, project: project) } + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } - subject do - delete :destroy, namespace_id: project.namespace, - project_id: project, - id: cluster - end + describe 'functionality' do + let(:user) { create(:user) } - context 'when logged as master' do - it "redirects back to clusters list" do - subject + before do + project.add_master(user) + sign_in(user) + end + + it "destroys and redirects back to clusters list" do + expect { go } + .to change { Clusters::Cluster.count }.by(-1) + .and change { Clusters::Platforms::Kubernetes.count }.by(-1) + .and change { Clusters::Providers::Gcp.count }.by(-1) expect(response).to redirect_to(project_clusters_path(project)) expect(flash[:notice]).to eq('Cluster integration was successfully removed.') end - end - context 'when logged as developer' do - let(:role) { :developer } + context 'when cluster is being created' do + let(:cluster) { create(:cluster, :project, :providing_by_gcp) } + + it "destroys and redirects back to clusters list" do + expect { go } + .to change { Clusters::Cluster.count }.by(-1) + .and change { Clusters::Providers::Gcp.count }.by(-1) + + expect(response).to redirect_to(project_clusters_path(project)) + expect(flash[:notice]).to eq('Cluster integration was successfully removed.') + end + end + + context 'when provider is user' do + let(:cluster) { create(:cluster, :project, :provided_by_user) } - it "does not allow to destroy cluster" do - subject + it "destroys and redirects back to clusters list" do + expect { go } + .to change { Clusters::Cluster.count }.by(-1) + .and change { Clusters::Platforms::Kubernetes.count }.by(-1) + .and change { Clusters::Providers::Gcp.count }.by(0) - expect(response).to have_gitlab_http_status(:not_found) + expect(response).to redirect_to(project_clusters_path(project)) + expect(flash[:notice]).to eq('Cluster integration was successfully removed.') + end end end - end - def make_logged_in - session[GoogleApi::CloudPlatform::Client.session_key_for_token] = '1234' - session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = in_hour.to_i.to_s - end + describe 'security' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end - def in_hour - Time.now + 1.hour + def go + delete :destroy, namespace_id: project.namespace, + project_id: project, + id: cluster + end end end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 6f48f091a20..8016176110e 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -248,6 +248,45 @@ describe Projects::IssuesController do end end + describe 'PUT #update' do + subject do + put :update, + namespace_id: project.namespace, + project_id: project, + id: issue.to_param, + issue: { title: 'New title' }, format: :json + end + + before do + sign_in(user) + end + + context 'when user has access to update issue' do + before do + project.add_developer(user) + end + + it 'updates the issue' do + subject + + expect(response).to have_http_status(:ok) + expect(issue.reload.title).to eq('New title') + end + end + + context 'when user does not have access to update issue' do + before do + project.add_guest(user) + end + + it 'responds with 404' do + subject + + expect(response).to have_http_status(:not_found) + end + end + end + describe 'Confidential Issues' do let(:project) { create(:project_empty_repo, :public) } let(:assignee) { create(:assignee) } @@ -557,6 +596,29 @@ describe Projects::IssuesController do end end end + + describe 'GET #edit' do + it_behaves_like 'restricted action', success: 200 + + def go(id:) + get :edit, + namespace_id: project.namespace.to_param, + project_id: project, + id: id + end + end + + describe 'PUT #update' do + it_behaves_like 'restricted action', success: 302 + + def go(id:) + put :update, + namespace_id: project.namespace.to_param, + project_id: project, + id: id, + issue: { title: 'New title' } + end + end end describe 'POST #create' do diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index b7cccddefdd..14021b8ca50 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -83,15 +83,15 @@ describe Projects::MergeRequestsController do end describe 'as json' do - context 'with basic param' do + context 'with basic serializer param' do it 'renders basic MR entity as json' do - go(basic: true, format: :json) + go(serializer: 'basic', format: :json) expect(response).to match_response_schema('entities/merge_request_basic') end end - context 'without basic param' do + context 'without basic serializer param' do it 'renders the merge request in the json format' do go(format: :json) @@ -186,17 +186,23 @@ describe Projects::MergeRequestsController do end describe 'PUT update' do + def update_merge_request(mr_params, additional_params = {}) + params = { + namespace_id: project.namespace, + project_id: project, + id: merge_request.iid, + merge_request: mr_params + }.merge(additional_params) + + put :update, params + end + context 'changing the assignee' do it 'limits the attributes exposed on the assignee' do assignee = create(:user) project.add_developer(assignee) - put :update, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - merge_request: { assignee_id: assignee.id }, - format: :json + update_merge_request({ assignee_id: assignee.id }, format: :json) body = JSON.parse(response.body) expect(body['assignee'].keys) @@ -204,6 +210,20 @@ describe Projects::MergeRequestsController do end end + context 'when user does not have access to update issue' do + before do + reporter = create(:user) + project.add_reporter(reporter) + sign_in(reporter) + end + + it 'responds with 404' do + update_merge_request(title: 'New title') + + expect(response).to have_http_status(:not_found) + end + end + context 'there is no source project' do let(:project) { create(:project, :repository) } let(:forked_project) { fork_project_with_submodules(project) } @@ -214,13 +234,7 @@ describe Projects::MergeRequestsController do end it 'closes MR without errors' do - post :update, - namespace_id: project.namespace, - project_id: project, - id: merge_request.iid, - merge_request: { - state_event: 'close' - } + update_merge_request(state_event: 'close') expect(response).to redirect_to([merge_request.target_project.namespace.becomes(Namespace), merge_request.target_project, merge_request]) expect(merge_request.reload.closed?).to be_truthy @@ -229,13 +243,7 @@ describe Projects::MergeRequestsController do it 'allows editing of a closed merge request' do merge_request.close! - put :update, - namespace_id: project.namespace, - project_id: project, - id: merge_request.iid, - merge_request: { - title: 'New title' - } + update_merge_request(title: 'New title') expect(response).to redirect_to([merge_request.target_project.namespace.becomes(Namespace), merge_request.target_project, merge_request]) expect(merge_request.reload.title).to eq 'New title' @@ -244,13 +252,7 @@ describe Projects::MergeRequestsController do it 'does not allow to update target branch closed merge request' do merge_request.close! - put :update, - namespace_id: project.namespace, - project_id: project, - id: merge_request.iid, - merge_request: { - target_branch: 'new_branch' - } + update_merge_request(target_branch: 'new_branch') expect { merge_request.reload.target_branch }.not_to change { merge_request.target_branch } end diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb index 573530d0db0..209979e642d 100644 --- a/spec/controllers/projects/milestones_controller_spec.rb +++ b/spec/controllers/projects/milestones_controller_spec.rb @@ -86,4 +86,32 @@ describe Projects::MilestonesController do expect(last_note).to eq('removed milestone') end end + + describe '#promote' do + context 'promotion succeeds' do + before do + group = create(:group) + group.add_developer(user) + milestone.project.update(namespace: group) + end + + it 'shows group milestone' do + post :promote, namespace_id: project.namespace.id, project_id: project.id, id: milestone.iid + + group_milestone = assigns(:milestone) + + expect(response).to redirect_to(group_milestone_path(project.group, group_milestone.iid)) + expect(flash[:notice]).to eq('Milestone has been promoted to group milestone.') + end + end + + context 'promotion fails' do + it 'shows project milestone' do + post :promote, namespace_id: project.namespace.id, project_id: project.id, id: milestone.iid + + expect(response).to redirect_to(project_milestone_path(project, milestone)) + expect(flash[:alert]).to eq('Promotion failed - Project does not belong to a group.') + end + end + end end diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index 1184c55e540..5f5a789d5cc 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -59,6 +59,7 @@ describe Projects::NotesController do expect(note_json[:id]).to eq(note.id) expect(note_json[:discussion_html]).not_to be_nil expect(note_json[:diff_discussion_html]).to be_nil + expect(note_json[:discussion_line_code]).to be_nil end end @@ -74,6 +75,7 @@ describe Projects::NotesController do expect(note_json[:id]).to eq(note.id) expect(note_json[:discussion_html]).not_to be_nil expect(note_json[:diff_discussion_html]).not_to be_nil + expect(note_json[:discussion_line_code]).not_to be_nil end end @@ -92,6 +94,7 @@ describe Projects::NotesController do expect(note_json[:id]).to eq(note.id) expect(note_json[:discussion_html]).not_to be_nil expect(note_json[:diff_discussion_html]).to be_nil + expect(note_json[:discussion_line_code]).to be_nil end end @@ -104,6 +107,20 @@ describe Projects::NotesController do expect(note_json[:id]).to eq(note.id) expect(note_json[:discussion_html]).to be_nil expect(note_json[:diff_discussion_html]).to be_nil + expect(note_json[:discussion_line_code]).to be_nil + end + + context 'when user cannot read commit' do + before do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?).with(user, :download_code, project).and_return(false) + end + + it 'renders 404' do + get :index, params + + expect(response).to have_gitlab_http_status(404) + end end end end @@ -120,6 +137,7 @@ describe Projects::NotesController do expect(note_json[:html]).not_to be_nil expect(note_json[:discussion_html]).to be_nil expect(note_json[:diff_discussion_html]).to be_nil + expect(note_json[:discussion_line_code]).to be_nil end end diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index f1ad5dd84ff..1604a2da485 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -46,7 +46,7 @@ describe Projects::PipelinesController do context 'when performing gitaly calls', :request_store do it 'limits the Gitaly requests' do - expect { subject }.to change { Gitlab::GitalyClient.get_request_count }.by(10) + expect { subject }.to change { Gitlab::GitalyClient.get_request_count }.by(8) end end end diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index c2b59239af9..cf38066dedc 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -119,7 +119,7 @@ FactoryGirl.define do finished_at nil end - factory :ci_build_tag do + trait :tag do tag true end diff --git a/spec/factories/clusters/cluster.rb b/spec/factories/clusters/cluster.rb new file mode 100644 index 00000000000..802981d47a0 --- /dev/null +++ b/spec/factories/clusters/cluster.rb @@ -0,0 +1,39 @@ +FactoryGirl.define do + factory :cluster, class: Clusters::Cluster do + user + name 'test-cluster' + + trait :project do + after(:create) do |cluster, evaluator| + cluster.projects << create(:project) + end + end + + trait :provided_by_user do + provider_type :user + platform_type :kubernetes + + platform_kubernetes do + create(:platform_kubernetes, :configured) + end + end + + trait :provided_by_gcp do + provider_type :gcp + platform_type :kubernetes + + before(:create) do |cluster, evaluator| + cluster.platform_kubernetes = build(:platform_kubernetes, :configured) + cluster.provider_gcp = build(:provider_gcp, :created) + end + end + + trait :providing_by_gcp do + provider_type :gcp + + provider_gcp do + create(:provider_gcp, :creating) + end + end + end +end diff --git a/spec/factories/clusters/platforms/kubernetes.rb b/spec/factories/clusters/platforms/kubernetes.rb new file mode 100644 index 00000000000..5d4e3e5cf5d --- /dev/null +++ b/spec/factories/clusters/platforms/kubernetes.rb @@ -0,0 +1,20 @@ +FactoryGirl.define do + factory :platform_kubernetes, class: Clusters::Platforms::Kubernetes do + cluster + namespace nil + api_url 'https://kubernetes.example.com' + token 'a' * 40 + + trait :configured do + api_url 'https://kubernetes.example.com' + token 'a' * 40 + username 'xxxxxx' + password 'xxxxxx' + + after(:create) do |platform_kubernetes, evaluator| + pem_file = File.expand_path(Rails.root.join('spec/fixtures/clusters/sample_cert.pem')) + platform_kubernetes.ca_cert = File.read(pem_file) + end + end + end +end diff --git a/spec/factories/clusters/providers/gcp.rb b/spec/factories/clusters/providers/gcp.rb new file mode 100644 index 00000000000..13bf50d7b7f --- /dev/null +++ b/spec/factories/clusters/providers/gcp.rb @@ -0,0 +1,32 @@ +FactoryGirl.define do + factory :provider_gcp, class: Clusters::Providers::Gcp do + cluster + gcp_project_id 'test-gcp-project' + + trait :scheduled do + access_token 'access_token_123' + end + + trait :creating do + access_token 'access_token_123' + + after(:build) do |gcp, evaluator| + gcp.make_creating('operation-123') + end + end + + trait :created do + endpoint '111.111.111.111' + + after(:build) do |gcp, evaluator| + gcp.make_created + end + end + + trait :errored do + after(:build) do |gcp, evaluator| + gcp.make_errored('Something wrong') + end + end + end +end diff --git a/spec/factories/gcp/cluster.rb b/spec/factories/gcp/cluster.rb deleted file mode 100644 index 630e40da888..00000000000 --- a/spec/factories/gcp/cluster.rb +++ /dev/null @@ -1,38 +0,0 @@ -FactoryGirl.define do - factory :gcp_cluster, class: Gcp::Cluster do - project - user - enabled true - gcp_project_id 'gcp-project-12345' - gcp_cluster_name 'test-cluster' - gcp_cluster_zone 'us-central1-a' - gcp_cluster_size 1 - gcp_machine_type 'n1-standard-4' - - trait :with_kubernetes_service do - after(:create) do |cluster, evaluator| - create(:kubernetes_service, project: cluster.project).tap do |service| - cluster.update(service: service) - end - end - end - - trait :custom_project_namespace do - project_namespace 'sample-app' - end - - trait :created_on_gke do - status_event :make_created - endpoint '111.111.111.111' - ca_cert 'xxxxxx' - kubernetes_token 'xxxxxx' - username 'xxxxxx' - password 'xxxxxx' - end - - trait :errored do - status_event :make_errored - status_reason 'general error' - end - end -end diff --git a/spec/features/admin/admin_disables_two_factor_spec.rb b/spec/features/admin/admin_disables_two_factor_spec.rb index 6a97378391b..2abdd3c9ef2 100644 --- a/spec/features/admin/admin_disables_two_factor_spec.rb +++ b/spec/features/admin/admin_disables_two_factor_spec.rb @@ -7,7 +7,7 @@ feature 'Admin disables 2FA for a user' do edit_user(user) page.within('.two-factor-status') do - click_link 'Disable' + accept_confirm { click_link 'Disable' } end page.within('.two-factor-status') do diff --git a/spec/features/admin/admin_groups_spec.rb b/spec/features/admin/admin_groups_spec.rb index 771fb5253da..a5f22848031 100644 --- a/spec/features/admin/admin_groups_spec.rb +++ b/spec/features/admin/admin_groups_spec.rb @@ -152,7 +152,7 @@ feature 'Admin Groups' do expect(page).to have_content('Developer') end - find(:css, 'li', text: current_user.name).find(:css, 'a.btn-remove').click + accept_confirm { find(:css, 'li', text: current_user.name).find(:css, 'a.btn-remove').click } visit group_group_members_path(group) diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb index 2e65fcc5231..eec44549a03 100644 --- a/spec/features/admin/admin_hooks_spec.rb +++ b/spec/features/admin/admin_hooks_spec.rb @@ -62,14 +62,14 @@ describe 'Admin::Hooks', :js do it 'from hooks list page' do visit admin_hooks_path - expect { click_link 'Remove' }.to change(SystemHook, :count).by(-1) + expect { accept_confirm { find(:link, 'Remove').send_keys(:return) } }.to change(SystemHook, :count).by(-1) end it 'from hook edit page' do visit admin_hooks_path click_link 'Edit' - expect { click_link 'Remove' }.to change(SystemHook, :count).by(-1) + expect { accept_confirm { find(:link, 'Remove').send_keys(:return) } }.to change(SystemHook, :count).by(-1) end end end diff --git a/spec/features/admin/admin_labels_spec.rb b/spec/features/admin/admin_labels_spec.rb index a5834056a1d..de406d7d966 100644 --- a/spec/features/admin/admin_labels_spec.rb +++ b/spec/features/admin/admin_labels_spec.rb @@ -33,7 +33,7 @@ RSpec.describe 'admin issues labels' do it 'deletes all labels', :js do page.within '.labels' do page.all('.btn-remove').each do |remove| - remove.click + accept_confirm { remove.click } wait_for_requests end end diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index c490dce7ab0..1218ea52227 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -73,7 +73,7 @@ feature 'Admin updates settings' do context 'sign-in restrictions', :js do it 'de-activates oauth sign-in source' do - find('.btn', text: 'GitLab.com').click + find('input#application_setting_enabled_oauth_sign_in_sources_[value=gitlab]').send_keys(:return) expect(find('.btn', text: 'GitLab.com')).not_to have_css('.active') end @@ -95,6 +95,29 @@ feature 'Admin updates settings' do expect(find_field('ED25519 SSH keys').value).to eq(forbidden) end + scenario 'Change Performance Bar settings' do + group = create(:group) + + check 'Enable the Performance Bar' + fill_in 'Allowed group', with: group.path + + click_on 'Save' + + expect(page).to have_content 'Application settings saved successfully' + + expect(find_field('Enable the Performance Bar')).to be_checked + expect(find_field('Allowed group').value).to eq group.path + + uncheck 'Enable the Performance Bar' + + click_on 'Save' + + expect(page).to have_content 'Application settings saved successfully' + + expect(find_field('Enable the Performance Bar')).not_to be_checked + expect(find_field('Allowed group').value).to be_nil + end + def check_all_events page.check('Active') page.check('Push') diff --git a/spec/features/admin/admin_users_impersonation_tokens_spec.rb b/spec/features/admin/admin_users_impersonation_tokens_spec.rb index 388d30828a7..e16eae219a4 100644 --- a/spec/features/admin/admin_users_impersonation_tokens_spec.rb +++ b/spec/features/admin/admin_users_impersonation_tokens_spec.rb @@ -24,7 +24,7 @@ describe 'Admin > Users > Impersonation Tokens', :js do fill_in "Name", with: name # Set date to 1st of next month - find_field("Expires at").trigger('focus') + find_field("Expires at").click find(".pika-next").click click_on "1" @@ -60,7 +60,7 @@ describe 'Admin > Users > Impersonation Tokens', :js do it "allows revocation of an active impersonation token" do visit admin_user_impersonation_tokens_path(user_id: user.username) - click_on "Revoke" + accept_confirm { click_on "Revoke" } expect(page).to have_selector(".settings-message") expect(no_personal_access_tokens_message).to have_text("This user has no active Impersonation Tokens.") diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index f9f4bd6f5b9..b47f9055d29 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -290,7 +290,7 @@ describe "Admin::Users" do it 'allows group membership to be revoked', :js do page.within(first('.group_member')) do - find('.btn-remove').click + accept_confirm { find('.btn-remove').click } end wait_for_requests @@ -319,7 +319,7 @@ describe "Admin::Users" do expect(page).to have_content("Secondary email: #{secondary_email.email}") - find("#remove_email_#{secondary_email.id}").click + accept_confirm { find("#remove_email_#{secondary_email.id}").click } expect(page).not_to have_content(secondary_email.email) end diff --git a/spec/features/admin/admin_uses_repository_checks_spec.rb b/spec/features/admin/admin_uses_repository_checks_spec.rb index 42f5b5eb8dc..f1ac73ff819 100644 --- a/spec/features/admin/admin_uses_repository_checks_spec.rb +++ b/spec/features/admin/admin_uses_repository_checks_spec.rb @@ -37,7 +37,7 @@ feature 'Admin uses repository checks' do expect(RepositoryCheck::ClearWorker).to receive(:perform_async) - click_link 'Clear all repository checks' + accept_confirm { find(:link, 'Clear all repository checks').send_keys(:return) } expect(page).to have_content('Started asynchronous removal of all repository check states.') end diff --git a/spec/features/atom/dashboard_issues_spec.rb b/spec/features/atom/dashboard_issues_spec.rb index 5aae2dbaf91..89c9d377003 100644 --- a/spec/features/atom/dashboard_issues_spec.rb +++ b/spec/features/atom/dashboard_issues_spec.rb @@ -13,8 +13,10 @@ describe "Dashboard Issues Feed" do end describe "atom feed" do - it "renders atom feed via private token" do - visit issues_dashboard_path(:atom, private_token: user.private_token) + it "renders atom feed via personal access token" do + personal_access_token = create(:personal_access_token, user: user) + + visit issues_dashboard_path(:atom, private_token: personal_access_token.token) expect(response_headers['Content-Type']).to have_content('application/atom+xml') expect(body).to have_selector('title', text: "#{user.name} issues") diff --git a/spec/features/atom/dashboard_spec.rb b/spec/features/atom/dashboard_spec.rb index 321c8a2a670..2c0c331b6db 100644 --- a/spec/features/atom/dashboard_spec.rb +++ b/spec/features/atom/dashboard_spec.rb @@ -4,9 +4,11 @@ describe "Dashboard Feed" do describe "GET /" do let!(:user) { create(:user, name: "Jonh") } - context "projects atom feed via private token" do + context "projects atom feed via personal access token" do it "renders projects atom feed" do - visit dashboard_projects_path(:atom, private_token: user.private_token) + personal_access_token = create(:personal_access_token, user: user) + + visit dashboard_projects_path(:atom, private_token: personal_access_token.token) expect(body).to have_selector('feed title') end end diff --git a/spec/features/atom/issues_spec.rb b/spec/features/atom/issues_spec.rb index 3eeb4d35131..4102ac0588a 100644 --- a/spec/features/atom/issues_spec.rb +++ b/spec/features/atom/issues_spec.rb @@ -28,10 +28,12 @@ describe 'Issues Feed' do end end - context 'when authenticated via private token' do + context 'when authenticated via personal access token' do it 'renders atom feed' do + personal_access_token = create(:personal_access_token, user: user) + visit project_issues_path(project, :atom, - private_token: user.private_token) + private_token: personal_access_token.token) expect(response_headers['Content-Type']) .to have_content('application/atom+xml') diff --git a/spec/features/atom/users_spec.rb b/spec/features/atom/users_spec.rb index 9ce687afb31..2b934d81674 100644 --- a/spec/features/atom/users_spec.rb +++ b/spec/features/atom/users_spec.rb @@ -4,9 +4,11 @@ describe "User Feed" do describe "GET /" do let!(:user) { create(:user) } - context 'user atom feed via private token' do + context 'user atom feed via personal access token' do it "renders user atom feed" do - visit user_path(user, :atom, private_token: user.private_token) + personal_access_token = create(:personal_access_token, user: user) + + visit user_path(user, :atom, private_token: personal_access_token.token) expect(body).to have_selector('feed title') end end diff --git a/spec/features/boards/add_issues_modal_spec.rb b/spec/features/boards/add_issues_modal_spec.rb index c480b5b7e34..e4cfcea45a5 100644 --- a/spec/features/boards/add_issues_modal_spec.rb +++ b/spec/features/boards/add_issues_modal_spec.rb @@ -101,7 +101,7 @@ describe 'Issue Boards add issue modal', :js do click_button 'Cancel' end - first('.board-delete').click + accept_confirm { first('.board-delete').click } click_button('Add issues') diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index 60ed17c0c81..e8d779f5772 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -2,6 +2,7 @@ require 'rails_helper' describe 'Issue Boards', :js do include DragTo + include MobileHelpers let(:group) { create(:group, :nested) } let(:project) { create(:project, :public, namespace: group) } @@ -13,7 +14,7 @@ describe 'Issue Boards', :js do project.team << [user, :master] project.team << [user2, :master] - page.driver.set_cookie('sidebar_collapsed', 'true') + set_cookie('sidebar_collapsed', 'true') sign_in(user) end @@ -135,7 +136,7 @@ describe 'Issue Boards', :js do it 'allows user to delete board' do page.within(find('.board:nth-child(2)')) do - find('.board-delete').click + accept_confirm { find('.board-delete').click } end wait_for_requests @@ -150,7 +151,7 @@ describe 'Issue Boards', :js do find('.dropdown-menu-close').click page.within(find('.board:nth-child(2)')) do - find('.board-delete').click + accept_confirm { find('.board-delete').click } end wait_for_requests @@ -379,7 +380,7 @@ describe 'Issue Boards', :js do end it 'filters by milestone' do - set_filter("milestone", "\"#{milestone.title}\"") + set_filter("milestone", "\"#{milestone.title}") click_filter_link(milestone.title) submit_filter @@ -400,7 +401,7 @@ describe 'Issue Boards', :js do end it 'filters by label with space after reload' do - set_filter("label", "\"#{accepting.title}\"") + set_filter("label", "\"#{accepting.title}") click_filter_link(accepting.title) submit_filter @@ -521,7 +522,7 @@ describe 'Issue Boards', :js do end it 'allows user to use keyboard shortcuts' do - find('.boards-list').native.send_keys('i') + find('body').native.send_keys('i') expect(page).to have_content('New Issue') end end @@ -538,7 +539,7 @@ describe 'Issue Boards', :js do end it 'does not show create new list' do - expect(page).not_to have_selector('.js-new-board-list') + expect(page).not_to have_button('.js-new-board-list') end it 'does not allow dragging' do @@ -563,6 +564,9 @@ describe 'Issue Boards', :js do end def drag(selector: '.board-list', list_from_index: 0, from_index: 0, to_index: 0, list_to_index: 0) + # ensure there is enough horizontal space for four boards + resize_window(2000, 800) + drag_to(selector: selector, scrollable: '#board-app', list_from_index: list_from_index, diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index 4965f803883..9137ab82ff4 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -51,7 +51,7 @@ describe 'Issue Boards', :js do expect(page).to have_selector('.issue-boards-sidebar') - find('.gutter-toggle').trigger('click') + find('.gutter-toggle').click expect(page).not_to have_selector('.issue-boards-sidebar') end @@ -171,7 +171,7 @@ describe 'Issue Boards', :js do end page.within(find('.board:nth-child(2)')) do - find('.card:nth-child(2)').trigger('click') + find('.card:nth-child(2)').click end page.within('.assignee') do diff --git a/spec/features/calendar_spec.rb b/spec/features/calendar_spec.rb index 4fc6956d111..a9530becb65 100644 --- a/spec/features/calendar_spec.rb +++ b/spec/features/calendar_spec.rb @@ -63,8 +63,8 @@ feature 'Contributions Calendar', :js do Event.create(note_comment_params) end - def selected_day_activities - find('.user-calendar-activities').text + def selected_day_activities(visible: true) + find('.user-calendar-activities', visible: visible).text end before do @@ -112,7 +112,7 @@ feature 'Contributions Calendar', :js do end it 'hides calendar day activities' do - expect(selected_day_activities).to be_empty + expect(selected_day_activities(visible: false)).to be_empty end end end diff --git a/spec/features/container_registry_spec.rb b/spec/features/container_registry_spec.rb index d5e9de20e59..bef2aa9e0e5 100644 --- a/spec/features/container_registry_spec.rb +++ b/spec/features/container_registry_spec.rb @@ -47,7 +47,7 @@ describe "Container Registry", :js do scenario 'user removes a specific tag from container repository' do visit_container_registry - find('.js-toggle-repo').trigger('click') + find('.js-toggle-repo').click wait_for_requests expect_any_instance_of(ContainerRegistry::Tag) diff --git a/spec/features/dashboard/group_spec.rb b/spec/features/dashboard/group_spec.rb index 1213f8c32eb..1c7932e7964 100644 --- a/spec/features/dashboard/group_spec.rb +++ b/spec/features/dashboard/group_spec.rb @@ -13,7 +13,7 @@ RSpec.describe 'Dashboard Group' do it 'creates new group', :js do visit dashboard_groups_path - find('.btn-new').trigger('click') + find('.btn-new').click new_path = 'Samurai' new_description = 'Tokugawa Shogunate' diff --git a/spec/features/dashboard/groups_list_spec.rb b/spec/features/dashboard/groups_list_spec.rb index c6873d1923c..d92c002b4e7 100644 --- a/spec/features/dashboard/groups_list_spec.rb +++ b/spec/features/dashboard/groups_list_spec.rb @@ -138,7 +138,7 @@ feature 'Dashboard Groups page', :js do expect(page).not_to have_selector("#group-#{group.id}") # Go to next page - find(".gl-pagination .page:not(.active) a").trigger('click') + find(".gl-pagination .page:not(.active) a").click wait_for_requests diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb index a8919976c31..5b4c00b3c7e 100644 --- a/spec/features/dashboard/issues_spec.rb +++ b/spec/features/dashboard/issues_spec.rb @@ -33,7 +33,7 @@ RSpec.describe 'Dashboard Issues' do end it 'shows issues when current user is author', :js do - find('#assignee_id', visible: false).set('') + execute_script("document.querySelector('#assignee_id').value=''") find('.js-author-search', match: :first).click expect(find('li[data-user-id="null"] a.is-active')).to be_visible @@ -71,7 +71,7 @@ RSpec.describe 'Dashboard Issues' do describe 'new issue dropdown' do it 'shows projects only with issues feature enabled', :js do - find('.new-project-item-select-button').trigger('click') + find('.new-project-item-select-button').click page.within('.select2-results') do expect(page).to have_content(project.name_with_namespace) @@ -80,7 +80,7 @@ RSpec.describe 'Dashboard Issues' do end it 'shows the new issue page', :js do - find('.new-project-item-select-button').trigger('click') + find('.new-project-item-select-button').click wait_for_requests @@ -93,7 +93,7 @@ RSpec.describe 'Dashboard Issues' do find('#select2-drop-mask', visible: false) execute_script("$('#select2-drop-mask').remove();") - find('.new-project-item-link').trigger('click') + find('.new-project-item-link').click expect(page).to have_current_path("#{project_path}/issues/new") diff --git a/spec/features/dashboard/merge_requests_spec.rb b/spec/features/dashboard/merge_requests_spec.rb index f01ba442e58..991d360ccaf 100644 --- a/spec/features/dashboard/merge_requests_spec.rb +++ b/spec/features/dashboard/merge_requests_spec.rb @@ -25,7 +25,7 @@ feature 'Dashboard Merge Requests' do end it 'shows projects only with merge requests feature enabled', :js do - find('.new-project-item-select-button').trigger('click') + find('.new-project-item-select-button').click page.within('.select2-results') do expect(page).to have_content(project.name_with_namespace) diff --git a/spec/features/dashboard/todos/todos_spec.rb b/spec/features/dashboard/todos/todos_spec.rb index 01aca443f4a..6f916078b1a 100644 --- a/spec/features/dashboard/todos/todos_spec.rb +++ b/spec/features/dashboard/todos/todos_spec.rb @@ -52,7 +52,7 @@ feature 'Dashboard Todos' do end it 'updates todo count' do - expect(page).to have_content 'To do 0' + expect(page).to have_content 'Todos 0' expect(page).to have_content 'Done 1' end @@ -81,7 +81,7 @@ feature 'Dashboard Todos' do end it 'updates todo count' do - expect(page).to have_content 'To do 1' + expect(page).to have_content 'Todos 1' expect(page).to have_content 'Done 0' end end @@ -200,7 +200,7 @@ feature 'Dashboard Todos' do end it 'updates todo count' do - expect(page).to have_content 'To do 1' + expect(page).to have_content 'Todos 1' expect(page).to have_content 'Done 0' end end @@ -252,11 +252,11 @@ feature 'Dashboard Todos' do describe 'mark all as done', :js do before do visit dashboard_todos_path - find('.js-todos-mark-all').trigger('click') + find('.js-todos-mark-all').click end it 'shows "All done" message!' do - expect(page).to have_content 'To do 0' + expect(page).to have_content 'Todos 0' expect(page).to have_content "You're all done!" expect(page).not_to have_selector('.gl-pagination') end @@ -283,7 +283,7 @@ feature 'Dashboard Todos' do it 'updates todo count' do mark_all_and_undo - expect(page).to have_content 'To do 2' + expect(page).to have_content 'Todos 2' expect(page).to have_content 'Done 0' end @@ -309,9 +309,9 @@ feature 'Dashboard Todos' do end def mark_all_and_undo - find('.js-todos-mark-all').trigger('click') + find('.js-todos-mark-all').click wait_for_requests - find('.js-todos-undo-all').trigger('click') + find('.js-todos-undo-all').click wait_for_requests end end diff --git a/spec/features/discussion_comments/commit_spec.rb b/spec/features/discussion_comments/commit_spec.rb index 0375d0bf8ff..69d35cdbc72 100644 --- a/spec/features/discussion_comments/commit_spec.rb +++ b/spec/features/discussion_comments/commit_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Discussion Comments Merge Request', :js do +describe 'Discussion Comments Commit', :js do include RepoHelpers let(:user) { create(:user) } diff --git a/spec/features/discussion_comments/snippets_spec.rb b/spec/features/discussion_comments/snippets_spec.rb index 1e6389d9a13..4a236c4639b 100644 --- a/spec/features/discussion_comments/snippets_spec.rb +++ b/spec/features/discussion_comments/snippets_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Discussion Comments Issue', :js do +describe 'Discussion Comments Snippet', :js do let(:user) { create(:user) } let(:project) { create(:project) } let(:snippet) { create(:project_snippet, :private, project: project, author: user) } diff --git a/spec/features/explore/new_menu_spec.rb b/spec/features/explore/new_menu_spec.rb index c5ec495a418..8d5233d0c0f 100644 --- a/spec/features/explore/new_menu_spec.rb +++ b/spec/features/explore/new_menu_spec.rb @@ -65,9 +65,9 @@ feature 'Top Plus Menu', :js do visit project_path(project) page.within '.header-content' do - find('.header-new-dropdown-toggle').trigger('click') + find('.header-new-dropdown-toggle').click expect(page).to have_selector('.header-new.dropdown.open', count: 1) - find('.header-new-project-snippet a').trigger('click') + find('.header-new-project-snippet a').click end expect(page).to have_content('New Snippet') @@ -87,9 +87,9 @@ feature 'Top Plus Menu', :js do visit group_path(group) page.within '.header-content' do - find('.header-new-dropdown-toggle').trigger('click') + find('.header-new-dropdown-toggle').click expect(page).to have_selector('.header-new.dropdown.open', count: 1) - find('.header-new-group-project a').trigger('click') + find('.header-new-group-project a').click end expect(page).to have_content('Project path') @@ -155,7 +155,7 @@ feature 'Top Plus Menu', :js do def click_topmenuitem(item_name) page.within '.header-content' do - find('.header-new-dropdown-toggle').trigger('click') + find('.header-new-dropdown-toggle').click expect(page).to have_selector('.header-new.dropdown.open', count: 1) click_link item_name end diff --git a/spec/features/groups/milestone_spec.rb b/spec/features/groups/milestone_spec.rb index 12aa54a3da1..1b41b3842c8 100644 --- a/spec/features/groups/milestone_spec.rb +++ b/spec/features/groups/milestone_spec.rb @@ -19,9 +19,9 @@ feature 'Group milestones', :js do end it 'renders description preview' do - form = find('.gfm-form') + description = find('.note-textarea') - form.fill_in(:milestone_description, with: '') + description.native.send_keys('') click_link('Preview') @@ -31,7 +31,7 @@ feature 'Group milestones', :js do click_link('Write') - form.fill_in(:milestone_description, with: ':+1: Nice') + description.native.send_keys(':+1: Nice') click_link('Preview') @@ -51,6 +51,13 @@ feature 'Group milestones', :js do expect(find('.start_date')).to have_content(Date.today.at_beginning_of_month.strftime('%b %-d, %Y')) end + + it 'description input does not support autocomplete' do + description = find('.note-textarea') + description.native.send_keys('!') + + expect(page).not_to have_selector('.atwho-view') + end end context 'milestones list' do diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb index cc8906fa969..c1f3d94bc20 100644 --- a/spec/features/groups_spec.rb +++ b/spec/features/groups_spec.rb @@ -65,7 +65,7 @@ feature 'Group' do end it 'updates the team URL on graph path update', :js do - out_span = find('span[data-bind-out="create_chat_team"]') + out_span = find('span[data-bind-out="create_chat_team"]', visible: false) expect(out_span.text).to be_empty diff --git a/spec/features/issues/bulk_assignment_labels_spec.rb b/spec/features/issues/bulk_assignment_labels_spec.rb index 3223eb20b55..fa4d3a55c62 100644 --- a/spec/features/issues/bulk_assignment_labels_spec.rb +++ b/spec/features/issues/bulk_assignment_labels_spec.rb @@ -405,7 +405,7 @@ feature 'Issues > Labels bulk assignment' do end def update_issues - find('.update-selected-issues').trigger('click') + find('.update-selected-issues').click wait_for_requests end diff --git a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb index 1c4649d0ba9..2e4a25ee15d 100644 --- a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb @@ -43,15 +43,16 @@ describe 'Dropdown assignee', :js do end it 'should show loading indicator when opened' do - filtered_search.set('assignee:') + slow_requests do + filtered_search.set('assignee:') - expect(page).to have_css('#js-dropdown-assignee .filter-dropdown-loading', visible: true) + expect(page).to have_css('#js-dropdown-assignee .filter-dropdown-loading', visible: true) + end end it 'should hide loading indicator when loaded' do filtered_search.set('assignee:') - expect(find(js_dropdown_assignee)).to have_css('.filter-dropdown-loading') expect(find(js_dropdown_assignee)).not_to have_css('.filter-dropdown-loading') end diff --git a/spec/features/issues/filtered_search/dropdown_author_spec.rb b/spec/features/issues/filtered_search/dropdown_author_spec.rb index 5e20fb48768..2fb5e7cdba4 100644 --- a/spec/features/issues/filtered_search/dropdown_author_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_author_spec.rb @@ -51,9 +51,11 @@ describe 'Dropdown author', :js do end it 'should show loading indicator when opened' do - filtered_search.set('author:') + slow_requests do + filtered_search.set('author:') - expect(page).to have_css('#js-dropdown-author .filter-dropdown-loading', visible: true) + expect(page).to have_css('#js-dropdown-author .filter-dropdown-loading', visible: true) + end end it 'should hide loading indicator when loaded' do diff --git a/spec/features/issues/filtered_search/dropdown_emoji_spec.rb b/spec/features/issues/filtered_search/dropdown_emoji_spec.rb index 3012c77f2b9..8db435634fd 100644 --- a/spec/features/issues/filtered_search/dropdown_emoji_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_emoji_spec.rb @@ -70,9 +70,11 @@ describe 'Dropdown emoji', :js do end it 'should show loading indicator when opened' do - filtered_search.set('my-reaction:') + slow_requests do + filtered_search.set('my-reaction:') - expect(page).to have_css('#js-dropdown-my-reaction .filter-dropdown-loading', visible: true) + expect(page).to have_css('#js-dropdown-my-reaction .filter-dropdown-loading', visible: true) + end end it 'should hide loading indicator when loaded' do diff --git a/spec/features/issues/filtered_search/dropdown_label_spec.rb b/spec/features/issues/filtered_search/dropdown_label_spec.rb index cbc4f8d4c50..18cdb199c70 100644 --- a/spec/features/issues/filtered_search/dropdown_label_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_label_spec.rb @@ -66,9 +66,11 @@ describe 'Dropdown label', :js do end it 'shows loading indicator when opened and hides it when loaded' do - filtered_search.set('label:') + slow_requests do + filtered_search.set('label:') - expect(find(js_dropdown_label)).to have_css('.filter-dropdown-loading') + expect(page).to have_css("#{js_dropdown_label} .filter-dropdown-loading", visible: true) + end expect(find(js_dropdown_label)).not_to have_css('.filter-dropdown-loading') end diff --git a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb index f6c2e952bea..031eb06723a 100644 --- a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb @@ -50,15 +50,16 @@ describe 'Dropdown milestone', :js do end it 'should show loading indicator when opened' do - filtered_search.set('milestone:') + slow_requests do + filtered_search.set('milestone:') - expect(page).to have_css('#js-dropdown-milestone .filter-dropdown-loading', visible: true) + expect(page).to have_css('#js-dropdown-milestone .filter-dropdown-loading', visible: true) + end end it 'should hide loading indicator when loaded' do filtered_search.set('milestone:') - expect(find(js_dropdown_milestone)).to have_css('.filter-dropdown-loading') expect(find(js_dropdown_milestone)).not_to have_css('.filter-dropdown-loading') end diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index 2974016c6a7..b3c50964810 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -139,7 +139,7 @@ describe 'Filter issues', :js do input_filtered_search('label:none') expect_tokens([label_token('none', false)]) - expect_issues_list_count(8) + expect_issues_list_count(4) expect_filtered_search_input_empty end diff --git a/spec/features/issues/filtered_search/recent_searches_spec.rb b/spec/features/issues/filtered_search/recent_searches_spec.rb index eef7988e2bd..f355cec3ba9 100644 --- a/spec/features/issues/filtered_search/recent_searches_spec.rb +++ b/spec/features/issues/filtered_search/recent_searches_spec.rb @@ -27,9 +27,8 @@ describe 'Recent searches', :js do input_filtered_search('foo', submit: true) input_filtered_search('bar', submit: true) - items = all('.filtered-search-history-dropdown-item', visible: false) + items = all('.filtered-search-history-dropdown-item', visible: false, count: 2) - expect(items.count).to eq(2) expect(items[0].text).to eq('bar') expect(items[1].text).to eq('foo') end @@ -38,9 +37,8 @@ describe 'Recent searches', :js do visit project_issues_path(project_1, label_name: 'foo', search: 'bar') visit project_issues_path(project_1, label_name: 'qux', search: 'garply') - items = all('.filtered-search-history-dropdown-item', visible: false) + items = all('.filtered-search-history-dropdown-item', visible: false, count: 2) - expect(items.count).to eq(2) expect(items[0].text).to eq('label:~qux garply') expect(items[1].text).to eq('label:~foo bar') end @@ -50,9 +48,8 @@ describe 'Recent searches', :js do visit project_issues_path(project_1, search: 'foo') - items = all('.filtered-search-history-dropdown-item', visible: false) + items = all('.filtered-search-history-dropdown-item', visible: false, count: 3) - expect(items.count).to eq(3) expect(items[0].text).to eq('foo') expect(items[1].text).to eq('saved1') expect(items[2].text).to eq('saved2') @@ -69,9 +66,8 @@ describe 'Recent searches', :js do input_filtered_search('more', submit: true) input_filtered_search('things', submit: true) - items = all('.filtered-search-history-dropdown-item', visible: false) + items = all('.filtered-search-history-dropdown-item', visible: false, count: 2) - expect(items.count).to eq(2) expect(items[0].text).to eq('things') expect(items[1].text).to eq('more') end @@ -80,7 +76,8 @@ describe 'Recent searches', :js do set_recent_searches(project_1_local_storage_key, '["foo", "bar"]') visit project_issues_path(project_1) - all('.filtered-search-history-dropdown-item', visible: false)[0].trigger('click') + find('.filtered-search-history-dropdown-toggle-button').click + all('.filtered-search-history-dropdown-item', count: 2)[0].click wait_for_filtered_search('foo') expect(find('.filtered-search').value.strip).to eq('foo') @@ -90,12 +87,11 @@ describe 'Recent searches', :js do set_recent_searches(project_1_local_storage_key, '["foo"]') visit project_issues_path(project_1) - items_before = all('.filtered-search-history-dropdown-item', visible: false) + find('.filtered-search-history-dropdown-toggle-button').click + all('.filtered-search-history-dropdown-item', count: 1) - expect(items_before.count).to eq(1) - - find('.filtered-search-history-clear-button', visible: false).trigger('click') - items_after = all('.filtered-search-history-dropdown-item', visible: false) + find('.filtered-search-history-clear-button').click + items_after = all('.filtered-search-history-dropdown-item', count: 0) expect(items_after.count).to eq(0) end diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb index 920f5546eef..0ae70c855db 100644 --- a/spec/features/issues/filtered_search/visual_tokens_spec.rb +++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb @@ -2,7 +2,6 @@ require 'rails_helper' describe 'Visual tokens', :js do include FilteredSearchHelpers - include WaitForRequests let!(:project) { create(:project) } let!(:user) { create(:user, name: 'administrator', username: 'root') } @@ -28,7 +27,7 @@ describe 'Visual tokens', :js do sign_in(user) create(:issue, project: project) - page.driver.set_cookie('sidebar_collapsed', 'true') + set_cookie('sidebar_collapsed', 'true') visit project_issues_path(project) end diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb index 8ce470fc288..2db6f9a2982 100644 --- a/spec/features/issues/form_spec.rb +++ b/spec/features/issues/form_spec.rb @@ -218,15 +218,54 @@ describe 'New/edit issue', :js do context 'edit issue' do before do - visit project_issue_path(project, issue) - page.within('.content .issuable-actions') do - click_on 'Edit' + visit edit_project_issue_path(project, issue) + end + + it 'allows user to update issue' do + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user.id.to_s) + expect(find('input[name="issue[milestone_id]"]', visible: false).value).to match(milestone.id.to_s) + expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible + + page.within '.js-user-search' do + expect(page).to have_content user.name + end + + page.within '.js-milestone-select' do + expect(page).to have_content milestone.title + end + + click_button 'Labels' + page.within '.dropdown-menu-labels' do + click_link label.title + click_link label2.title + end + page.within '.js-label-select' do + expect(page).to have_content label.title + end + expect(page.all('input[name="issue[label_ids][]"]', visible: false)[1].value).to match(label.id.to_s) + expect(page.all('input[name="issue[label_ids][]"]', visible: false)[2].value).to match(label2.id.to_s) + + click_button 'Save changes' + + page.within '.issuable-sidebar' do + page.within '.assignee' do + expect(page).to have_content user.name + end + + page.within '.milestone' do + expect(page).to have_content milestone.title + end + + page.within '.labels' do + expect(page).to have_content label.title + expect(page).to have_content label2.title + end end end it 'description has autocomplete' do - find_field('issue-description').native.send_keys('') - fill_in 'issue-description', with: '@' + find('#issue_description').native.send_keys('') + fill_in 'issue_description', with: '@' expect(page).to have_selector('.atwho-view') end diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb index 15041ff04ea..b8a66245153 100644 --- a/spec/features/issues/gfm_autocomplete_spec.rb +++ b/spec/features/issues/gfm_autocomplete_spec.rb @@ -17,9 +17,9 @@ feature 'GFM autocomplete', :js do it 'updates issue descripton with GFM reference' do find('.issuable-edit').click - find('#issue-description').native.send_keys("@#{user.name[0...3]}") + simulate_input('#issue-description', "@#{user.name[0...3]}") - find('.atwho-view .cur').trigger('click') + find('.atwho-view .cur').click click_button 'Save changes' @@ -28,7 +28,6 @@ feature 'GFM autocomplete', :js do it 'opens autocomplete menu when field starts with text' do page.within '.timeline-content-form' do - find('#note-body').native.send_keys('') find('#note-body').native.send_keys('@') end @@ -46,7 +45,6 @@ feature 'GFM autocomplete', :js do it 'doesnt select the first item for non-assignee dropdowns' do page.within '.timeline-content-form' do - find('#note-body').native.send_keys('') find('#note-body').native.send_keys(':') end @@ -86,7 +84,6 @@ feature 'GFM autocomplete', :js do it 'selects the first item for assignee dropdowns' do page.within '.timeline-content-form' do - find('#note-body').native.send_keys('') find('#note-body').native.send_keys('@') end @@ -100,7 +97,7 @@ feature 'GFM autocomplete', :js do it 'includes items for assignee dropdowns with non-ASCII characters in name' do page.within '.timeline-content-form' do find('#note-body').native.send_keys('') - find('#note-body').native.send_keys("@#{user.name[0...8]}") + simulate_input('#note-body', "@#{user.name[0...8]}") end expect(page).to have_selector('.atwho-container') @@ -112,7 +109,6 @@ feature 'GFM autocomplete', :js do it 'selects the first item for non-assignee dropdowns if a query is entered' do page.within '.timeline-content-form' do - find('#note-body').native.send_keys('') find('#note-body').native.send_keys(':1') end @@ -127,9 +123,8 @@ feature 'GFM autocomplete', :js do it 'wraps the result in double quotes' do note = find('#note-body') page.within '.timeline-content-form' do - note.native.send_keys('') - note.native.send_keys("~#{label.title[0]}") - note.click + find('#note-body').native.send_keys('') + simulate_input('#note-body', "~#{label.title[0]}") end label_item = find('.atwho-view li', text: label.title) @@ -152,16 +147,13 @@ feature 'GFM autocomplete', :js do it "does not show dropdown when preceded with a special character" do note = find('#note-body') page.within '.timeline-content-form' do - note.native.send_keys('') note.native.send_keys("@") - note.click end expect(page).to have_selector('.atwho-container') page.within '.timeline-content-form' do note.native.send_keys("@") - note.click end expect(page).to have_selector('.atwho-container', visible: false) @@ -170,9 +162,7 @@ feature 'GFM autocomplete', :js do it "does not throw an error if no labels exist" do note = find('#note-body') page.within '.timeline-content-form' do - note.native.send_keys('') note.native.send_keys('~') - note.click end expect(page).to have_selector('.atwho-container', visible: false) @@ -181,9 +171,7 @@ feature 'GFM autocomplete', :js do it 'doesn\'t wrap for assignee values' do note = find('#note-body') page.within '.timeline-content-form' do - note.native.send_keys('') note.native.send_keys("@#{user.username[0]}") - note.click end user_item = find('.atwho-view li', text: user.username) @@ -194,9 +182,7 @@ feature 'GFM autocomplete', :js do it 'doesn\'t wrap for emoji values' do note = find('#note-body') page.within '.timeline-content-form' do - note.native.send_keys('') - note.native.send_keys(":cartwheel") - note.click + note.native.send_keys(":cartwheel_") end emoji_item = find('.atwho-view li', text: 'cartwheel_tone1') @@ -223,12 +209,11 @@ feature 'GFM autocomplete', :js do it 'triggers autocomplete after selecting a quick action' do note = find('#note-body') page.within '.timeline-content-form' do - note.native.send_keys('') note.native.send_keys('/as') - note.click end - find('.atwho-view li', text: '/assign').native.send_keys(:tab) + find('.atwho-view li', text: '/assign') + note.native.send_keys(:tab) user_item = find('.atwho-view li', text: user.username) expect(user_item).to have_content(user.username) diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index bc9c3d825c1..a9de52bd8d5 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -130,8 +130,8 @@ feature 'Issue Sidebar' do it 'adds new label' do page.within('.block.labels') do fill_in 'new_label_name', with: 'wontfix' - page.find('.suggest-colors a', match: :first).trigger('click') - page.find('button', text: 'Create').trigger('click') + page.find('.suggest-colors a', match: :first).click + page.find('button', text: 'Create').click page.within('.dropdown-page-one') do expect(page).to have_content 'wontfix' @@ -142,8 +142,8 @@ feature 'Issue Sidebar' do it 'shows error message if label title is taken' do page.within('.block.labels') do fill_in 'new_label_name', with: label.title - page.find('.suggest-colors a', match: :first).trigger('click') - page.find('button', text: 'Create').trigger('click') + page.find('.suggest-colors a', match: :first).click + page.find('button', text: 'Create').click page.within('.dropdown-page-two') do expect(page).to have_content 'Title has already been taken' @@ -170,7 +170,7 @@ feature 'Issue Sidebar' do end def open_issue_sidebar - find('aside.right-sidebar.right-sidebar-collapsed .js-sidebar-toggle').trigger('click') + find('aside.right-sidebar.right-sidebar-collapsed .js-sidebar-toggle').click find('aside.right-sidebar.right-sidebar-expanded') end end diff --git a/spec/features/issues/move_spec.rb b/spec/features/issues/move_spec.rb index 6d7b1b1cd8f..17035b5501c 100644 --- a/spec/features/issues/move_spec.rb +++ b/spec/features/issues/move_spec.rb @@ -38,7 +38,7 @@ feature 'issue move to another project' do end scenario 'moving issue to another project', :js do - find('.js-move-issue').trigger('click') + find('.js-move-issue').click wait_for_requests all('.js-move-issue-dropdown-item')[0].click find('.js-move-issue-confirmation-button').click @@ -52,7 +52,7 @@ feature 'issue move to another project' do scenario 'searching project dropdown', :js do new_project_search.team << [user, :reporter] - find('.js-move-issue').trigger('click') + find('.js-move-issue').click wait_for_requests page.within '.js-sidebar-move-issue-block' do @@ -69,7 +69,7 @@ feature 'issue move to another project' do background { another_project.team << [user, :guest] } scenario 'browsing projects in projects select' do - find('.js-move-issue').trigger('click') + find('.js-move-issue').click wait_for_requests page.within '.js-sidebar-move-issue-block' do diff --git a/spec/features/issues/update_issues_spec.rb b/spec/features/issues/update_issues_spec.rb index 1f57c110c11..bcc6e9bab0f 100644 --- a/spec/features/issues/update_issues_spec.rb +++ b/spec/features/issues/update_issues_spec.rb @@ -118,7 +118,7 @@ feature 'Multiple issue updating from issues#index', :js do end def click_update_issues_button - find('.update-selected-issues').trigger('click') + find('.update-selected-issues').click wait_for_requests end end diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb index 9f5e25ff2cb..c4c06ed514b 100644 --- a/spec/features/issues/user_uses_slash_commands_spec.rb +++ b/spec/features/issues/user_uses_slash_commands_spec.rb @@ -226,7 +226,7 @@ feature 'Issues > User uses quick actions', :js do end it 'applies the commands to both issues and moves the issue' do - write_note("/label ~#{bug.title} ~#{wontfix.title}\n/milestone %\"#{milestone.title}\"\n/move #{target_project.full_path}") + write_note("/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"\n\n/move #{target_project.full_path}") expect(page).to have_content 'Commands applied' expect(issue.reload).to be_closed @@ -245,7 +245,7 @@ feature 'Issues > User uses quick actions', :js do end it 'moves the issue and applies the commands to both issues' do - write_note("/move #{target_project.full_path}\n/label ~#{bug.title} ~#{wontfix.title}\n/milestone %\"#{milestone.title}\"") + write_note("/move #{target_project.full_path}\n\n/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"") expect(page).to have_content 'Commands applied' expect(issue.reload).to be_closed diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index 25e99774575..b9af77f918a 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Issues', :js do +describe 'Issues' do include DropzoneHelper include IssueHelpers include SortingHelper @@ -24,15 +24,109 @@ describe 'Issues', :js do end before do - visit project_issue_path(project, issue) - page.within('.content .issuable-actions') do - find('.issuable-edit').click - end - find('.issue-details .content-block .js-zen-enter').click + visit edit_project_issue_path(project, issue) + find('.js-zen-enter').click end it 'opens new issue popup' do - expect(page).to have_content(issue.description) + expect(page).to have_content("Issue ##{issue.iid}") + end + end + + describe 'Editing issue assignee' do + let!(:issue) do + create(:issue, + author: user, + assignees: [user], + project: project) + end + + it 'allows user to select unassigned', :js do + visit edit_project_issue_path(project, issue) + + expect(page).to have_content "Assignee #{user.name}" + + first('.js-user-search').click + click_link 'Unassigned' + + click_button 'Save changes' + + page.within('.assignee') do + expect(page).to have_content 'No assignee - assign yourself' + end + + expect(issue.reload.assignees).to be_empty + end + end + + describe 'due date', :js do + context 'on new form' do + before do + visit new_project_issue_path(project) + end + + it 'saves with due date' do + date = Date.today.at_beginning_of_month + + fill_in 'issue_title', with: 'bug 345' + fill_in 'issue_description', with: 'bug description' + find('#issuable-due-date').click + + page.within '.pika-single' do + click_button date.day + end + + expect(find('#issuable-due-date').value).to eq date.to_s + + click_button 'Submit issue' + + page.within '.issuable-sidebar' do + expect(page).to have_content date.to_s(:medium) + end + end + end + + context 'on edit form' do + let(:issue) { create(:issue, author: user, project: project, due_date: Date.today.at_beginning_of_month.to_s) } + + before do + visit edit_project_issue_path(project, issue) + end + + it 'saves with due date' do + date = Date.today.at_beginning_of_month + + expect(find('#issuable-due-date').value).to eq date.to_s + + date = date.tomorrow + + fill_in 'issue_title', with: 'bug 345' + fill_in 'issue_description', with: 'bug description' + find('#issuable-due-date').click + + page.within '.pika-single' do + click_button date.day + end + + expect(find('#issuable-due-date').value).to eq date.to_s + + click_button 'Save changes' + + page.within '.issuable-sidebar' do + expect(page).to have_content date.to_s(:medium) + end + end + + it 'warns about version conflict' do + issue.update(title: "New title") + + fill_in 'issue_title', with: 'bug 345' + fill_in 'issue_description', with: 'bug description' + + click_button 'Save changes' + + expect(page).to have_content 'Someone edited the issue the same time you did' + end end end @@ -273,7 +367,7 @@ describe 'Issues', :js do it 'changes incoming email address token', :js do find('.issue-email-modal-btn').click previous_token = find('input#issue_email').value - find('.incoming-email-token-reset').trigger('click') + find('.incoming-email-token-reset').click wait_for_requests @@ -489,6 +583,18 @@ describe 'Issues', :js do expect(page.find_field("issue_description").value).not_to match /\n\n$/ end + + it "cancels a file upload correctly" do + slow_requests do + dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false) + + click_button 'Cancel' + end + + expect(page).to have_button('Attach a file') + expect(page).not_to have_button('Cancel') + expect(page).not_to have_selector('.uploading-progress-container', visible: true) + end end context 'form filled by URL parameters' do diff --git a/spec/features/merge_requests/conflicts_spec.rb b/spec/features/merge_requests/conflicts_spec.rb index ba976bc7216..4e2963c116d 100644 --- a/spec/features/merge_requests/conflicts_spec.rb +++ b/spec/features/merge_requests/conflicts_spec.rb @@ -23,11 +23,11 @@ feature 'Merge request conflict resolution', :js do within find('.files-wrapper .diff-file', text: 'files/ruby/regex.rb') do all('button', text: 'Use ours').each do |button| - button.trigger('click') + button.send_keys(:return) end end - click_button 'Commit conflict resolution' + find_button('Commit conflict resolution').send_keys(:return) expect(page).to have_content('All merge conflicts were resolved') merge_request.reload_diff @@ -71,7 +71,7 @@ feature 'Merge request conflict resolution', :js do execute_script('ace.edit($(".files-wrapper .diff-file pre")[1]).setValue("Gregor Samsa woke from troubled dreams");') end - click_button 'Commit conflict resolution' + find_button('Commit conflict resolution').send_keys(:return) expect(page).to have_content('All merge conflicts were resolved') merge_request.reload_diff diff --git a/spec/features/merge_requests/diff_notes_avatars_spec.rb b/spec/features/merge_requests/diff_notes_avatars_spec.rb index 9aa0672feae..9e816cf041b 100644 --- a/spec/features/merge_requests/diff_notes_avatars_spec.rb +++ b/spec/features/merge_requests/diff_notes_avatars_spec.rb @@ -22,7 +22,7 @@ feature 'Diff note avatars', :js do project.team << [user, :master] sign_in user - page.driver.set_cookie('sidebar_collapsed', 'true') + set_cookie('sidebar_collapsed', 'true') end context 'discussion tab' do @@ -56,7 +56,7 @@ feature 'Diff note avatars', :js do end it 'does not render avatar after commenting' do - first('.diff-line-num').trigger('mouseover') + first('.diff-line-num').click find('.js-add-diff-note-button').click page.within('.js-discussion-note-form') do @@ -85,7 +85,7 @@ feature 'Diff note avatars', :js do it 'shows note avatar' do page.within find_line(position.line_code(project.repository)) do - find('.diff-notes-collapse').click + find('.diff-notes-collapse').send_keys(:return) expect(page).to have_selector('img.js-diff-comment-avatar', count: 1) end @@ -93,7 +93,7 @@ feature 'Diff note avatars', :js do it 'shows comment on note avatar' do page.within find_line(position.line_code(project.repository)) do - find('.diff-notes-collapse').click + find('.diff-notes-collapse').send_keys(:return) expect(first('img.js-diff-comment-avatar')["data-original-title"]).to eq("#{note.author.name}: #{note.note.truncate(17)}") end @@ -101,7 +101,7 @@ feature 'Diff note avatars', :js do it 'toggles comments when clicking avatar' do page.within find_line(position.line_code(project.repository)) do - find('.diff-notes-collapse').click + find('.diff-notes-collapse').send_keys(:return) end expect(page).to have_selector('.notes_holder', visible: false) @@ -117,7 +117,7 @@ feature 'Diff note avatars', :js do open_more_actions_dropdown(note) page.within find(".note-row-#{note.id}") do - find('.js-note-delete').click + accept_confirm { find('.js-note-delete').click } end wait_for_requests @@ -139,7 +139,7 @@ feature 'Diff note avatars', :js do end page.within find_line(position.line_code(project.repository)) do - find('.diff-notes-collapse').trigger('click') + find('.diff-notes-collapse').send_keys(:return) expect(page).to have_selector('img.js-diff-comment-avatar', count: 2) end @@ -152,14 +152,14 @@ feature 'Diff note avatars', :js do page.within '.js-discussion-note-form' do find('.js-note-text').native.send_keys('Test') - find('.js-comment-button').trigger('click') + find('.js-comment-button').click wait_for_requests end end page.within find_line(position.line_code(project.repository)) do - find('.diff-notes-collapse').trigger('click') + find('.diff-notes-collapse').send_keys(:return) expect(page).to have_selector('img.js-diff-comment-avatar', count: 3) expect(find('.diff-comments-more-count')).to have_content '+1' @@ -177,7 +177,7 @@ feature 'Diff note avatars', :js do it 'shows extra comment count' do page.within find_line(position.line_code(project.repository)) do - find('.diff-notes-collapse').click + find('.diff-notes-collapse').send_keys(:return) expect(find('.diff-comments-more-count')).to have_content '+1' end diff --git a/spec/features/merge_requests/diff_notes_resolve_spec.rb b/spec/features/merge_requests/diff_notes_resolve_spec.rb index 3db0729cafb..15d380b1bf4 100644 --- a/spec/features/merge_requests/diff_notes_resolve_spec.rb +++ b/spec/features/merge_requests/diff_notes_resolve_spec.rb @@ -192,7 +192,7 @@ feature 'Diff notes resolve', :js do page.find('.discussion-next-btn').click end - expect(page.evaluate_script("$('body').scrollTop()")).to be > 0 + expect(page.evaluate_script("window.pageYOffset")).to be > 0 end it 'hides jump to next button when all resolved' do @@ -241,10 +241,8 @@ feature 'Diff notes resolve', :js do end it 'resolves discussion' do - page.all('.note').each do |note| - note.all('.line-resolve-btn').each do |button| - button.click - end + page.all('.note .line-resolve-btn').each do |button| + button.click end expect(page).to have_content('Resolved by') @@ -305,10 +303,10 @@ feature 'Diff notes resolve', :js do end page.within '.line-resolve-all-container' do - page.find('.discussion-next-btn').trigger('click') + page.find('.discussion-next-btn').click end - expect(page.evaluate_script("$('body').scrollTop()")).to be > 0 + expect(page.evaluate_script("window.pageYOffset")).to be > 0 end it 'updates updated text after resolving note' do diff --git a/spec/features/merge_requests/diffs_spec.rb b/spec/features/merge_requests/diffs_spec.rb index 2adca58620f..1bf77296ae6 100644 --- a/spec/features/merge_requests/diffs_spec.rb +++ b/spec/features/merge_requests/diffs_spec.rb @@ -7,14 +7,12 @@ feature 'Diffs URL', :js do let(:merge_request) { create(:merge_request, source_project: project) } context 'when visit with */* as accept header' do - before do - page.driver.add_header('Accept', '*/*') - end - it 'renders the notes' do create :note_on_merge_request, project: project, noteable: merge_request, note: 'Rebasing with master' - visit diffs_project_merge_request_path(project, merge_request) + inspect_requests(inject_headers: { 'Accept' => '*/*' }) do + visit diffs_project_merge_request_path(project, merge_request) + end # Load notes and diff through AJAX expect(page).to have_css('.note-text', visible: false, text: 'Rebasing with master') @@ -90,7 +88,7 @@ feature 'Diffs URL', :js do visit diffs_project_merge_request_path(project, merge_request) # Throws `Capybara::Poltergeist::InvalidSelector` if we try to use `#hash` syntax - find("[id=\"#{changelog_id}\"] .js-edit-blob").trigger('click') + find("[id=\"#{changelog_id}\"] .js-edit-blob").click expect(page).to have_selector('.js-fork-suggestion-button', count: 1) expect(page).to have_selector('.js-cancel-fork-suggestion-button', count: 1) diff --git a/spec/features/merge_requests/form_spec.rb b/spec/features/merge_requests/form_spec.rb index 758fc9b139d..1dcc1e139a0 100644 --- a/spec/features/merge_requests/form_spec.rb +++ b/spec/features/merge_requests/form_spec.rb @@ -43,7 +43,7 @@ describe 'New/edit merge request', :js do expect(page).to have_content user2.name end - find('a', text: 'Assign to me').trigger('click') + find('a', text: 'Assign to me').click expect(find('input[name="merge_request[assignee_id]"]', visible: false).value).to match(user.id.to_s) page.within '.js-assignee-search' do expect(page).to have_content user.name diff --git a/spec/features/merge_requests/mini_pipeline_graph_spec.rb b/spec/features/merge_requests/mini_pipeline_graph_spec.rb index bf21a719901..bac56270362 100644 --- a/spec/features/merge_requests/mini_pipeline_graph_spec.rb +++ b/spec/features/merge_requests/mini_pipeline_graph_spec.rb @@ -92,7 +92,7 @@ feature 'Mini Pipeline Graph', :js do end it 'should close when toggle is clicked again' do - toggle.trigger('click') + toggle.click expect(toggle.find(:xpath, '..')).not_to have_selector('.mini-pipeline-graph-dropdown-menu') end diff --git a/spec/features/merge_requests/update_merge_requests_spec.rb b/spec/features/merge_requests/update_merge_requests_spec.rb index 1a41fd36a4f..c5498563b39 100644 --- a/spec/features/merge_requests/update_merge_requests_spec.rb +++ b/spec/features/merge_requests/update_merge_requests_spec.rb @@ -127,7 +127,7 @@ feature 'Multiple merge requests updating from merge_requests#index' do end def click_update_merge_requests_button - find('.update-selected-issues').trigger('click') + find('.update-selected-issues').click wait_for_requests end end diff --git a/spec/features/merge_requests/user_posts_diff_notes_spec.rb b/spec/features/merge_requests/user_posts_diff_notes_spec.rb index 7a773fb2baa..d44eb23d7f4 100644 --- a/spec/features/merge_requests/user_posts_diff_notes_spec.rb +++ b/spec/features/merge_requests/user_posts_diff_notes_spec.rb @@ -8,7 +8,7 @@ feature 'Merge requests > User posts diff notes', :js do let(:project) { merge_request.source_project } before do - page.driver.set_cookie('sidebar_collapsed', 'true') + set_cookie('sidebar_collapsed', 'true') project.add_developer(user) sign_in(user) @@ -103,7 +103,10 @@ feature 'Merge requests > User posts diff notes', :js do it 'allows commenting' do should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]')) - first('.js-note-delete', visible: false).trigger('click') + accept_confirm do + first('button.more-actions-toggle').click + first('.js-note-delete').click + end should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]')) end @@ -236,7 +239,7 @@ feature 'Merge requests > User posts diff notes', :js do def should_allow_dismissing_a_comment(line_holder, diff_side = nil) write_comment_on_line(line_holder, diff_side) - find('.js-close-discussion-note-form').trigger('click') + find('.js-close-discussion-note-form').click assert_comment_dismissal(line_holder) end diff --git a/spec/features/merge_requests/user_posts_notes_spec.rb b/spec/features/merge_requests/user_posts_notes_spec.rb index d7cda73ab40..f4c75a2f265 100644 --- a/spec/features/merge_requests/user_posts_notes_spec.rb +++ b/spec/features/merge_requests/user_posts_notes_spec.rb @@ -141,7 +141,7 @@ describe 'Merge requests > User posts notes', :js do end it 'removes the attachment div and resets the edit form' do - find('.js-note-attachment-delete').click + accept_confirm { find('.js-note-attachment-delete').click } is_expected.not_to have_css('.note-attachment') is_expected.not_to have_css('.current-note-edit-form') wait_for_requests diff --git a/spec/features/merge_requests/versions_spec.rb b/spec/features/merge_requests/versions_spec.rb index 50f7d721ff3..29f95039af8 100644 --- a/spec/features/merge_requests/versions_spec.rb +++ b/spec/features/merge_requests/versions_spec.rb @@ -67,8 +67,8 @@ feature 'Merge Request versions', :js do line_code = '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44_2_2' page.within(diff_file_selector) do - find(".line_holder[id='#{line_code}'] td:nth-of-type(1)").trigger 'mouseover' - find(".line_holder[id='#{line_code}'] button").trigger 'click' + find(".line_holder[id='#{line_code}'] td:nth-of-type(1)").hover + find(".line_holder[id='#{line_code}'] button").click page.within("form[data-line-code='#{line_code}']") do fill_in "note[note]", with: "Typo, please fix" @@ -137,8 +137,8 @@ feature 'Merge Request versions', :js do line_code = '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44_4_4' page.within(diff_file_selector) do - find(".line_holder[id='#{line_code}'] td:nth-of-type(1)").trigger 'mouseover' - find(".line_holder[id='#{line_code}'] button").trigger 'click' + find(".line_holder[id='#{line_code}'] td:nth-of-type(1)").hover + find(".line_holder[id='#{line_code}'] button").click page.within("form[data-line-code='#{line_code}']") do fill_in "note[note]", with: "Typo, please fix" diff --git a/spec/features/merge_requests/widget_deployments_spec.rb b/spec/features/merge_requests/widget_deployments_spec.rb index 5658c2c5122..72a52c979b3 100644 --- a/spec/features/merge_requests/widget_deployments_spec.rb +++ b/spec/features/merge_requests/widget_deployments_spec.rb @@ -42,7 +42,7 @@ feature 'Widget Deployments Header', :js do end scenario 'does start build when stop button clicked' do - click_button('Stop environment') + accept_confirm { click_button('Stop environment') } expect(page).to have_content('close_app') end diff --git a/spec/features/profile_spec.rb b/spec/features/profile_spec.rb index 1cddd35fd8a..c60883911f7 100644 --- a/spec/features/profile_spec.rb +++ b/spec/features/profile_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Profile account page' do +describe 'Profile account page', :js do let(:user) { create(:user) } before do @@ -56,47 +56,38 @@ describe 'Profile account page' do end end - describe 'when I reset private token' do - before do - visit profile_account_path - end - - it 'resets private token' do - previous_token = find("#private-token").value - - click_link('Reset private token') - - expect(find('#private-token').value).not_to eq(previous_token) - end - end - describe 'when I reset RSS token' do before do - visit profile_account_path + visit profile_personal_access_tokens_path end it 'resets RSS token' do - previous_token = find("#rss-token").value + within('.rss-token-reset') do + previous_token = find("#rss_token").value - click_link('Reset RSS token') + accept_confirm { click_link('reset it') } + + expect(find('#rss_token').value).not_to eq(previous_token) + end expect(page).to have_content 'RSS token was successfully reset' - expect(find('#rss-token').value).not_to eq(previous_token) end end describe 'when I reset incoming email token' do before do allow(Gitlab.config.incoming_email).to receive(:enabled).and_return(true) - visit profile_account_path + visit profile_personal_access_tokens_path end it 'resets incoming email token' do - previous_token = find('#incoming-email-token').value + within('.incoming-email-token-reset') do + previous_token = find('#incoming_email_token').value - click_link('Reset incoming email token') + accept_confirm { click_link('reset it') } - expect(find('#incoming-email-token').value).not_to eq(previous_token) + expect(find('#incoming_email_token').value).not_to eq(previous_token) + end end end diff --git a/spec/features/profiles/oauth_applications_spec.rb b/spec/features/profiles/oauth_applications_spec.rb index 8cb240077eb..d1edeef8da4 100644 --- a/spec/features/profiles/oauth_applications_spec.rb +++ b/spec/features/profiles/oauth_applications_spec.rb @@ -14,7 +14,7 @@ describe 'Profile > Applications' do page.within('.oauth-applications') do expect(page).to have_content('Your applications (1)') - click_button 'Destroy' + accept_confirm { click_button 'Destroy' } end expect(page).to have_content('The application was deleted successfully') @@ -28,7 +28,7 @@ describe 'Profile > Applications' do page.within('.oauth-authorized-applications') do expect(page).to have_content('Authorized applications (1)') - click_button 'Revoke' + accept_confirm { click_button 'Revoke' } end expect(page).to have_content('The application was revoked access.') diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index a572160dae9..8461cd0027c 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -34,7 +34,7 @@ describe 'Profile > Personal Access Tokens', :js do fill_in "Name", with: name # Set date to 1st of next month - find_field("Expires at").trigger('focus') + find_field("Expires at").click find(".pika-next").click click_on "1" @@ -78,7 +78,7 @@ describe 'Profile > Personal Access Tokens', :js do it "allows revocation of an active token" do visit profile_personal_access_tokens_path - click_on "Revoke" + accept_confirm { click_on "Revoke" } expect(page).to have_selector(".settings-message") expect(no_personal_access_tokens_message).to have_text("This user has no active Personal Access Tokens.") @@ -100,7 +100,7 @@ describe 'Profile > Personal Access Tokens', :js do errors = ActiveModel::Errors.new(PersonalAccessToken.new).tap { |e| e.add(:name, "cannot be nil") } allow_any_instance_of(PersonalAccessToken).to receive(:errors).and_return(errors) - click_on "Revoke" + accept_confirm { click_on "Revoke" } expect(active_personal_access_tokens).to have_text(personal_access_token.name) expect(page).to have_content("Could not revoke") end diff --git a/spec/features/profiles/user_visits_notifications_tab_spec.rb b/spec/features/profiles/user_visits_notifications_tab_spec.rb index 923ca8b1c80..df89918f17a 100644 --- a/spec/features/profiles/user_visits_notifications_tab_spec.rb +++ b/spec/features/profiles/user_visits_notifications_tab_spec.rb @@ -13,7 +13,7 @@ feature 'User visits the notifications tab', :js do it 'changes the project notifications setting' do expect(page).to have_content('Notifications') - first('#notifications-button').trigger('click') + first('#notifications-button').click click_link('On mention') expect(page).to have_content('On mention') diff --git a/spec/features/profiles/user_visits_profile_preferences_page_spec.rb b/spec/features/profiles/user_visits_profile_preferences_page_spec.rb index 924ee0e4174..90d6841af0e 100644 --- a/spec/features/profiles/user_visits_profile_preferences_page_spec.rb +++ b/spec/features/profiles/user_visits_profile_preferences_page_spec.rb @@ -53,7 +53,7 @@ describe 'User visits the profile preferences page' do expect(page).to have_content("You don't have starred projects yet") expect(page.current_path).to eq starred_dashboard_projects_path - find('.shortcuts-activity').trigger('click') + find('.shortcuts-activity').click expect(page).not_to have_content("You don't have starred projects yet") expect(page.current_path).to eq dashboard_projects_path diff --git a/spec/features/projects/artifacts/download_spec.rb b/spec/features/projects/artifacts/download_spec.rb index f1bdb2812c6..6f76c14910b 100644 --- a/spec/features/projects/artifacts/download_spec.rb +++ b/spec/features/projects/artifacts/download_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature 'Download artifact', :js do +feature 'Download artifact' do let(:project) { create(:project, :public) } let(:pipeline) { create(:ci_empty_pipeline, status: :success, project: project) } let(:job) { create(:ci_build, :artifacts, :success, pipeline: pipeline) } diff --git a/spec/features/projects/artifacts/file_spec.rb b/spec/features/projects/artifacts/file_spec.rb index b2be10a7e0c..df1d17bdcb7 100644 --- a/spec/features/projects/artifacts/file_spec.rb +++ b/spec/features/projects/artifacts/file_spec.rb @@ -39,7 +39,6 @@ feature 'Artifact file', :js do context 'JPG file' do before do - page.driver.browser.url_blacklist = [] visit_file('rails_sample.jpg') wait_for_requests diff --git a/spec/features/projects/branches_spec.rb b/spec/features/projects/branches_spec.rb index 941d34dd660..7a77df83034 100644 --- a/spec/features/projects/branches_spec.rb +++ b/spec/features/projects/branches_spec.rb @@ -67,7 +67,7 @@ describe 'Branches' do expect(page).to have_content('fix') expect(find('.all-branches')).to have_selector('li', count: 1) - find('.js-branch-fix .btn-remove').trigger(:click) + accept_confirm { find('.js-branch-fix .btn-remove').click } expect(page).not_to have_content('fix') expect(find('.all-branches')).to have_selector('li', count: 0) diff --git a/spec/features/projects/clusters_spec.rb b/spec/features/projects/clusters_spec.rb index a644f78a1f9..e16b5e7b5f3 100644 --- a/spec/features/projects/clusters_spec.rb +++ b/spec/features/projects/clusters_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' feature 'Clusters', :js do + include GoogleApi::CloudPlatformHelpers + let!(:project) { create(:project, :repository) } let!(:user) { create(:user) } @@ -11,8 +13,10 @@ feature 'Clusters', :js do context 'when user has signed in Google' do before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:validate_token).and_return(true) + allow_any_instance_of(Projects::ClustersController) + .to receive(:token_in_session).and_return('token') + allow_any_instance_of(Projects::ClustersController) + .to receive(:expires_at_in_session).and_return(1.hour.since.to_i.to_s) end context 'when user does not have a cluster and visits cluster index page' do @@ -38,15 +42,15 @@ feature 'Clusters', :js do allow(WaitForClusterCreationWorker).to receive(:perform_in).and_return(nil) - fill_in 'cluster_gcp_project_id', with: 'gcp-project-123' - fill_in 'cluster_gcp_cluster_name', with: 'dev-cluster' + fill_in 'cluster_provider_gcp_attributes_gcp_project_id', with: 'gcp-project-123' + fill_in 'cluster_name', with: 'dev-cluster' click_button 'Create cluster' end it 'user sees a cluster details page and creation status' do expect(page).to have_content('Cluster is being created on Google Container Engine...') - Gcp::Cluster.last.make_created! + Clusters::Cluster.last.provider.make_created! expect(page).to have_content('Cluster was successfully created on Google Container Engine') end @@ -64,7 +68,8 @@ feature 'Clusters', :js do end context 'when user has a cluster and visits cluster index page' do - let!(:cluster) { create(:gcp_cluster, :created_on_gke, :with_kubernetes_service, project: project) } + let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } before do visit project_clusters_path(project) @@ -72,7 +77,7 @@ feature 'Clusters', :js do it 'user sees an cluster details page' do expect(page).to have_button('Save') - expect(page.find(:css, '.cluster-name').value).to eq(cluster.gcp_cluster_name) + expect(page.find(:css, '.cluster-name').value).to eq(cluster.name) end context 'when user disables the cluster' do diff --git a/spec/features/projects/commit/diff_notes_spec.rb b/spec/features/projects/commit/diff_notes_spec.rb index f0fe4e00acc..4dbfc6f6edf 100644 --- a/spec/features/projects/commit/diff_notes_spec.rb +++ b/spec/features/projects/commit/diff_notes_spec.rb @@ -20,8 +20,8 @@ feature 'Commit diff', :js do it "adds comment to diff" do diff_line_num = first('.diff-line-num.new') - diff_line_num.trigger('mouseover') - diff_line_num.find('.js-add-diff-note-button').trigger('click') + diff_line_num.hover + diff_line_num.find('.js-add-diff-note-button').click page.within(first('.diff-viewer')) do find('.js-note-text').set 'test comment' diff --git a/spec/features/projects/deploy_keys_spec.rb b/spec/features/projects/deploy_keys_spec.rb index 2d1a9b931b5..e445758cb5e 100644 --- a/spec/features/projects/deploy_keys_spec.rb +++ b/spec/features/projects/deploy_keys_spec.rb @@ -20,7 +20,7 @@ describe 'Project deploy keys', :js do page.within(find('.deploy-keys')) do expect(page).to have_selector('.deploy-keys li', count: 1) - click_on 'Remove' + accept_confirm { find(:button, text: 'Remove').send_keys(:return) } expect(page).not_to have_selector('.fa-spinner', count: 0) expect(page).to have_selector('.deploy-keys li', count: 0) diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index 0fe1eb4c293..5fc3ba54f65 100644 --- a/spec/features/projects/environments/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -193,12 +193,14 @@ feature 'Environment' do create(:environment, project: project, name: 'staging-1.0/review', state: :available) - - visit folder_project_environments_path(project, id: 'staging-1.0') end it 'renders a correct environment folder' do - expect(page).to have_gitlab_http_status(:ok) + reqs = inspect_requests do + visit folder_project_environments_path(project, id: 'staging-1.0') + end + + expect(reqs.first.status_code).to eq(200) expect(page).to have_content('Environments / staging-1.0') end end diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb index 610f566c0cf..b4eb5795470 100644 --- a/spec/features/projects/environments/environments_spec.rb +++ b/spec/features/projects/environments/environments_spec.rb @@ -151,7 +151,7 @@ feature 'Environments page', :js do find('.js-dropdown-play-icon-container').click expect(page).to have_content(action.name.humanize) - expect { find('.js-manual-action-link').trigger('click') } + expect { find('.js-manual-action-link').click } .not_to change { Ci::Pipeline.count } end diff --git a/spec/features/projects/features_visibility_spec.rb b/spec/features/projects/features_visibility_spec.rb index e5282b42a4f..951456763dc 100644 --- a/spec/features/projects/features_visibility_spec.rb +++ b/spec/features/projects/features_visibility_spec.rb @@ -22,7 +22,7 @@ describe 'Edit Project Settings' do # disable by clicking toggle toggle_feature_off("project[project_feature_attributes][#{tool_name}_access_level]") page.within('.sharing-permissions') do - click_button 'Save changes' + find('input[value="Save changes"]').click end wait_for_requests expect(page).not_to have_selector(".shortcuts-#{shortcut_name}") @@ -30,7 +30,7 @@ describe 'Edit Project Settings' do # re-enable by clicking toggle again toggle_feature_on("project[project_feature_attributes][#{tool_name}_access_level]") page.within('.sharing-permissions') do - click_button 'Save changes' + find('input[value="Save changes"]').click end wait_for_requests expect(page).to have_selector(".shortcuts-#{shortcut_name}") diff --git a/spec/features/projects/files/edit_file_soft_wrap_spec.rb b/spec/features/projects/files/edit_file_soft_wrap_spec.rb index 25f7e18ac5c..3ab43b3c656 100644 --- a/spec/features/projects/files/edit_file_soft_wrap_spec.rb +++ b/spec/features/projects/files/edit_file_soft_wrap_spec.rb @@ -7,18 +7,18 @@ feature 'User uses soft wrap whilst editing file', :js do project.team << [user, :master] sign_in user visit project_new_blob_path(project, 'master', file_name: 'test_file-name') - editor = find('.file-editor.code') - editor.click - editor.send_keys 'Touch water with paw then recoil in horror chase dog then - run away chase the pig around the house eat owner\'s food, and knock - dish off table head butt cant eat out of my own dish. Cat is love, cat - is life rub face on everything poop on grasses so meow. Playing with - balls of wool flee in terror at cucumber discovered on floor run in - circles tuxedo cats always looking dapper, but attack dog, run away - and pretend to be victim so all of a sudden cat goes crazy, yet chase - laser. Make muffins sit in window and stare ooo, a bird! yum lick yarn - hanging out of own butt jump off balcony, onto stranger\'s head yet - chase laser. Purr for no reason stare at ceiling hola te quiero.'.squish + page.within('.file-editor.code') do + find('.ace_text-input', visible: false).send_keys 'Touch water with paw then recoil in horror chase dog then + run away chase the pig around the house eat owner\'s food, and knock + dish off table head butt cant eat out of my own dish. Cat is love, cat + is life rub face on everything poop on grasses so meow. Playing with + balls of wool flee in terror at cucumber discovered on floor run in + circles tuxedo cats always looking dapper, but attack dog, run away + and pretend to be victim so all of a sudden cat goes crazy, yet chase + laser. Make muffins sit in window and stare ooo, a bird! yum lick yarn + hanging out of own butt jump off balcony, onto stranger\'s head yet + chase laser. Purr for no reason stare at ceiling hola te quiero.'.squish + end end let(:toggle_button) { find('.soft-wrap-toggle') } @@ -36,6 +36,6 @@ feature 'User uses soft wrap whilst editing file', :js do end def get_content_width - find('.ace_content')[:style].slice!(/width: \d+/).slice!(/\d+/) + find('.ace_content')[:style].slice!(/width: \d+/).slice!(/\d+/).to_i 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 05776c50f9d..461aa39d0ad 100644 --- a/spec/features/projects/import_export/export_file_spec.rb +++ b/spec/features/projects/import_export/export_file_spec.rb @@ -41,7 +41,7 @@ feature 'Import/Export - project export integration test', :js do expect(page).to have_content('Export project') - click_link 'Export project' + find(:link, 'Export project').send_keys(:return) visit edit_project_path(project) diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb index 026aa03f7cf..af125e1b9d3 100644 --- a/spec/features/projects/import_export/import_file_spec.rb +++ b/spec/features/projects/import_export/import_file_spec.rb @@ -94,6 +94,6 @@ feature 'Import/Export - project import integration test', :js do end def click_import_project_tab - find('#import-project-tab').trigger('click') + find('#import-project-tab').click end end diff --git a/spec/features/projects/import_export/namespace_export_file_spec.rb b/spec/features/projects/import_export/namespace_export_file_spec.rb index b6a7c3cdcdb..e76bc6f1220 100644 --- a/spec/features/projects/import_export/namespace_export_file_spec.rb +++ b/spec/features/projects/import_export/namespace_export_file_spec.rb @@ -52,7 +52,7 @@ feature 'Import/Export - Namespace export file cleanup', :js do expect(page).to have_content('Export project') - click_link 'Export project' + find(:link, 'Export project').send_keys(:return) visit edit_project_path(project) diff --git a/spec/features/projects/jobs/user_browses_job_spec.rb b/spec/features/projects/jobs/user_browses_job_spec.rb index 21c9acc7ac0..5d9208ebadd 100644 --- a/spec/features/projects/jobs/user_browses_job_spec.rb +++ b/spec/features/projects/jobs/user_browses_job_spec.rb @@ -21,12 +21,12 @@ describe 'User browses a job', :js do expect(page).to have_content("Job ##{build.id}") expect(page).to have_css('#build-trace') - click_link('Erase') + accept_confirm { click_link('Erase') } + expect(page).to have_no_css('.artifacts') expect(build).not_to have_trace expect(build.artifacts_file.exists?).to be_falsy expect(build.artifacts_metadata.exists?).to be_falsy - expect(page).to have_no_css('.artifacts') page.within('.erased') do expect(page).to have_content('Job has been erased') diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index b095c3e6f7b..c2a0d2395a9 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -380,7 +380,6 @@ feature 'Jobs' do end it 'loads the page and shows all needed controls' do - expect(page.status_code).to eq(200) expect(page).to have_content 'Retry' end end @@ -392,11 +391,10 @@ feature 'Jobs' do job.run! visit project_job_path(project, job) find('.js-cancel-job').click() - find('.js-retry-button').trigger('click') + find('.js-retry-button').click end it 'shows the right status and buttons', :js do - expect(page).to have_gitlab_http_status(200) page.within('aside.right-sidebar') do expect(page).to have_content 'Cancel' end @@ -443,28 +441,30 @@ feature 'Jobs' do context 'access source' do context 'job from project' do before do - Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' } job.run! - visit project_job_path(project, job) - find('.js-raw-link-controller').click() end it 'sends the right headers' do - expect(page.status_code).to eq(200) - expect(page.response_headers['Content-Type']).to eq('text/plain; charset=utf-8') - expect(page.response_headers['X-Sendfile']).to eq(job.trace.send(:current_path)) + requests = inspect_requests(inject_headers: { 'X-Sendfile-Type' => 'X-Sendfile' }) do + visit raw_project_job_path(project, job) + end + + expect(requests.first.status_code).to eq(200) + expect(requests.first.response_headers['Content-Type']).to eq('text/plain; charset=utf-8') + expect(requests.first.response_headers['X-Sendfile']).to eq(job.trace.send(:current_path)) end end context 'job from other project' do before do - Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' } job2.run! - visit raw_project_job_path(project, job2) end it 'sends the right headers' do - expect(page.status_code).to eq(404) + requests = inspect_requests(inject_headers: { 'X-Sendfile-Type' => 'X-Sendfile' }) do + visit raw_project_job_path(project, job2) + end + expect(requests.first.status_code).to eq(404) end end end @@ -473,8 +473,6 @@ feature 'Jobs' do let(:existing_file) { Tempfile.new('existing-trace-file').path } before do - Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' } - job.run! end @@ -483,16 +481,14 @@ feature 'Jobs' do allow_any_instance_of(Gitlab::Ci::Trace) .to receive(:paths) .and_return([existing_file]) - - visit project_job_path(project, job) - - find('.js-raw-link-controller').click end it 'sends the right headers' do - expect(page.status_code).to eq(200) - expect(page.response_headers['Content-Type']).to eq('text/plain; charset=utf-8') - expect(page.response_headers['X-Sendfile']).to eq(existing_file) + requests = inspect_requests(inject_headers: { 'X-Sendfile-Type' => 'X-Sendfile' }) do + visit raw_project_job_path(project, job) + end + expect(requests.first.response_headers['Content-Type']).to eq('text/plain; charset=utf-8') + expect(requests.first.response_headers['X-Sendfile']).to eq(existing_file) end end diff --git a/spec/features/projects/members/groups_with_access_list_spec.rb b/spec/features/projects/members/groups_with_access_list_spec.rb index b1053982eee..7f067aadec6 100644 --- a/spec/features/projects/members/groups_with_access_list_spec.rb +++ b/spec/features/projects/members/groups_with_access_list_spec.rb @@ -31,6 +31,7 @@ feature 'Projects > Members > Groups with access list', :js do tomorrow = Date.today + 3 fill_in "member_expires_at_#{group.id}", with: tomorrow.strftime("%F") + find('body').click wait_for_requests page.within(find('li.group_member')) do @@ -40,7 +41,7 @@ feature 'Projects > Members > Groups with access list', :js do scenario 'deletes group link' do page.within(first('.group_member')) do - find('.btn-remove').click + accept_confirm { find('.btn-remove').click } end wait_for_requests diff --git a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb index 5f7b4ee0e77..0f88f4cb1e8 100644 --- a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb +++ b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb @@ -20,7 +20,7 @@ feature 'Projects > Members > Master adds member with expiration date', :js do page.within '.users-project-form' do select2(new_member.id, from: '#user_ids', multiple: true) - fill_in 'expires_at', with: date.to_s(:medium) + fill_in 'expires_at', with: date.to_s(:medium) + "\n" click_on 'Add to project' end @@ -37,7 +37,7 @@ feature 'Projects > Members > Master adds member with expiration date', :js do visit project_project_members_path(project) page.within "#project_member_#{new_member.project_members.first.id}" do - find('.js-access-expiration-date').set date.to_s(:medium) + find('.js-access-expiration-date').set date.to_s(:medium) + "\n" wait_for_requests expect(page).to have_content('Expires in 3 days') end diff --git a/spec/features/projects/members/share_with_group_spec.rb b/spec/features/projects/members/share_with_group_spec.rb index 63b5df5a8f5..3198798306c 100644 --- a/spec/features/projects/members/share_with_group_spec.rb +++ b/spec/features/projects/members/share_with_group_spec.rb @@ -41,7 +41,7 @@ feature 'Project > Members > Share with Group', :js do select2 group_to_share_with.id, from: '#link_group_id' page.find('body').click - find('.btn-create').trigger('click') + find('.btn-create').click page.within('.project-members-groups') do expect(page).to have_content(group_to_share_with.name) @@ -123,7 +123,7 @@ feature 'Project > Members > Share with Group', :js do fill_in 'expires_at_groups', with: (Time.now + 4.5.days).strftime('%Y-%m-%d') page.find('body').click - find('.btn-create').trigger('click') + find('.btn-create').click end scenario 'the group link shows the expiration time with a warning class' do diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb index 0fbe1ddb2a5..4eb36156812 100644 --- a/spec/features/projects/members/user_requests_access_spec.rb +++ b/spec/features/projects/members/user_requests_access_spec.rb @@ -60,7 +60,7 @@ feature 'Projects > Members > User requests access', :js do expect(project.requesters.exists?(user_id: user)).to be_truthy - click_link 'Withdraw Access Request' + accept_confirm { click_link 'Withdraw Access Request' } expect(project.requesters.exists?(user_id: user)).to be_falsey expect(page).to have_content 'Your access request to the project has been withdrawn.' diff --git a/spec/features/projects/merge_requests/user_comments_on_diff_spec.rb b/spec/features/projects/merge_requests/user_comments_on_diff_spec.rb index f34302f25f8..e3f90a78cb5 100644 --- a/spec/features/projects/merge_requests/user_comments_on_diff_spec.rb +++ b/spec/features/projects/merge_requests/user_comments_on_diff_spec.rb @@ -31,7 +31,7 @@ describe 'User comments on a diff', :js do page.within('.files > div:nth-child(3)') do expect(page).to have_content('Line is wrong') - find('.js-toggle-diff-comments').trigger('click') + find('.js-toggle-diff-comments').click expect(page).not_to have_content('Line is wrong') end @@ -64,7 +64,7 @@ describe 'User comments on a diff', :js do # Hide the comment. page.within('.files > div:nth-child(3)') do - find('.js-toggle-diff-comments').trigger('click') + find('.js-toggle-diff-comments').click expect(page).not_to have_content('Line is wrong') end @@ -77,7 +77,7 @@ describe 'User comments on a diff', :js do # Show the comment. page.within('.files > div:nth-child(3)') do - find('.js-toggle-diff-comments').trigger('click') + find('.js-toggle-diff-comments').click end # Now both the comments should be shown. @@ -90,6 +90,7 @@ describe 'User comments on a diff', :js do end # Check the same comments in the side-by-side view. + execute_script("window.scrollTo(0,0);") click_link('Side-by-side') wait_for_requests @@ -153,11 +154,11 @@ describe 'User comments on a diff', :js do find('.more-actions').click find('.more-actions .dropdown-menu li', match: :first) - find('.js-note-delete').click + accept_confirm { find('.js-note-delete').click } end page.within('.merge-request-tabs') do - find('.notes-tab').trigger('click') + find('.notes-tab').click end wait_for_requests diff --git a/spec/features/projects/merge_requests/user_edits_merge_request_spec.rb b/spec/features/projects/merge_requests/user_edits_merge_request_spec.rb index f6e3997383f..3d19a2923b9 100644 --- a/spec/features/projects/merge_requests/user_edits_merge_request_spec.rb +++ b/spec/features/projects/merge_requests/user_edits_merge_request_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe 'User edits a merge request', :js do + include Select2Helper + let(:project) { create(:project, :repository) } let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } let(:user) { create(:user) } @@ -15,8 +17,7 @@ describe 'User edits a merge request', :js do it 'changes the target branch' do expect(page).to have_content('Target branch') - first('.target_branch').click - select('merge-test', from: 'merge_request_target_branch', visible: false) + select2('merge-test', from: '#merge_request_target_branch') click_button('Save changes') expect(page).to have_content("Request to merge #{merge_request.source_branch} into merge-test") diff --git a/spec/features/projects/merge_requests/user_manages_subscription_spec.rb b/spec/features/projects/merge_requests/user_manages_subscription_spec.rb index 30a80f8e652..4ca435491cb 100644 --- a/spec/features/projects/merge_requests/user_manages_subscription_spec.rb +++ b/spec/features/projects/merge_requests/user_manages_subscription_spec.rb @@ -13,7 +13,7 @@ describe 'User manages subscription', :js do end it 'toggles subscription' do - subscribe_button = find('.issuable-subscribe-button span') + subscribe_button = find('.js-issuable-subscribe-button') expect(subscribe_button).to have_content('Subscribe') diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb index 8e11cb94350..6f097ad16c7 100644 --- a/spec/features/projects/new_project_spec.rb +++ b/spec/features/projects/new_project_spec.rb @@ -15,7 +15,7 @@ feature 'New project' do expect(page).to have_content('Project path') expect(page).to have_content('Project name') - find('#import-project-tab').trigger('click') + find('#import-project-tab').click expect(page).to have_link('GitHub') expect(page).to have_link('Bitbucket') @@ -137,7 +137,7 @@ feature 'New project' do context 'Import project options', :js do before do visit new_project_path - find('#import-project-tab').trigger('click') + find('#import-project-tab').click end context 'from git repository url' do diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb index 24b335a7068..fa2f7a1fd78 100644 --- a/spec/features/projects/pipeline_schedules_spec.rb +++ b/spec/features/projects/pipeline_schedules_spec.rb @@ -54,7 +54,7 @@ feature 'Pipeline Schedules', :js do end it 'deletes the pipeline' do - click_link 'Delete' + accept_confirm { click_link 'Delete' } expect(page).not_to have_css(".pipeline-schedule-table-row") end diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index acbc5b046e6..b8fa1a54c24 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -67,13 +67,13 @@ describe 'Pipeline', :js do it 'shows a running icon and a cancel action for the running build' do page.within('#ci-badge-deploy') do expect(page).to have_selector('.js-ci-status-icon-running') - expect(page).to have_selector('.js-icon-action-cancel') + expect(page).to have_selector('.js-icon-cancel') expect(page).to have_content('deploy') end end it 'should be possible to cancel the running build' do - find('#ci-badge-deploy .ci-action-icon-container').trigger('click') + find('#ci-badge-deploy .ci-action-icon-container').click expect(page).not_to have_content('Cancel running') end @@ -86,13 +86,13 @@ describe 'Pipeline', :js do expect(page).to have_content('build') end - page.within('#ci-badge-build .ci-action-icon-container') do - expect(page).to have_selector('.js-icon-action-retry') + page.within('#ci-badge-build .ci-action-icon-container.js-icon-retry') do + expect(page).to have_selector('svg') end end it 'should be possible to retry the success job' do - find('#ci-badge-build .ci-action-icon-container').trigger('click') + find('#ci-badge-build .ci-action-icon-container').click expect(page).not_to have_content('Retry job') end @@ -105,13 +105,13 @@ describe 'Pipeline', :js do expect(page).to have_content('test') end - page.within('#ci-badge-test .ci-action-icon-container') do - expect(page).to have_selector('.js-icon-action-retry') + page.within('#ci-badge-test .ci-action-icon-container.js-icon-retry') do + expect(page).to have_selector('svg') end end it 'should be possible to retry the failed build' do - find('#ci-badge-test .ci-action-icon-container').trigger('click') + find('#ci-badge-test .ci-action-icon-container').click expect(page).not_to have_content('Retry job') end @@ -124,13 +124,13 @@ describe 'Pipeline', :js do expect(page).to have_content('manual') end - page.within('#ci-badge-manual-build .ci-action-icon-container') do - expect(page).to have_selector('.js-icon-action-play') + page.within('#ci-badge-manual-build .ci-action-icon-container.js-icon-play') do + expect(page).to have_selector('svg') end end it 'should be possible to play the manual job' do - find('#ci-badge-manual-build .ci-action-icon-container').trigger('click') + find('#ci-badge-manual-build .ci-action-icon-container').click expect(page).not_to have_content('Play job') end @@ -165,7 +165,7 @@ describe 'Pipeline', :js do context 'when retrying' do before do - find('.js-retry-button').trigger('click') + find('.js-retry-button').click end it { expect(page).not_to have_content('Retry') } @@ -231,7 +231,7 @@ describe 'Pipeline', :js do context 'when retrying' do before do - find('.js-retry-button').trigger('click') + find('.js-retry-button').click end it { expect(page).not_to have_content('Retry') } diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index ae888fd4343..fc689bbb486 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -103,7 +103,7 @@ describe 'Pipelines', :js do context 'when canceling' do before do - find('.js-pipelines-cancel-button').click + accept_confirm { find('.js-pipelines-cancel-button').click } wait_for_requests end @@ -232,7 +232,7 @@ describe 'Pipelines', :js do context 'when canceling' do before do - find('.js-pipelines-cancel-button').trigger('click') + accept_alert { find('.js-pipelines-cancel-button').click } end it 'indicates that pipeline was canceled' do @@ -345,14 +345,14 @@ describe 'Pipelines', :js do context 'when clicking a stage badge' do it 'should open a dropdown' do - find('.js-builds-dropdown-button').trigger('click') + find('.js-builds-dropdown-button').click expect(page).to have_link build.name end it 'should be possible to cancel pending build' do - find('.js-builds-dropdown-button').trigger('click') - find('a.js-ci-action-icon').trigger('click') + find('.js-builds-dropdown-button').click + find('a.js-ci-action-icon').click expect(page).to have_content('canceled') expect(build.reload).to be_canceled @@ -361,11 +361,16 @@ describe 'Pipelines', :js do context 'dropdown jobs list' do it 'should keep the dropdown open when the user ctr/cmd + clicks in the job name' do - find('.js-builds-dropdown-button').trigger('click') - - execute_script('var e = $.Event("keydown", { keyCode: 64 }); $("body").trigger(e);') - - find('.mini-pipeline-graph-dropdown-item').trigger('click') + find('.js-builds-dropdown-button').click + dropdown_item = find('.mini-pipeline-graph-dropdown-item').native + + %i(alt control).each do |meta_key| + page.driver.browser.action + .key_down(meta_key) + .click(dropdown_item) + .key_up(meta_key) + .perform + end expect(page).to have_selector('.js-ci-action-icon') end @@ -525,7 +530,6 @@ describe 'Pipelines', :js do let(:project) { create(:project, :public, :repository) } it { expect(page).to have_content 'Build with confidence' } - it { expect(page).to have_gitlab_http_status(:success) } end context 'when project is private' do diff --git a/spec/features/projects/ref_switcher_spec.rb b/spec/features/projects/ref_switcher_spec.rb index 50c0bfd580d..33ccbc1a29f 100644 --- a/spec/features/projects/ref_switcher_spec.rb +++ b/spec/features/projects/ref_switcher_spec.rb @@ -6,7 +6,7 @@ feature 'Ref switcher', :js do before do project.team << [user, :master] - page.driver.set_cookie('new_repo', 'true') + set_cookie('new_repo', 'true') sign_in(user) visit project_tree_path(project, 'master') end diff --git a/spec/features/projects/services/user_activates_jira_spec.rb b/spec/features/projects/services/user_activates_jira_spec.rb index 0a86292ae6c..ac78b1dfb1c 100644 --- a/spec/features/projects/services/user_activates_jira_spec.rb +++ b/spec/features/projects/services/user_activates_jira_spec.rb @@ -65,7 +65,7 @@ describe 'User activates Jira', :js do expect(find('.flash-container-page')).to have_content 'Test failed. message' expect(find('.flash-container-page')).to have_content 'Save anyway' - find('.flash-alert .flash-action').trigger('click') + find('.flash-alert .flash-action').click wait_for_requests expect(page).to have_content('JIRA activated.') diff --git a/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb b/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb index 95d5e8b14b9..6f057137867 100644 --- a/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb +++ b/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb @@ -76,7 +76,7 @@ feature 'Setup Mattermost slash commands', :js do select_element = find('#mattermost_team_id') selected_option = select_element.find('option[selected]') - expect(select_element['disabled']).to be(true) + expect(select_element['disabled']).to eq("true") expect(selected_option).to have_content(team_name.to_s) end @@ -104,7 +104,7 @@ feature 'Setup Mattermost slash commands', :js do select_element = find('#mattermost_team_id') - expect(select_element['disabled']).to be(false) + expect(select_element['disabled']).to be_falsey expect(select_element.all('option').count).to eq(3) end @@ -122,7 +122,7 @@ feature 'Setup Mattermost slash commands', :js do click_link 'Add to Mattermost' - expect(find('input[type="submit"]')['disabled']).not_to be(true) + expect(find('input[type="submit"]')['disabled']).not_to eq("true") end it 'disables the submit button if the required fields are not provided', :js do @@ -132,7 +132,7 @@ feature 'Setup Mattermost slash commands', :js do fill_in('mattermost_trigger', with: '') - expect(find('input[type="submit"]')['disabled']).to be(true) + expect(find('input[type="submit"]')['disabled']).to eq("true") end def stub_teams(count: 0) diff --git a/spec/features/projects/services/user_activates_packagist_spec.rb b/spec/features/projects/services/user_activates_packagist_spec.rb new file mode 100644 index 00000000000..b0cc818f093 --- /dev/null +++ b/spec/features/projects/services/user_activates_packagist_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe 'User activates Packagist' do + let(:project) { create(:project) } + let(:user) { create(:user) } + + before do + project.add_master(user) + sign_in(user) + + visit(project_settings_integrations_path(project)) + + click_link('Packagist') + end + + it 'activates service' do + check('Active') + fill_in('Username', with: 'theUser') + fill_in('Token', with: 'verySecret') + click_button('Save') + + expect(page).to have_content('Packagist activated.') + end +end diff --git a/spec/features/projects/services/user_views_services_spec.rb b/spec/features/projects/services/user_views_services_spec.rb index f86591c2633..5c5e8b66642 100644 --- a/spec/features/projects/services/user_views_services_spec.rb +++ b/spec/features/projects/services/user_views_services_spec.rb @@ -21,5 +21,6 @@ describe 'User views services' do expect(page).to have_content('JetBrains TeamCity') expect(page).to have_content('Asana') expect(page).to have_content('Irker (IRC gateway)') + expect(page).to have_content('Packagist') end end diff --git a/spec/features/projects/settings/forked_project_settings_spec.rb b/spec/features/projects/settings/forked_project_settings_spec.rb new file mode 100644 index 00000000000..28954a4fb40 --- /dev/null +++ b/spec/features/projects/settings/forked_project_settings_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +feature 'Settings for a forked project', :js do + include ProjectForksHelper + let(:user) { create(:user) } + let(:original_project) { create(:project) } + let(:forked_project) { fork_project(original_project, user) } + + before do + original_project.add_master(user) + forked_project.add_master(user) + sign_in(user) + end + + shared_examples 'project settings for a forked projects' do + it 'allows deleting the link to the forked project' do + visit edit_project_path(forked_project) + + click_button 'Remove fork relationship' + + wait_for_requests + + fill_in('confirm_name_input', with: forked_project.name) + click_button('Confirm') + + expect(page).to have_content('The fork relationship has been removed.') + expect(forked_project.reload.forked?).to be_falsy + end + end + + it_behaves_like 'project settings for a forked projects' + + context 'when the original project is deleted' do + before do + original_project.destroy! + end + + it_behaves_like 'project settings for a forked projects' + end +end diff --git a/spec/features/projects/settings/merge_requests_settings_spec.rb b/spec/features/projects/settings/merge_requests_settings_spec.rb index b1ec556bf16..ac76c30cc7c 100644 --- a/spec/features/projects/settings/merge_requests_settings_spec.rb +++ b/spec/features/projects/settings/merge_requests_settings_spec.rb @@ -21,7 +21,7 @@ feature 'Project settings > Merge Requests', :js do within('.sharing-permissions-form') do find('.project-feature-controls[data-for="project[project_feature_attributes][merge_requests_access_level]"] .project-feature-toggle').click - click_on('Save changes') + find('input[value="Save changes"]').send_keys(:return) end expect(page).not_to have_content('Only allow merge requests to be merged if the pipeline succeeds') @@ -41,7 +41,7 @@ feature 'Project settings > Merge Requests', :js do within('.sharing-permissions-form') do find('.project-feature-controls[data-for="project[project_feature_attributes][builds_access_level]"] .project-feature-toggle').click - click_on('Save changes') + find('input[value="Save changes"]').send_keys(:return) end expect(page).to have_content('Only allow merge requests to be merged if the pipeline succeeds') @@ -62,7 +62,7 @@ feature 'Project settings > Merge Requests', :js do within('.sharing-permissions-form') do find('.project-feature-controls[data-for="project[project_feature_attributes][merge_requests_access_level]"] .project-feature-toggle').click - click_on('Save changes') + find('input[value="Save changes"]').send_keys(:return) end expect(page).to have_content('Only allow merge requests to be merged if the pipeline succeeds') diff --git a/spec/features/projects/settings/pipelines_settings_spec.rb b/spec/features/projects/settings/pipelines_settings_spec.rb index de8fbb15b9c..ea8f997409d 100644 --- a/spec/features/projects/settings/pipelines_settings_spec.rb +++ b/spec/features/projects/settings/pipelines_settings_spec.rb @@ -22,7 +22,7 @@ feature "Pipelines settings" do context 'for master' do given(:role) { :master } - scenario 'be allowed to change', :js do + scenario 'be allowed to change' do fill_in('Test coverage parsing', with: 'coverage_regex') click_on 'Save changes' diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb index a4fefb0d0e7..e2a5619c22b 100644 --- a/spec/features/projects/settings/repository_settings_spec.rb +++ b/spec/features/projects/settings/repository_settings_spec.rb @@ -34,7 +34,6 @@ feature 'Repository settings' do visit project_settings_repository_path(project) - expect(page.status_code).to eq(200) expect(page).to have_content('private_deploy_key') expect(page).to have_content('public_deploy_key') end @@ -86,7 +85,7 @@ feature 'Repository settings' do project.deploy_keys << private_deploy_key visit project_settings_repository_path(project) - find('li', text: private_deploy_key.title).click_button('Remove') + accept_confirm { find('li', text: private_deploy_key.title).click_button('Remove') } expect(page).not_to have_content(private_deploy_key.title) end diff --git a/spec/features/projects/snippets/create_snippet_spec.rb b/spec/features/projects/snippets/create_snippet_spec.rb index 3e79dba3f19..e4215291f99 100644 --- a/spec/features/projects/snippets/create_snippet_spec.rb +++ b/spec/features/projects/snippets/create_snippet_spec.rb @@ -10,7 +10,7 @@ feature 'Create Snippet', :js do fill_in 'project_snippet_title', with: 'My Snippet Title' fill_in 'project_snippet_description', with: 'My Snippet **Description**' page.within('.file-editor') do - find('.ace_editor').native.send_keys('Hello World!') + find('.ace_text-input', visible: false).send_keys('Hello World!') end end @@ -59,7 +59,7 @@ feature 'Create Snippet', :js do fill_form dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif') - click_button('Create snippet') + find("input[value='Create snippet']").send_keys(:return) wait_for_requests expect(page).to have_content('My Snippet Title') diff --git a/spec/features/projects/tree/create_directory_spec.rb b/spec/features/projects/tree/create_directory_spec.rb index 4c1fa5a666e..8ee7b9cf015 100644 --- a/spec/features/projects/tree/create_directory_spec.rb +++ b/spec/features/projects/tree/create_directory_spec.rb @@ -1,8 +1,6 @@ require 'spec_helper' feature 'Multi-file editor new directory', :js do - include WaitForRequests - let(:user) { create(:user) } let(:project) { create(:project, :repository) } @@ -10,7 +8,7 @@ feature 'Multi-file editor new directory', :js do project.add_master(user) sign_in(user) - page.driver.set_cookie('new_repo', 'true') + set_cookie('new_repo', 'true') visit project_tree_path(project, :master) @@ -32,7 +30,6 @@ feature 'Multi-file editor new directory', :js do click_button('Commit 1 file') - expect(page).to have_content('Your changes have been committed') expect(page).to have_selector('td', text: 'commit message') click_link('foldername') diff --git a/spec/features/projects/tree/create_file_spec.rb b/spec/features/projects/tree/create_file_spec.rb index a67ec891e7c..1e2de0711b8 100644 --- a/spec/features/projects/tree/create_file_spec.rb +++ b/spec/features/projects/tree/create_file_spec.rb @@ -1,8 +1,6 @@ require 'spec_helper' feature 'Multi-file editor new file', :js do - include WaitForRequests - let(:user) { create(:user) } let(:project) { create(:project, :repository) } @@ -10,7 +8,7 @@ feature 'Multi-file editor new file', :js do project.add_master(user) sign_in(user) - page.driver.set_cookie('new_repo', 'true') + set_cookie('new_repo', 'true') visit project_tree_path(project, :master) @@ -28,13 +26,10 @@ feature 'Multi-file editor new file', :js do click_button('Create file') end - find('.inputarea').send_keys('file content') - fill_in('commit-message', with: 'commit message') click_button('Commit 1 file') - expect(page).to have_content('Your changes have been committed') expect(page).to have_selector('td', text: 'commit message') end end diff --git a/spec/features/projects/tree/upload_file_spec.rb b/spec/features/projects/tree/upload_file_spec.rb new file mode 100644 index 00000000000..8439bb5a69e --- /dev/null +++ b/spec/features/projects/tree/upload_file_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +feature 'Multi-file editor upload file', :js do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + let(:txt_file) { File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt') } + let(:img_file) { File.join(Rails.root, 'spec', 'fixtures', 'dk.png') } + + before do + project.add_master(user) + sign_in(user) + + set_cookie('new_repo', 'true') + + visit project_tree_path(project, :master) + + wait_for_requests + end + + it 'uploads text file' do + find('.add-to-tree').click + + # make the field visible so capybara can use it + execute_script('document.querySelector("#file-upload").classList.remove("hidden")') + attach_file('file-upload', txt_file) + + find('.add-to-tree').click + + expect(page).to have_selector('.repo-tab', text: 'doc_sample.txt') + expect(find('.blob-editor-container .lines-content')['innerText']).to have_content(File.open(txt_file, &:readline)) + end + + it 'uploads image file' do + find('.add-to-tree').click + + # make the field visible so capybara can use it + execute_script('document.querySelector("#file-upload").classList.remove("hidden")') + attach_file('file-upload', img_file) + + find('.add-to-tree').click + + expect(page).to have_selector('.repo-tab', text: 'dk.png') + expect(page).not_to have_selector('.monaco-editor') + expect(page).to have_content('The source could not be displayed for this temporary file.') + end +end diff --git a/spec/features/projects/user_browses_files_spec.rb b/spec/features/projects/user_browses_files_spec.rb index f43b11c9485..f5e4d7f5130 100644 --- a/spec/features/projects/user_browses_files_spec.rb +++ b/spec/features/projects/user_browses_files_spec.rb @@ -175,10 +175,11 @@ describe 'User browses files' do page.within('#modal-upload-blob') do fill_in(:commit_message, with: 'New commit message') + fill_in(:branch_name, with: 'new_branch_name', visible: true) + click_button('Upload file') end - fill_in(:branch_name, with: 'new_branch_name', visible: true) - click_button('Upload file') + wait_for_all_requests visit(project_blob_path(project, 'new_branch_name/logo_sample.svg')) diff --git a/spec/features/projects/wiki/markdown_preview_spec.rb b/spec/features/projects/wiki/markdown_preview_spec.rb index d63cbe578d8..337baaf4dcd 100644 --- a/spec/features/projects/wiki/markdown_preview_spec.rb +++ b/spec/features/projects/wiki/markdown_preview_spec.rb @@ -18,13 +18,13 @@ feature 'Projects > Wiki > User previews markdown changes', :js do sign_in(user) visit project_path(project) - find('.shortcuts-wiki').trigger('click') + find('.shortcuts-wiki').click end context "while creating a new wiki page" do context "when there are no spaces or hyphens in the page name" do it "rewrites relative links as expected" do - find('.add-new-wiki').trigger('click') + find('.add-new-wiki').click page.within '#modal-new-wiki' do fill_in :new_wiki_path, with: 'a/b/c/d' click_button 'Create page' @@ -91,7 +91,7 @@ feature 'Projects > Wiki > User previews markdown changes', :js do context "while editing a wiki page" do def create_wiki_page(path) - find('.add-new-wiki').trigger('click') + find('.add-new-wiki').click page.within '#modal-new-wiki' do fill_in :new_wiki_path, with: path diff --git a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb index e72b7dc0dd5..4a9d1cb87e1 100644 --- a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb @@ -77,14 +77,14 @@ describe 'User creates wiki page' do [stem] ++++ - \sqrt{4} = 2 + \\sqrt{4} = 2 ++++ another part [latexmath] ++++ - \beta_x \gamma + \\beta_x \\gamma ++++ stem:[2+2] is 4 diff --git a/spec/features/projects/wiki/user_views_wiki_page_spec.rb b/spec/features/projects/wiki/user_views_wiki_page_spec.rb index 470391dc66b..ff325aeadd3 100644 --- a/spec/features/projects/wiki/user_views_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_views_wiki_page_spec.rb @@ -81,10 +81,15 @@ describe 'User views a wiki page' do end it 'shows a file stored in a page' do - file = Gollum::File.new(project.wiki) + gollum_file_double = double('Gollum::File', + mime_type: 'image/jpeg', + name: 'images/image.jpg', + path: 'images/image.jpg', + raw_data: '') + wiki_file = Gitlab::Git::WikiFile.new(gollum_file_double) - allow_any_instance_of(Gollum::Wiki).to receive(:file).with('image.jpg', 'master').and_return(file) - allow_any_instance_of(Gollum::File).to receive(:mime_type).and_return('image/jpeg') + allow(wiki_file).to receive(:mime_type).and_return('image/jpeg') + allow_any_instance_of(ProjectWiki).to receive(:find_file).with('image.jpg', nil).and_return(wiki_file) expect(page).to have_xpath('//img[@data-src="image.jpg"]') expect(page).to have_link('image', href: "#{project.wiki.wiki_base_path}/image.jpg") @@ -133,7 +138,7 @@ describe 'User views a wiki page' do it 'opens a default wiki page', :js do visit(project_path(project)) - find('.shortcuts-wiki').trigger('click') + find('.shortcuts-wiki').click expect(page).to have_content('Home · Create Page') end diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index 3b01ed442bf..63e6051b571 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -13,8 +13,8 @@ feature 'Project' do end it "allows creation from templates", :js do - find('#create-from-template-tab').trigger('click') - find("##{template.name}").trigger('click') + find('#create-from-template-tab').click + find("label[for=#{template.name}]").click fill_in("project_path", with: template.name) page.within '#content-body' do diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb index 2ab1eda90f1..a4084818284 100644 --- a/spec/features/protected_branches_spec.rb +++ b/spec/features/protected_branches_spec.rb @@ -48,7 +48,7 @@ feature 'Protected Branches', :js do expect(page).to have_content('fix') expect(find('.all-branches')).to have_selector('li', count: 1) - page.find('[data-target="#modal-delete-branch"]').trigger(:click) + page.find('[data-target="#modal-delete-branch"]').click expect(page).to have_css('.js-delete-branch[disabled]') fill_in 'delete_branch_input', with: 'fix' @@ -67,9 +67,9 @@ feature 'Protected Branches', :js do form = '.js-new-protected-branch' within form do - find(".js-allowed-to-merge").trigger('click') + find(".js-allowed-to-merge").click click_link 'No one' - find(".js-allowed-to-push").trigger('click') + find(".js-allowed-to-push").click click_link 'Developers + Masters' end @@ -171,7 +171,7 @@ feature 'Protected Branches', :js do end def set_protected_branch_name(branch_name) - find(".js-protected-branch-select").trigger('click') + find(".js-protected-branch-select").click find(".dropdown-input-field").set(branch_name) click_on("Create wildcard #{branch_name}") end diff --git a/spec/features/raven_js_spec.rb b/spec/features/raven_js_spec.rb index b1f51959d54..74890c86047 100644 --- a/spec/features/raven_js_spec.rb +++ b/spec/features/raven_js_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature 'RavenJS', :js do +feature 'RavenJS' do let(:raven_path) { '/raven.bundle.js' } it 'should not load raven if sentry is disabled' do @@ -18,6 +18,8 @@ feature 'RavenJS', :js do end def has_requested_raven - page.driver.network_traffic.one? {|request| request.url.end_with?(raven_path)} + page.all('script', visible: false).one? do |elm| + elm[:src] =~ /#{raven_path}$/ + end end end diff --git a/spec/features/search/user_searches_for_code_spec.rb b/spec/features/search/user_searches_for_code_spec.rb index 0ed797a62ea..77212fb105b 100644 --- a/spec/features/search/user_searches_for_code_spec.rb +++ b/spec/features/search/user_searches_for_code_spec.rb @@ -32,14 +32,14 @@ describe 'User searches for code' do include_examples 'top right search form' it 'finds code' do - find('.js-search-project-dropdown').trigger('click') + find('.js-search-project-dropdown').click page.within('.project-filter') do click_link(project.name_with_namespace) end fill_in('dashboard_search', with: 'rspec') - find('.btn-search').trigger('click') + find('.btn-search').click page.within('.results') do expect(find(:css, '.search-results')).to have_content('Update capybara, rspec-rails, poltergeist to recent versions') diff --git a/spec/features/search/user_searches_for_issues_spec.rb b/spec/features/search/user_searches_for_issues_spec.rb index 630a81b1c5e..ef9553f2a91 100644 --- a/spec/features/search/user_searches_for_issues_spec.rb +++ b/spec/features/search/user_searches_for_issues_spec.rb @@ -18,7 +18,7 @@ describe 'User searches for issues', :js do it 'finds an issue' do fill_in('dashboard_search', with: issue1.title) - find('.btn-search').trigger('click') + find('.btn-search').click page.within('.search-filter') do click_link('Issues') @@ -31,14 +31,14 @@ describe 'User searches for issues', :js do context 'when on a project page' do it 'finds an issue' do - find('.js-search-project-dropdown').trigger('click') + find('.js-search-project-dropdown').click page.within('.project-filter') do click_link(project.name_with_namespace) end fill_in('dashboard_search', with: issue1.title) - find('.btn-search').trigger('click') + find('.btn-search').click page.within('.search-filter') do click_link('Issues') @@ -62,7 +62,7 @@ describe 'User searches for issues', :js do it 'finds an issue' do fill_in('dashboard_search', with: issue1.title) - find('.btn-search').trigger('click') + find('.btn-search').click page.within('.search-filter') do click_link('Issues') diff --git a/spec/features/search/user_searches_for_merge_requests_spec.rb b/spec/features/search/user_searches_for_merge_requests_spec.rb index 116256682f4..3b6739aecbd 100644 --- a/spec/features/search/user_searches_for_merge_requests_spec.rb +++ b/spec/features/search/user_searches_for_merge_requests_spec.rb @@ -17,7 +17,7 @@ describe 'User searches for merge requests', :js do it 'finds a merge request' do fill_in('dashboard_search', with: merge_request1.title) - find('.btn-search').trigger('click') + find('.btn-search').click page.within('.search-filter') do click_link('Merge requests') @@ -30,14 +30,14 @@ describe 'User searches for merge requests', :js do context 'when on a project page' do it 'finds a merge request' do - find('.js-search-project-dropdown').trigger('click') + find('.js-search-project-dropdown').click page.within('.project-filter') do click_link(project.name_with_namespace) end fill_in('dashboard_search', with: merge_request1.title) - find('.btn-search').trigger('click') + find('.btn-search').click page.within('.search-filter') do click_link('Merge requests') diff --git a/spec/features/search/user_searches_for_milestones_spec.rb b/spec/features/search/user_searches_for_milestones_spec.rb index 4fa9fe9ce8c..6e197aee498 100644 --- a/spec/features/search/user_searches_for_milestones_spec.rb +++ b/spec/features/search/user_searches_for_milestones_spec.rb @@ -17,7 +17,7 @@ describe 'User searches for milestones', :js do it 'finds a milestone' do fill_in('dashboard_search', with: milestone1.title) - find('.btn-search').trigger('click') + find('.btn-search').click page.within('.search-filter') do click_link('Milestones') @@ -30,14 +30,14 @@ describe 'User searches for milestones', :js do context 'when on a project page' do it 'finds a milestone' do - find('.js-search-project-dropdown').trigger('click') + find('.js-search-project-dropdown').click page.within('.project-filter') do click_link(project.name_with_namespace) end fill_in('dashboard_search', with: milestone1.title) - find('.btn-search').trigger('click') + find('.btn-search').click page.within('.search-filter') do click_link('Milestones') diff --git a/spec/features/search/user_searches_for_wiki_pages_spec.rb b/spec/features/search/user_searches_for_wiki_pages_spec.rb index 1ea56479ecc..00af625dc86 100644 --- a/spec/features/search/user_searches_for_wiki_pages_spec.rb +++ b/spec/features/search/user_searches_for_wiki_pages_spec.rb @@ -15,14 +15,14 @@ describe 'User searches for wiki pages', :js do include_examples 'top right search form' it 'finds a page' do - find('.js-search-project-dropdown').trigger('click') + find('.js-search-project-dropdown').click page.within('.project-filter') do click_link(project.name_with_namespace) end fill_in('dashboard_search', with: 'content') - find('.btn-search').trigger('click') + find('.btn-search').click page.within('.search-filter') do click_link('Wiki') diff --git a/spec/features/search/user_uses_search_filters_spec.rb b/spec/features/search/user_uses_search_filters_spec.rb index 95f3eb5e805..aa883c964d2 100644 --- a/spec/features/search/user_uses_search_filters_spec.rb +++ b/spec/features/search/user_uses_search_filters_spec.rb @@ -16,7 +16,7 @@ describe 'User uses search filters', :js do context' when filtering by group' do it 'shows group projects' do - find('.js-search-group-dropdown').trigger('click') + find('.js-search-group-dropdown').click wait_for_requests @@ -27,7 +27,7 @@ describe 'User uses search filters', :js do expect(find('.js-search-group-dropdown')).to have_content(group.name) page.within('.project-filter') do - find('.js-search-project-dropdown').trigger('click') + find('.js-search-project-dropdown').click wait_for_requests @@ -39,7 +39,7 @@ describe 'User uses search filters', :js do context' when filtering by project' do it 'shows a project' do page.within('.project-filter') do - find('.js-search-project-dropdown').trigger('click') + find('.js-search-project-dropdown').click wait_for_requests diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index d70cf1527e7..a7928857b7d 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -181,6 +181,21 @@ describe "Internal Project Access" do it { is_expected.to be_denied_for(:visitor) } end + describe "GET /:project_path/issues/:id/edit" do + let(:issue) { create(:issue, project: project) } + subject { edit_project_issue_path(project, issue) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_allowed_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + end + describe "GET /:project_path/snippets" do subject { project_snippets_path(project) } diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index ea130606545..a4396b20afd 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -181,6 +181,21 @@ describe "Private Project Access" do it { is_expected.to be_denied_for(:visitor) } end + describe "GET /:project_path/issues/:id/edit" do + let(:issue) { create(:issue, project: project) } + subject { edit_project_issue_path(project, issue) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_allowed_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + end + describe "GET /:project_path/snippets" do subject { project_snippets_path(project) } diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index d15f5af66c9..fccdeb0e5b7 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -394,6 +394,21 @@ describe "Public Project Access" do it { is_expected.to be_allowed_for(:visitor) } end + describe "GET /:project_path/issues/:id/edit" do + let(:issue) { create(:issue, project: project) } + subject { edit_project_issue_path(project, issue) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_allowed_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + end + describe "GET /:project_path/snippets" do subject { project_snippets_path(project) } diff --git a/spec/features/snippets/notes_on_personal_snippets_spec.rb b/spec/features/snippets/notes_on_personal_snippets_spec.rb index bf79974b8c6..269351e55c9 100644 --- a/spec/features/snippets/notes_on_personal_snippets_spec.rb +++ b/spec/features/snippets/notes_on_personal_snippets_spec.rb @@ -74,24 +74,21 @@ describe 'Comments on personal snippets', :js do it 'should not have autocomplete' do wait_for_requests - request_count_before = page.driver.network_traffic.count find('#note_note').native.send_keys('') fill_in 'note[note]', with: '@' wait_for_requests - request_count_after = page.driver.network_traffic.count # This selector probably won't be in place even if autocomplete was enabled # but we want to make sure expect(page).not_to have_selector('.atwho-view') - expect(request_count_before).to eq(request_count_after) end end context 'when editing a note' do it 'changes the text' do - find('.js-note-edit').trigger('click') + find('.js-note-edit').click page.within('.current-note-edit-form') do fill_in 'note[note]', with: 'new content' @@ -113,7 +110,7 @@ describe 'Comments on personal snippets', :js do open_more_actions_dropdown(snippet_notes[0]) page.within("#notes-list li#note_#{snippet_notes[0].id}") do - click_on 'Delete comment' + accept_confirm { click_on 'Delete comment' } end wait_for_requests diff --git a/spec/features/snippets/user_creates_snippet_spec.rb b/spec/features/snippets/user_creates_snippet_spec.rb index d732383a1e1..941765b7578 100644 --- a/spec/features/snippets/user_creates_snippet_spec.rb +++ b/spec/features/snippets/user_creates_snippet_spec.rb @@ -14,7 +14,7 @@ feature 'User creates snippet', :js do fill_in 'personal_snippet_title', with: 'My Snippet Title' fill_in 'personal_snippet_description', with: 'My Snippet **Description**' page.within('.file-editor') do - find('.ace_editor').native.send_keys 'Hello World!' + find('.ace_text-input', visible: false).send_keys 'Hello World!' end end @@ -43,8 +43,8 @@ feature 'User creates snippet', :js do link = find('a.no-attachment-icon img[alt="banana_sample"]')['src'] expect(link).to match(%r{/uploads/-/system/temp/\h{32}/banana_sample\.gif\z}) - visit(link) - expect(page.status_code).to eq(200) + reqs = inspect_requests { visit(link) } + expect(reqs.first.status_code).to eq(200) end end @@ -61,8 +61,8 @@ feature 'User creates snippet', :js do link = find('a.no-attachment-icon img[alt="banana_sample"]')['src'] expect(link).to match(%r{/uploads/-/system/personal_snippet/#{Snippet.last.id}/\h{32}/banana_sample\.gif\z}) - visit(link) - expect(page.status_code).to eq(200) + reqs = inspect_requests { visit(link) } + expect(reqs.first.status_code).to eq(200) end scenario 'validation fails for the first time' do @@ -86,15 +86,15 @@ feature 'User creates snippet', :js do link = find('a.no-attachment-icon img[alt="banana_sample"]')['src'] expect(link).to match(%r{/uploads/-/system/personal_snippet/#{Snippet.last.id}/\h{32}/banana_sample\.gif\z}) - visit(link) - expect(page.status_code).to eq(200) + reqs = inspect_requests { visit(link) } + expect(reqs.first.status_code).to eq(200) end scenario 'Authenticated user creates a snippet with + in filename' do fill_in 'personal_snippet_title', with: 'My Snippet Title' page.within('.file-editor') do find(:xpath, "//input[@id='personal_snippet_file_name']").set 'snippet+file+name' - find('.ace_editor').native.send_keys 'Hello World!' + find('.ace_text-input', visible: false).send_keys 'Hello World!' end click_button 'Create snippet' diff --git a/spec/features/tags/master_creates_tag_spec.rb b/spec/features/tags/master_creates_tag_spec.rb index 1455345bd56..1f8bd8d681e 100644 --- a/spec/features/tags/master_creates_tag_spec.rb +++ b/spec/features/tags/master_creates_tag_spec.rb @@ -63,7 +63,7 @@ feature 'Master creates tag' do expect(ref_input.value).to eq 'master' expect(find('.dropdown-toggle-text')).to have_content 'master' - find('.js-branch-select').trigger('click') + find('.js-branch-select').click expect(find('.dropdown-menu')).to have_content 'empty-branch' end diff --git a/spec/features/tags/master_deletes_tag_spec.rb b/spec/features/tags/master_deletes_tag_spec.rb index f5b3774122b..dfda664d673 100644 --- a/spec/features/tags/master_deletes_tag_spec.rb +++ b/spec/features/tags/master_deletes_tag_spec.rb @@ -64,7 +64,7 @@ feature 'Master deletes tag' do def delete_first_tag page.within('.content') do - first('.btn-remove').click + accept_confirm { first('.btn-remove').click } end end end diff --git a/spec/features/triggers_spec.rb b/spec/features/triggers_spec.rb index 548d8372a07..bc472e74997 100644 --- a/spec/features/triggers_spec.rb +++ b/spec/features/triggers_spec.rb @@ -45,7 +45,7 @@ feature 'Triggers', :js do visit project_settings_ci_cd_path(@project) # See if edit page has correct descrption - find('a[title="Edit"]').click + find('a[title="Edit"]').send_keys(:return) expect(page.find('#trigger_description').value).to have_content 'trigger desc' end @@ -54,7 +54,7 @@ feature 'Triggers', :js do visit project_settings_ci_cd_path(@project) # See if edit page opens, then fill in new description and save - find('a[title="Edit"]').click + find('a[title="Edit"]').send_keys(:return) fill_in 'trigger_description', with: new_trigger_title click_button 'Save trigger' @@ -70,7 +70,7 @@ feature 'Triggers', :js do visit project_settings_ci_cd_path(@project) # See if the trigger can be edited and description is blank - find('a[title="Edit"]').click + find('a[title="Edit"]').send_keys(:return) expect(page.find('#trigger_description').value).to have_content '' # See if trigger can be updated with description and saved successfully @@ -94,12 +94,13 @@ feature 'Triggers', :js do scenario 'take trigger ownership' do # See if "Take ownership" on trigger works post trigger creation - find('a.btn-trigger-take-ownership').click page.accept_confirm do - expect(page.find('.flash-notice')).to have_content 'Trigger was re-assigned.' - expect(page.find('.triggers-list')).to have_content trigger_title - expect(page.find('.triggers-list .trigger-owner')).to have_content user.name + first(:link, "Take ownership").send_keys(:return) end + + expect(page.find('.flash-notice')).to have_content 'Trigger was re-assigned.' + expect(page.find('.triggers-list')).to have_content trigger_title + expect(page.find('.triggers-list .trigger-owner')).to have_content user.name end end @@ -116,11 +117,12 @@ feature 'Triggers', :js do scenario 'revoke trigger' do # See if "Revoke" on trigger works post trigger creation - find('a.btn-trigger-revoke').click page.accept_confirm do - expect(page.find('.flash-notice')).to have_content 'Trigger removed' - expect(page).to have_selector('p.settings-message.text-center.append-bottom-default') + find('a.btn-trigger-revoke').send_keys(:return) end + + expect(page.find('.flash-notice')).to have_content 'Trigger removed' + expect(page).to have_selector('p.settings-message.text-center.append-bottom-default') end end diff --git a/spec/features/u2f_spec.rb b/spec/features/u2f_spec.rb index f3662cb184f..c9afef2a8de 100644 --- a/spec/features/u2f_spec.rb +++ b/spec/features/u2f_spec.rb @@ -79,7 +79,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do first_u2f_device = register_u2f_device second_u2f_device = register_u2f_device(name: 'My other device') - click_on "Delete", match: :first + accept_confirm { click_on "Delete", match: :first } expect(page).to have_content('Successfully deleted') expect(page.body).not_to match(first_u2f_device.name) @@ -162,7 +162,6 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do @u2f_device.respond_to_u2f_authentication - expect(page).to have_content('We heard back from your U2F device') expect(page).to have_css('.sign-out-link', visible: false) end end @@ -174,23 +173,10 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do @u2f_device.respond_to_u2f_authentication - expect(page).to have_content('We heard back from your U2F device') expect(page).to have_css('.sign-out-link', visible: false) end end - it 'persists remember_me value via hidden field' do - gitlab_sign_in(user, remember: true) - - @u2f_device.respond_to_u2f_authentication - expect(page).to have_content('We heard back from your U2F device') - - within 'div#js-authenticate-u2f' do - field = first('input#user_remember_me', visible: false) - expect(field.value).to eq '1' - end - end - describe "when a given U2F device has already been registered by another user" do describe "but not the current user" do it "does not allow logging in with that particular device" do @@ -205,7 +191,6 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do # Try authenticating user with the old U2F device gitlab_sign_in(current_user) @u2f_device.respond_to_u2f_authentication - expect(page).to have_content('We heard back from your U2F device') expect(page).to have_content('Authentication via U2F device failed') end end @@ -223,7 +208,6 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do # Try authenticating user with the same U2F device gitlab_sign_in(current_user) @u2f_device.respond_to_u2f_authentication - expect(page).to have_content('We heard back from your U2F device') expect(page).to have_css('.sign-out-link', visible: false) end @@ -235,7 +219,6 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do unregistered_device = FakeU2fDevice.new(page, 'My device') gitlab_sign_in(user) unregistered_device.respond_to_u2f_authentication - expect(page).to have_content('We heard back from your U2F device') expect(page).to have_content('Authentication via U2F device failed') end @@ -260,7 +243,6 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do [first_device, second_device].each do |device| gitlab_sign_in(user) device.respond_to_u2f_authentication - expect(page).to have_content('We heard back from your U2F device') expect(page).to have_css('.sign-out-link', visible: false) @@ -283,7 +265,9 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do it "deletes u2f registrations" do visit profile_account_path - expect { click_on "Disable" }.to change { U2fRegistration.count }.by(-1) + expect do + accept_confirm { click_on "Disable" } + end.to change { U2fRegistration.count }.by(-1) end end end diff --git a/spec/features/uploads/user_uploads_file_to_note_spec.rb b/spec/features/uploads/user_uploads_file_to_note_spec.rb index 1261ffdc2ee..972c10aaf23 100644 --- a/spec/features/uploads/user_uploads_file_to_note_spec.rb +++ b/spec/features/uploads/user_uploads_file_to_note_spec.rb @@ -21,16 +21,12 @@ feature 'User uploads file to note' do end context 'uploading is in progress' do - it 'shows "Cancel" button on uploading', :js do - dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false) - - expect(page).to have_button('Cancel') - end - it 'cancels uploading on clicking to "Cancel" button', :js do - dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false) + slow_requests do + dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false) - click_button 'Cancel' + click_button 'Cancel' + end expect(page).to have_button('Attach a file') expect(page).not_to have_button('Cancel') @@ -38,16 +34,20 @@ feature 'User uploads file to note' do end it 'shows "Attaching a file" message on uploading 1 file', :js do - dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false) + slow_requests do + dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false) - expect(page).to have_selector('.attaching-file-message', visible: true, text: 'Attaching a file -') + expect(page).to have_selector('.attaching-file-message', visible: true, text: 'Attaching a file -') + end end it 'shows "Attaching 2 files" message on uploading 2 file', :js do - dropzone_file([Rails.root.join('spec', 'fixtures', 'video_sample.mp4'), - Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false) + slow_requests do + dropzone_file([Rails.root.join('spec', 'fixtures', 'video_sample.mp4'), + Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false) - expect(page).to have_selector('.attaching-file-message', visible: true, text: 'Attaching 2 files -') + expect(page).to have_selector('.attaching-file-message', visible: true, text: 'Attaching 2 files -') + end end it 'shows error message, "retry" and "attach a new file" link a if file is too big', :js do diff --git a/spec/features/users_spec.rb b/spec/features/users_spec.rb index 0252c957c95..a9973cdf214 100644 --- a/spec/features/users_spec.rb +++ b/spec/features/users_spec.rb @@ -24,6 +24,7 @@ feature 'Users', :js do user.reload expect(user.reset_password_token).not_to be_nil + find('a[href="#login-pane"]').click gitlab_sign_in(user) expect(current_path).to eq root_path diff --git a/spec/features/variables_spec.rb b/spec/features/variables_spec.rb index 5d8e818f7bf..c78f7d0d9be 100644 --- a/spec/features/variables_spec.rb +++ b/spec/features/variables_spec.rb @@ -82,7 +82,7 @@ describe 'Project variables', :js do it 'deletes variable' do page.within('.variables-table') do - click_on 'Remove' + accept_confirm { click_on 'Remove' } end expect(page).not_to have_selector('variables-table') diff --git a/spec/fixtures/api/schemas/entities/issue.json b/spec/fixtures/api/schemas/entities/issue.json new file mode 100644 index 00000000000..3d3329a3406 --- /dev/null +++ b/spec/fixtures/api/schemas/entities/issue.json @@ -0,0 +1,44 @@ +{ + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "author_id": { "type": "integer" }, + "description": { "type": ["string", "null"] }, + "lock_version": { "type": ["string", "null"] }, + "milestone_id": { "type": ["string", "null"] }, + "title": { "type": "string" }, + "moved_to_id": { "type": ["integer", "null"] }, + "project_id": { "type": "integer" }, + "web_url": { "type": "string" }, + "state": { "type": "string" }, + "create_note_path": { "type": "string" }, + "preview_note_path": { "type": "string" }, + "current_user": { + "type": "object", + "properties": { + "can_create_note": { "type": "boolean" }, + "can_update": { "type": "boolean" } + } + }, + "created_at": { "type": "date-time" }, + "updated_at": { "type": "date-time" }, + "branch_name": { "type": ["string", "null"] }, + "due_date": { "type": "date" }, + "confidential": { "type": "boolean" }, + "discussion_locked": { "type": ["boolean", "null"] }, + "updated_by_id": { "type": ["string", "null"] }, + "deleted_at": { "type": ["string", "null"] }, + "time_estimate": { "type": "integer" }, + "total_time_spent": { "type": "integer" }, + "human_time_estimate": { "type": ["integer", "null"] }, + "human_total_time_spent": { "type": ["integer", "null"] }, + "milestone": { "type": ["object", "null"] }, + "labels": { + "type": "array", + "items": { "$ref": "label.json" } + }, + "assignees": { "type": ["array", "null"] } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/entities/issue_sidebar.json b/spec/fixtures/api/schemas/entities/issue_sidebar.json new file mode 100644 index 00000000000..682e345d5f5 --- /dev/null +++ b/spec/fixtures/api/schemas/entities/issue_sidebar.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "subscribed": { "type": "boolean" }, + "time_estimate": { "type": "integer" }, + "total_time_spent": { "type": "integer" }, + "human_time_estimate": { "type": ["integer", "null"] }, + "human_total_time_spent": { "type": ["integer", "null"] }, + "participants": { + "type": "array", + "items": { "$ref": "../public_api/v4/user/basic.json" } + }, + "assignees": { + "type": "array", + "items": { "$ref": "../public_api/v4/user/basic.json" } + } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/entities/label.json b/spec/fixtures/api/schemas/entities/label.json new file mode 100644 index 00000000000..40dff764c17 --- /dev/null +++ b/spec/fixtures/api/schemas/entities/label.json @@ -0,0 +1,26 @@ +{ + "type": "object", + "required": [ + "id", + "color", + "description", + "title", + "priority" + ], + "properties": { + "id": { "type": "integer" }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{3}{1,2}$" + }, + "description": { "type": ["string", "null"] }, + "text_color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{3}{1,2}$" + }, + "type": { "type": "string" }, + "title": { "type": "string" }, + "priority": { "type": ["integer", "null"] } + }, + "additionalProperties": false +}
\ No newline at end of file diff --git a/spec/fixtures/api/schemas/entities/merge_request_basic.json b/spec/fixtures/api/schemas/entities/merge_request_basic.json index 6b14188582a..995f13381ad 100644 --- a/spec/fixtures/api/schemas/entities/merge_request_basic.json +++ b/spec/fixtures/api/schemas/entities/merge_request_basic.json @@ -9,7 +9,9 @@ "human_time_estimate": { "type": ["string", "null"] }, "human_total_time_spent": { "type": ["string", "null"] }, "merge_error": { "type": ["string", "null"] }, - "assignee_id": { "type": ["integer", "null"] } + "assignee_id": { "type": ["integer", "null"] }, + "subscribed": { "type": ["boolean", "null"] }, + "participants": { "type": "array" } }, "additionalProperties": false } diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json index e1f62508933..a55ecaa5697 100644 --- a/spec/fixtures/api/schemas/issue.json +++ b/spec/fixtures/api/schemas/issue.json @@ -19,32 +19,7 @@ }, "labels": { "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "color", - "description", - "title", - "priority" - ], - "properties": { - "id": { "type": "integer" }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$" - }, - "description": { "type": ["string", "null"] }, - "text_color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$" - }, - "type": { "type": "string" }, - "title": { "type": "string" }, - "priority": { "type": ["integer", "null"] } - }, - "additionalProperties": false - } + "items": { "$ref": "entities/label.json" } }, "assignee": { "id": { "type": "integet" }, diff --git a/spec/fixtures/api/schemas/public_api/v4/user/login.json b/spec/fixtures/api/schemas/public_api/v4/user/login.json index e6c1d9c9d84..aa066883c47 100644 --- a/spec/fixtures/api/schemas/public_api/v4/user/login.json +++ b/spec/fixtures/api/schemas/public_api/v4/user/login.json @@ -27,11 +27,9 @@ "can_create_group", "can_create_project", "two_factor_enabled", - "external", - "private_token" + "external" ], "properties": { - "$ref": "full.json", - "private_token": { "type": "string" } + "$ref": "full.json" } } diff --git a/spec/fixtures/clusters/sample_cert.pem b/spec/fixtures/clusters/sample_cert.pem new file mode 100644 index 00000000000..e39a2b34416 --- /dev/null +++ b/spec/fixtures/clusters/sample_cert.pem @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFtTCCA52gAwIBAgIJAOutg3Kf2y5dMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTcxMDI5MTgxOTU3WhcNMTgxMDI5MTgxOTU3WjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC +CgKCAgEAvQysroM3TLxaavadSPnFIltrYnxCnU4PvCR8971HMWXsq7Z4ShU4BbbE +8yp7oUFjulSwW6DhdIvnQb8ihLKictLmrA0isQqrD/iNpKZ6/lI4DGWw4QzrvMnW +V4yy2QZNpg9tzQHd4+xkeeIoG23RijDU/sPd5dqxF+rPHBfCVInmYvSzLvMhneNj +Bt6gV02gU9e9hsnMatsDvEbvWKp7wcbPot0nWrfZulx2QAWyXy+zG9mJQUds6yc0 +4agAeT9JEb/xtRgR/kS0aUHSGnfSnhZiEn17s0PhTmbu7qSHgzgB+7oJrC9jPoUh +S2Wo3n0xykAjHrA8wC/Ddw3L38S41VQ58GEfNchistPswyMmXo/Oenv9P3s/kCOI +fndiksFNdqVo51y9Vjngj589hpOseFDyKmWPIEQZ9kxW/crjP6RZWWLHgz26KtxZ +uJaoYL8VBbYfrk/bucw0Ma2GEOp8rTsBE7SvgejXZa78q+381Kzc/utW6VwSXqzY +xeIitft0rXi17SZ+XoiTkIXtHn0ZwMtOXNDBADTpFmKa6wVACQilvcpOYD8gUHyH +pB+EDRdST3M4Fiq1MBAVhk8Lj3tHSJ/1ymeF1PWSu57AnJlzerzq2fcfPotNNd37 +ZPNkPh0kxPLwxbAyrHflzx9qVVdI1irY9055mNSnhzlec4qJ9cECAwEAAaOBpzCB +pDAdBgNVHQ4EFgQUnVa5dYPoIG/3+qXml0bX8+N16GwwdQYDVR0jBG4wbIAUnVa5 +dYPoIG/3+qXml0bX8+N16GyhSaRHMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpT +b21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGSCCQDr +rYNyn9suXTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQAUg4cyxXi1 +VR8ejTpaAruRyJ1pEG9Kc3kiIRXODy60z3hJXnx9LkScPkWGiuL5XacfZ2rMd4bw +oVXIyi8U1UHWfAH8EZdrFKkU92jCiL5soHUONxLAvQEJ/FTR/qijrpzLCxXBdVQE +xFEDWUu6rxLFyjEwzwnRTLgpjR606fdb7qXHkuAMvZ/ezJj8j97hok3Odpn4lr2H +6hMTpK7HmDBX+kmdJJ+yBrm9hG1Pzpl7QU0dkxZ+qJNFjYMLnziiTwkv0c5ZaA9E +NykZUcOv3Sjb6spu1A/E2BSq4WTjkIjrogFlfimE1vmUmObTRJOqUB0Vky1kHEwN +pg7QqIJQmof1EAIaSM/YpUWXyumBwGLDUEud1JUz05In9Q4IZjEwZSJwbQW4fUia +A93m9rk3Lw3xsFcaUdPMFIXk0rPoF1IgmV/oqb0gK95lOWRLbN+AV8qpKPpcKXOc +TkIdFE47ZisEDhIdF6wC1izEMLeMEsPAO7/Y6MY4nRxsinSe95lRaw+yQpzx+mvJ +Q7n1kiHI9Pd5M3+CiQda0d/GO1o5ORJnUGJRvr9HKuNmE7Lif0As/N0AlywjzE7A +6Z8AEiWyRV1ffshu1k2UKmzvZuZeGGKRtrIjbJIRAtpRVtVZZGzhq5/sojCLoJ+u +texqFBUo/4mFRZa4pDItUdyOlDy2/LO/ag== +-----END CERTIFICATE----- diff --git a/spec/helpers/ci_status_helper_spec.rb b/spec/helpers/ci_status_helper_spec.rb index 6a3945c0ebc..bc2422aba90 100644 --- a/spec/helpers/ci_status_helper_spec.rb +++ b/spec/helpers/ci_status_helper_spec.rb @@ -8,17 +8,13 @@ describe CiStatusHelper do describe '#ci_icon_for_status' do it 'renders to correct svg on success' do - expect(helper).to receive(:render) - .with('shared/icons/icon_status_success.svg', anything) - - helper.ci_icon_for_status(success_commit.status) + expect(helper.ci_icon_for_status('success').to_s) + .to include 'status_success' end it 'renders the correct svg on failure' do - expect(helper).to receive(:render) - .with('shared/icons/icon_status_failed.svg', anything) - - helper.ci_icon_for_status(failed_commit.status) + expect(helper.ci_icon_for_status('failed').to_s) + .to include 'status_failed' end end diff --git a/spec/helpers/gitlab_routing_helper_spec.rb b/spec/helpers/gitlab_routing_helper_spec.rb index a44b200c5da..6c4f7050ee0 100644 --- a/spec/helpers/gitlab_routing_helper_spec.rb +++ b/spec/helpers/gitlab_routing_helper_spec.rb @@ -63,4 +63,30 @@ describe GitlabRoutingHelper do it { expect(resend_invite_group_member_path(group_member)).to eq resend_invite_group_group_member_path(group_member.source, group_member) } end end + + describe '#preview_markdown_path' do + let(:project) { create(:project) } + + it 'returns group preview markdown path for a group parent' do + group = create(:group) + + expect(preview_markdown_path(group)).to eq("/groups/#{group.path}/preview_markdown") + end + + it 'returns project preview markdown path for a project parent' do + expect(preview_markdown_path(project)).to eq("/#{project.full_path}/preview_markdown") + end + + it 'returns snippet preview markdown path for a personal snippet' do + @snippet = create(:personal_snippet) + + expect(preview_markdown_path(nil)).to eq("/snippets/preview_markdown") + end + + it 'returns project preview markdown path for a project snippet' do + @snippet = create(:project_snippet, project: project) + + expect(preview_markdown_path(project)).to eq("/#{project.full_path}/preview_markdown") + end + end end diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index ead3e28438e..cb851d828f2 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -159,4 +159,36 @@ describe IssuablesHelper do end end end + + describe '#issuable_initial_data' do + let(:user) { create(:user) } + + before do + allow(helper).to receive(:current_user).and_return(user) + allow(helper).to receive(:can?).and_return(true) + end + + it 'returns the correct json for an issue' do + issue = create(:issue, author: user, description: 'issue text') + @project = issue.project + + expected_data = { + 'endpoint' => "/#{@project.full_path}/issues/#{issue.iid}", + 'canUpdate' => true, + 'canDestroy' => true, + 'issuableRef' => "##{issue.iid}", + 'markdownPreviewPath' => "/#{@project.full_path}/preview_markdown", + 'markdownDocsPath' => '/help/user/markdown', + 'issuableTemplates' => [], + 'projectPath' => @project.path, + 'projectNamespace' => @project.namespace.path, + 'initialTitleHtml' => issue.title, + 'initialTitleText' => issue.title, + 'initialDescriptionHtml' => '<p dir="auto">issue text</p>', + 'initialDescriptionText' => 'issue text', + 'initialTaskStatus' => '0 of 0 tasks completed' + } + expect(JSON.parse(helper.issuable_initial_data(issue))).to eq(expected_data) + end + end end diff --git a/spec/javascripts/fixtures/clusters.rb b/spec/javascripts/fixtures/clusters.rb index 5774f36f026..8e74c4f859c 100644 --- a/spec/javascripts/fixtures/clusters.rb +++ b/spec/javascripts/fixtures/clusters.rb @@ -6,7 +6,7 @@ describe Projects::ClustersController, '(JavaScript fixtures)', type: :controlle let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, :repository, namespace: namespace) } - let(:cluster) { project.create_cluster!(gcp_cluster_name: "gke-test-creation-1", gcp_project_id: 'gitlab-internal-153318', gcp_cluster_zone: 'us-central1-a', gcp_cluster_size: '1', project_namespace: 'aaa', gcp_machine_type: 'n1-standard-1')} + let(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } render_views diff --git a/spec/javascripts/gl_form_spec.js b/spec/javascripts/gl_form_spec.js index 124fc030774..5a8009e57fd 100644 --- a/spec/javascripts/gl_form_spec.js +++ b/spec/javascripts/gl_form_spec.js @@ -1,9 +1,9 @@ -import autosize from 'vendor/autosize'; +import Autosize from 'autosize'; import GLForm from '~/gl_form'; import '~/lib/utils/text_utility'; import '~/lib/utils/common_utils'; -window.autosize = autosize; +window.autosize = Autosize; describe('GLForm', () => { describe('when instantiated', function () { diff --git a/spec/javascripts/groups/components/app_spec.js b/spec/javascripts/groups/components/app_spec.js index cd19a0fae1e..59d4f7c45c6 100644 --- a/spec/javascripts/groups/components/app_spec.js +++ b/spec/javascripts/groups/components/app_spec.js @@ -431,9 +431,9 @@ describe('AppComponent', () => { }); it('should render groups tree', (done) => { - vm.groups = [mockParentGroupItem]; + vm.store.state.groups = [mockParentGroupItem]; vm.isLoading = false; - vm.pageInfo = mockPageInfo; + vm.store.state.pageInfo = mockPageInfo; Vue.nextTick(() => { expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined(); done(); diff --git a/spec/javascripts/header_spec.js b/spec/javascripts/header_spec.js index 4751eb868a4..2443ffd48f3 100644 --- a/spec/javascripts/header_spec.js +++ b/spec/javascripts/header_spec.js @@ -1,4 +1,4 @@ -import '~/header'; +import initTodoToggle from '~/header'; describe('Header', function () { const todosPendingCount = '.todos-count'; @@ -14,6 +14,7 @@ describe('Header', function () { preloadFixtures(fixtureTemplate); beforeEach(() => { + initTodoToggle(); loadFixtures(fixtureTemplate); }); diff --git a/spec/javascripts/helpers/vue_mount_component_helper.js b/spec/javascripts/helpers/vue_mount_component_helper.js index b71136c4114..34acdfbfba9 100644 --- a/spec/javascripts/helpers/vue_mount_component_helper.js +++ b/spec/javascripts/helpers/vue_mount_component_helper.js @@ -1,3 +1,8 @@ +export const createComponentWithStore = (Component, store, propsData = {}) => new Component({ + store, + propsData, +}); + export default (Component, props = {}, el = null) => new Component({ propsData: props, }).$mount(el); diff --git a/spec/javascripts/issuable_context_spec.js b/spec/javascripts/issuable_context_spec.js deleted file mode 100644 index bcb2b7b24a0..00000000000 --- a/spec/javascripts/issuable_context_spec.js +++ /dev/null @@ -1,34 +0,0 @@ -/* global IssuableContext */ -import '~/issuable_context'; -import $ from 'jquery'; - -describe('IssuableContext', () => { - describe('toggleHiddenParticipants', () => { - const event = jasmine.createSpyObj('event', ['preventDefault']); - - beforeEach(() => { - spyOn($.fn, 'data').and.returnValue('data'); - spyOn($.fn, 'text').and.returnValue('data'); - }); - - afterEach(() => { - gl.lazyLoader = undefined; - }); - - it('calls loadCheck if lazyLoader is set', () => { - gl.lazyLoader = jasmine.createSpyObj('lazyLoader', ['loadCheck']); - - IssuableContext.prototype.toggleHiddenParticipants(event); - - expect(gl.lazyLoader.loadCheck).toHaveBeenCalled(); - }); - - it('does not throw if lazyLoader is not defined', () => { - gl.lazyLoader = undefined; - - const toggle = IssuableContext.prototype.toggleHiddenParticipants.bind(null, event); - - expect(toggle).not.toThrow(); - }); - }); -}); diff --git a/spec/javascripts/issuable_spec.js b/spec/javascripts/issuable_spec.js index 45f55395d3a..ceee08d47c5 100644 --- a/spec/javascripts/issuable_spec.js +++ b/spec/javascripts/issuable_spec.js @@ -1,80 +1,44 @@ -/* global IssuableIndex */ - -import '~/lib/utils/url_utility'; -import '~/issuable_index'; - -(() => { - const BASE_URL = '/user/project/issues?scope=all&state=closed'; - const DEFAULT_PARAMS = '&utf8=%E2%9C%93'; - - function updateForm(formValues, form) { - $.each(formValues, (id, value) => { - $(`#${id}`, form).val(value); - }); - } - - function resetForm(form) { - $('input[name!="utf8"]', form).each((index, input) => { - input.setAttribute('value', ''); +import IssuableIndex from '~/issuable_index'; + +describe('Issuable', () => { + let Issuable; + describe('initBulkUpdate', () => { + it('should not set bulkUpdateSidebar', () => { + Issuable = new IssuableIndex('issue_'); + expect(Issuable.bulkUpdateSidebar).not.toBeDefined(); }); - } - describe('Issuable', () => { - preloadFixtures('static/issuable_filter.html.raw'); + it('should set bulkUpdateSidebar', () => { + const element = document.createElement('div'); + element.classList.add('issues-bulk-update'); + document.body.appendChild(element); - beforeEach(() => { - loadFixtures('static/issuable_filter.html.raw'); - IssuableIndex.init(); - }); - - it('should be defined', () => { - expect(window.IssuableIndex).toBeDefined(); + Issuable = new IssuableIndex('issue_'); + expect(Issuable.bulkUpdateSidebar).toBeDefined(); }); + }); - describe('filtering', () => { - let $filtersForm; - - beforeEach(() => { - $filtersForm = $('.js-filter-form'); - loadFixtures('static/issuable_filter.html.raw'); - resetForm($filtersForm); - }); - - it('should contain only the default parameters', () => { - spyOn(gl.utils, 'visitUrl'); - - IssuableIndex.filterResults($filtersForm); - - expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + DEFAULT_PARAMS); - }); - - it('should filter for the phrase "broken"', () => { - spyOn(gl.utils, 'visitUrl'); - - updateForm({ search: 'broken' }, $filtersForm); - IssuableIndex.filterResults($filtersForm); - const params = `${DEFAULT_PARAMS}&search=broken`; - - expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + params); - }); - - it('should keep query parameters after modifying filter', () => { - spyOn(gl.utils, 'visitUrl'); + describe('resetIncomingEmailToken', () => { + beforeEach(() => { + const element = document.createElement('a'); + element.classList.add('incoming-email-token-reset'); + element.setAttribute('href', 'foo'); + document.body.appendChild(element); - // initial filter - updateForm({ milestone_title: 'v1.0' }, $filtersForm); + const input = document.createElement('input'); + input.setAttribute('id', 'issue_email'); + document.body.appendChild(input); - IssuableIndex.filterResults($filtersForm); - let params = `${DEFAULT_PARAMS}&milestone_title=v1.0`; - expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + params); + Issuable = new IssuableIndex('issue_'); + }); - // update filter - updateForm({ label_name: 'Frontend' }, $filtersForm); + it('should send request to reset email token', () => { + spyOn(jQuery, 'ajax').and.callThrough(); + document.querySelector('.incoming-email-token-reset').click(); - IssuableIndex.filterResults($filtersForm); - params = `${DEFAULT_PARAMS}&milestone_title=v1.0&label_name=Frontend`; - expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + params); - }); + expect(jQuery.ajax).toHaveBeenCalled(); + expect(jQuery.ajax.calls.argsFor(0)[0].url).toEqual('foo'); }); }); -})(); +}); + diff --git a/spec/javascripts/issue_spec.js b/spec/javascripts/issue_spec.js index 60a452f2223..3636aac79a0 100644 --- a/spec/javascripts/issue_spec.js +++ b/spec/javascripts/issue_spec.js @@ -1,6 +1,5 @@ /* eslint-disable space-before-function-paren, one-var, one-var-declaration-per-line, no-use-before-define, comma-dangle, max-len */ import Issue from '~/issue'; -import CloseReopenReportToggle from '~/close_reopen_report_toggle'; import '~/lib/utils/text_utility'; describe('Issue', function() { @@ -189,37 +188,4 @@ describe('Issue', function() { }); }); }); - - describe('units', () => { - describe('class constructor', () => { - it('calls .initCloseReopenReport', () => { - spyOn(Issue.prototype, 'initCloseReopenReport'); - - new Issue(); // eslint-disable-line no-new - - expect(Issue.prototype.initCloseReopenReport).toHaveBeenCalled(); - }); - }); - - describe('initCloseReopenReport', () => { - it('calls .initDroplab', () => { - const container = jasmine.createSpyObj('container', ['querySelector']); - const dropdownTrigger = {}; - const dropdownList = {}; - const button = {}; - - spyOn(document, 'querySelector').and.returnValue(container); - spyOn(CloseReopenReportToggle.prototype, 'initDroplab'); - container.querySelector.and.returnValues(dropdownTrigger, dropdownList, button); - - Issue.prototype.initCloseReopenReport(); - - expect(document.querySelector).toHaveBeenCalledWith('.js-issuable-close-dropdown'); - expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-toggle'); - expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-menu'); - expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-button'); - expect(CloseReopenReportToggle.prototype.initDroplab).toHaveBeenCalled(); - }); - }); - }); }); diff --git a/spec/javascripts/jobs/mock_data.js b/spec/javascripts/jobs/mock_data.js index 17e4ef26b2c..43532275121 100644 --- a/spec/javascripts/jobs/mock_data.js +++ b/spec/javascripts/jobs/mock_data.js @@ -22,7 +22,7 @@ export default { details_path: '/root/ci-mock/-/jobs/4757', favicon: '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico', action: { - icon: 'icon_action_retry', + icon: 'retry', title: 'Retry', path: '/root/ci-mock/-/jobs/4757/retry', method: 'post', diff --git a/spec/javascripts/labels_issue_sidebar_spec.js b/spec/javascripts/labels_issue_sidebar_spec.js index e47adc49224..a197b35f6fb 100644 --- a/spec/javascripts/labels_issue_sidebar_spec.js +++ b/spec/javascripts/labels_issue_sidebar_spec.js @@ -1,14 +1,12 @@ /* eslint-disable no-new */ -/* global IssuableContext */ -/* global LabelsSelect */ +import IssuableContext from '~/issuable_context'; +import LabelsSelect from '~/labels_select'; import '~/gl_dropdown'; import 'select2'; import '~/api'; import '~/create_label'; -import '~/issuable_context'; import '~/users_select'; -import '~/labels_select'; (() => { let saveLabelCount = 0; diff --git a/spec/javascripts/merge_request_notes_spec.js b/spec/javascripts/merge_request_notes_spec.js index ac6ace48108..6054b75d0b8 100644 --- a/spec/javascripts/merge_request_notes_spec.js +++ b/spec/javascripts/merge_request_notes_spec.js @@ -1,6 +1,6 @@ /* global Notes */ -import 'vendor/autosize'; +import 'autosize'; import '~/gl_form'; import '~/lib/utils/text_utility'; import '~/render_gfm'; diff --git a/spec/javascripts/namespace_select_spec.js b/spec/javascripts/namespace_select_spec.js new file mode 100644 index 00000000000..9d7625ca269 --- /dev/null +++ b/spec/javascripts/namespace_select_spec.js @@ -0,0 +1,65 @@ +import NamespaceSelect from '~/namespace_select'; + +describe('NamespaceSelect', () => { + beforeEach(() => { + spyOn($.fn, 'glDropdown'); + }); + + it('initializes glDropdown', () => { + const dropdown = document.createElement('div'); + + // eslint-disable-next-line no-new + new NamespaceSelect({ dropdown }); + + expect($.fn.glDropdown).toHaveBeenCalled(); + }); + + describe('as input', () => { + let glDropdownOptions; + + beforeEach(() => { + const dropdown = document.createElement('div'); + // eslint-disable-next-line no-new + new NamespaceSelect({ dropdown }); + glDropdownOptions = $.fn.glDropdown.calls.argsFor(0)[0]; + }); + + it('prevents click events', () => { + const dummyEvent = new Event('dummy'); + spyOn(dummyEvent, 'preventDefault'); + + glDropdownOptions.clicked({ e: dummyEvent }); + + expect(dummyEvent.preventDefault).toHaveBeenCalled(); + }); + }); + + describe('as filter', () => { + let glDropdownOptions; + + beforeEach(() => { + const dropdown = document.createElement('div'); + dropdown.dataset.isFilter = 'true'; + // eslint-disable-next-line no-new + new NamespaceSelect({ dropdown }); + glDropdownOptions = $.fn.glDropdown.calls.argsFor(0)[0]; + }); + + it('does not prevent click events', () => { + const dummyEvent = new Event('dummy'); + spyOn(dummyEvent, 'preventDefault'); + + glDropdownOptions.clicked({ e: dummyEvent }); + + expect(dummyEvent.preventDefault).not.toHaveBeenCalled(); + }); + + it('sets URL of dropdown items', () => { + const dummyNamespace = { id: 'eal' }; + + const itemUrl = glDropdownOptions.url(dummyNamespace); + + expect(itemUrl).toContain(`namespace_id=${dummyNamespace.id}`); + }); + }); +}); diff --git a/spec/javascripts/notes/components/issue_comment_form_spec.js b/spec/javascripts/notes/components/issue_comment_form_spec.js index 3f659af5c3b..a26fc8f63cc 100644 --- a/spec/javascripts/notes/components/issue_comment_form_spec.js +++ b/spec/javascripts/notes/components/issue_comment_form_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import autosize from 'vendor/autosize'; +import Autosize from 'autosize'; import store from '~/notes/stores'; import issueCommentForm from '~/notes/components/issue_comment_form.vue'; import { loggedOutIssueData, notesDataMock, userDataMock, issueDataMock } from '../mock_data'; @@ -97,14 +97,14 @@ describe('issue_comment_form component', () => { }); it('should resize textarea after note discarded', (done) => { - spyOn(autosize, 'update'); + spyOn(Autosize, 'update'); spyOn(vm, 'discard').and.callThrough(); vm.note = 'foo'; vm.discard(); Vue.nextTick(() => { - expect(autosize.update).toHaveBeenCalled(); + expect(Autosize.update).toHaveBeenCalled(); done(); }); }); diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index 4546b88e44d..928a4b461cc 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -1,7 +1,7 @@ /* eslint-disable space-before-function-paren, no-unused-expressions, no-var, object-shorthand, comma-dangle, max-len */ /* global Notes */ -import 'vendor/autosize'; +import 'autosize'; import '~/gl_form'; import '~/lib/utils/text_utility'; import '~/render_gfm'; @@ -343,6 +343,7 @@ import '~/notes'; diff_discussion_html: false, }; $form = jasmine.createSpyObj('$form', ['closest', 'find']); + $form.length = 1; row = jasmine.createSpyObj('row', ['prevAll', 'first', 'find']); notes = jasmine.createSpyObj('notes', [ @@ -371,13 +372,29 @@ import '~/notes'; $form.closest.and.returnValues(row, $form); $form.find.and.returnValues(discussionContainer); body.attr.and.returnValue(''); - - Notes.prototype.renderDiscussionNote.call(notes, note, $form); }); it('should call Notes.animateAppendNote', () => { + Notes.prototype.renderDiscussionNote.call(notes, note, $form); + expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.discussion_html, $('.main-notes-list')); }); + + it('should append to row selected with line_code', () => { + $form.length = 0; + note.discussion_line_code = 'line_code'; + note.diff_discussion_html = '<tr></tr>'; + + const line = document.createElement('div'); + line.id = note.discussion_line_code; + document.body.appendChild(line); + + $form.closest.and.returnValues($form); + + Notes.prototype.renderDiscussionNote.call(notes, note, $form); + + expect(line.nextSibling.outerHTML).toEqual(note.diff_discussion_html); + }); }); describe('Discussion sub note', () => { diff --git a/spec/javascripts/pipelines/graph/action_component_spec.js b/spec/javascripts/pipelines/graph/action_component_spec.js index 85bd87318db..e8fcd4b1a36 100644 --- a/spec/javascripts/pipelines/graph/action_component_spec.js +++ b/spec/javascripts/pipelines/graph/action_component_spec.js @@ -11,7 +11,7 @@ describe('pipeline graph action component', () => { tooltipText: 'bar', link: 'foo', actionMethod: 'post', - actionIcon: 'icon_action_cancel', + actionIcon: 'cancel', }, }).$mount(); diff --git a/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js b/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js index 25fd18b197e..ba721bc53c6 100644 --- a/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js +++ b/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js @@ -11,7 +11,7 @@ describe('action component', () => { tooltipText: 'bar', link: 'foo', actionMethod: 'post', - actionIcon: 'icon_action_cancel', + actionIcon: 'cancel', }, }).$mount(); diff --git a/spec/javascripts/pipelines/graph/job_component_spec.js b/spec/javascripts/pipelines/graph/job_component_spec.js index e90593e0f40..342ee6c1242 100644 --- a/spec/javascripts/pipelines/graph/job_component_spec.js +++ b/spec/javascripts/pipelines/graph/job_component_spec.js @@ -14,7 +14,7 @@ describe('pipeline graph job component', () => { group: 'success', details_path: '/root/ci-mock/builds/4256', action: { - icon: 'icon_action_retry', + icon: 'retry', title: 'Retry', path: '/root/ci-mock/builds/4256/retry', method: 'post', diff --git a/spec/javascripts/pipelines/graph/mock_data.js b/spec/javascripts/pipelines/graph/mock_data.js index 56c522b7f77..b9494f86d74 100644 --- a/spec/javascripts/pipelines/graph/mock_data.js +++ b/spec/javascripts/pipelines/graph/mock_data.js @@ -39,7 +39,7 @@ export default { "details_path": "/root/ci-mock/builds/4153", "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", "action": { - "icon": "icon_action_retry", + "icon": "retry", "title": "Retry", "path": "/root/ci-mock/builds/4153/retry", "method": "post" @@ -62,7 +62,7 @@ export default { "details_path": "/root/ci-mock/builds/4153", "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", "action": { - "icon": "icon_action_retry", + "icon": "retry", "title": "Retry", "path": "/root/ci-mock/builds/4153/retry", "method": "post" @@ -96,7 +96,7 @@ export default { "details_path": "/root/ci-mock/builds/4166", "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", "action": { - "icon": "icon_action_retry", + "icon": "retry", "title": "Retry", "path": "/root/ci-mock/builds/4166/retry", "method": "post" @@ -119,7 +119,7 @@ export default { "details_path": "/root/ci-mock/builds/4166", "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", "action": { - "icon": "icon_action_retry", + "icon": "retry", "title": "Retry", "path": "/root/ci-mock/builds/4166/retry", "method": "post" @@ -138,7 +138,7 @@ export default { "details_path": "/root/ci-mock/builds/4159", "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", "action": { - "icon": "icon_action_retry", + "icon": "retry", "title": "Retry", "path": "/root/ci-mock/builds/4159/retry", "method": "post" @@ -161,7 +161,7 @@ export default { "details_path": "/root/ci-mock/builds/4159", "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", "action": { - "icon": "icon_action_retry", + "icon": "retry", "title": "Retry", "path": "/root/ci-mock/builds/4159/retry", "method": "post" diff --git a/spec/javascripts/pipelines/graph/stage_column_component_spec.js b/spec/javascripts/pipelines/graph/stage_column_component_spec.js index aa4d6eedaf4..063ab53681b 100644 --- a/spec/javascripts/pipelines/graph/stage_column_component_spec.js +++ b/spec/javascripts/pipelines/graph/stage_column_component_spec.js @@ -13,7 +13,7 @@ describe('stage column component', () => { group: 'success', details_path: '/root/ci-mock/builds/4256', action: { - icon: 'icon_action_retry', + icon: 'retry', title: 'Retry', path: '/root/ci-mock/builds/4256/retry', method: 'post', diff --git a/spec/javascripts/repo/components/new_branch_form_spec.js b/spec/javascripts/repo/components/new_branch_form_spec.js index c9c5ce096fc..9a705a1f0ed 100644 --- a/spec/javascripts/repo/components/new_branch_form_spec.js +++ b/spec/javascripts/repo/components/new_branch_form_spec.js @@ -1,8 +1,8 @@ import Vue from 'vue'; +import store from '~/repo/stores'; import newBranchForm from '~/repo/components/new_branch_form.vue'; -import eventHub from '~/repo/event_hub'; -import RepoStore from '~/repo/stores/repo_store'; -import createComponent from '../../helpers/vue_mount_component_helper'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { resetStore } from '../helpers'; describe('Multi-file editor new branch form', () => { let vm; @@ -10,17 +10,17 @@ describe('Multi-file editor new branch form', () => { beforeEach(() => { const Component = Vue.extend(newBranchForm); - RepoStore.currentBranch = 'master'; + vm = createComponentWithStore(Component, store); - vm = createComponent(Component, { - currentBranch: RepoStore.currentBranch, - }); + vm.$store.state.currentBranch = 'master'; + + vm.$mount(); }); afterEach(() => { vm.$destroy(); - RepoStore.currentBranch = ''; + resetStore(vm.$store); }); describe('template', () => { @@ -48,6 +48,10 @@ describe('Multi-file editor new branch form', () => { }); describe('submitNewBranch', () => { + beforeEach(() => { + spyOn(vm, 'createNewBranch').and.returnValue(Promise.resolve()); + }); + it('sets to loading', () => { vm.submitNewBranch(); @@ -66,57 +70,45 @@ describe('Multi-file editor new branch form', () => { }); }); - it('emits an event with branchName', () => { - spyOn(eventHub, '$emit'); - + it('calls createdNewBranch with branchName', () => { vm.branchName = 'testing'; vm.submitNewBranch(); - expect(eventHub.$emit).toHaveBeenCalledWith('createNewBranch', 'testing'); + expect(vm.createNewBranch).toHaveBeenCalledWith('testing'); }); }); - describe('showErrorMessage', () => { - it('sets loading to false', () => { - vm.loading = true; - - vm.showErrorMessage(); - - expect(vm.loading).toBeFalsy(); - }); - - it('creates flash element', () => { - vm.showErrorMessage('error message'); - - expect(vm.$el.querySelector('.flash-alert')).not.toBeNull(); - expect(vm.$el.querySelector('.flash-alert').textContent.trim()).toBe('error message'); + describe('submitNewBranch with error', () => { + beforeEach(() => { + spyOn(vm, 'createNewBranch').and.returnValue(Promise.reject({ + json: () => Promise.resolve({ + message: 'error message', + }), + })); }); - }); - describe('createdNewBranch', () => { - it('set loading to false', () => { + it('sets loading to false', (done) => { vm.loading = true; - vm.createdNewBranch(); - - expect(vm.loading).toBeFalsy(); - }); - - it('resets branch name', () => { - vm.branchName = 'testing'; + vm.submitNewBranch(); - vm.createdNewBranch(); + setTimeout(() => { + expect(vm.loading).toBeFalsy(); - expect(vm.branchName).toBe(''); + done(); + }); }); - it('sets the dropdown toggle text', () => { - vm.dropdownText = document.createElement('span'); + it('creates flash element', (done) => { + vm.submitNewBranch(); - vm.createdNewBranch('branch name'); + setTimeout(() => { + expect(vm.$el.querySelector('.flash-alert')).not.toBeNull(); + expect(vm.$el.querySelector('.flash-alert').textContent.trim()).toBe('error message'); - expect(vm.dropdownText.textContent).toBe('branch name'); + done(); + }); }); }); }); diff --git a/spec/javascripts/repo/components/new_dropdown/index_spec.js b/spec/javascripts/repo/components/new_dropdown/index_spec.js index ddbfdab582d..93b10fc1fee 100644 --- a/spec/javascripts/repo/components/new_dropdown/index_spec.js +++ b/spec/javascripts/repo/components/new_dropdown/index_spec.js @@ -1,9 +1,8 @@ import Vue from 'vue'; +import store from '~/repo/stores'; import newDropdown from '~/repo/components/new_dropdown/index.vue'; -import RepoStore from '~/repo/stores/repo_store'; -import RepoHelper from '~/repo/helpers/repo_helper'; -import eventHub from '~/repo/event_hub'; -import createComponent from '../../../helpers/vue_mount_component_helper'; +import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; +import { resetStore } from '../../helpers'; describe('new dropdown component', () => { let vm; @@ -11,15 +10,17 @@ describe('new dropdown component', () => { beforeEach(() => { const component = Vue.extend(newDropdown); - vm = createComponent(component); + vm = createComponentWithStore(component, store); + + vm.$store.state.path = ''; + + vm.$mount(); }); afterEach(() => { vm.$destroy(); - RepoStore.files = []; - RepoStore.openedFiles = []; - RepoStore.setViewToPreview(); + resetStore(vm.$store); }); it('renders new file and new directory links', () => { @@ -67,125 +68,4 @@ describe('new dropdown component', () => { .catch(done.fail); }); }); - - describe('createEntryInStore', () => { - ['tree', 'blob'].forEach((type) => { - describe(type, () => { - it('closes modal after creating file', () => { - vm.openModal = true; - - eventHub.$emit('createNewEntry', 'testing', type); - - expect(vm.openModal).toBeFalsy(); - }); - - it('sets editMode to true', () => { - eventHub.$emit('createNewEntry', 'testing', type); - - expect(RepoStore.editMode).toBeTruthy(); - }); - - it('toggles blob view', () => { - eventHub.$emit('createNewEntry', 'testing', type); - - expect(RepoStore.isPreviewView()).toBeFalsy(); - }); - - it('adds file into activeFiles', () => { - eventHub.$emit('createNewEntry', 'testing', type); - - expect(RepoStore.openedFiles.length).toBe(1); - }); - - it(`creates ${type} in the current stores path`, () => { - RepoStore.path = 'testing'; - - eventHub.$emit('createNewEntry', 'testing/app', type); - - expect(RepoStore.files[0].path).toBe('testing/app'); - expect(RepoStore.files[0].name).toBe('app'); - - if (type === 'tree') { - expect(RepoStore.files[0].files.length).toBe(1); - } - - RepoStore.path = ''; - }); - }); - }); - - describe('file', () => { - it('creates new file', () => { - eventHub.$emit('createNewEntry', 'testing', 'blob'); - - expect(RepoStore.files.length).toBe(1); - expect(RepoStore.files[0].name).toBe('testing'); - expect(RepoStore.files[0].type).toBe('blob'); - expect(RepoStore.files[0].tempFile).toBeTruthy(); - }); - - it('does not create temp file when file already exists', () => { - RepoStore.files.push(RepoHelper.serializeRepoEntity('blob', { - name: 'testing', - })); - - eventHub.$emit('createNewEntry', 'testing', 'blob'); - - expect(RepoStore.files.length).toBe(1); - expect(RepoStore.files[0].name).toBe('testing'); - expect(RepoStore.files[0].type).toBe('blob'); - expect(RepoStore.files[0].tempFile).toBeUndefined(); - }); - }); - - describe('tree', () => { - it('creates new tree', () => { - eventHub.$emit('createNewEntry', 'testing', 'tree'); - - expect(RepoStore.files.length).toBe(1); - expect(RepoStore.files[0].name).toBe('testing'); - expect(RepoStore.files[0].type).toBe('tree'); - expect(RepoStore.files[0].tempFile).toBeTruthy(); - expect(RepoStore.files[0].files.length).toBe(1); - expect(RepoStore.files[0].files[0].name).toBe('.gitkeep'); - }); - - it('creates multiple trees when entryName has slashes', () => { - eventHub.$emit('createNewEntry', 'app/test', 'tree'); - - expect(RepoStore.files.length).toBe(1); - expect(RepoStore.files[0].name).toBe('app'); - expect(RepoStore.files[0].files[0].name).toBe('test'); - expect(RepoStore.files[0].files[0].files[0].name).toBe('.gitkeep'); - }); - - it('creates tree in existing tree', () => { - RepoStore.files.push(RepoHelper.serializeRepoEntity('tree', { - name: 'app', - })); - - eventHub.$emit('createNewEntry', 'app/test', 'tree'); - - expect(RepoStore.files.length).toBe(1); - expect(RepoStore.files[0].name).toBe('app'); - expect(RepoStore.files[0].tempFile).toBeUndefined(); - expect(RepoStore.files[0].files[0].tempFile).toBeTruthy(); - expect(RepoStore.files[0].files[0].name).toBe('test'); - expect(RepoStore.files[0].files[0].files[0].name).toBe('.gitkeep'); - }); - - it('does not create new tree when already exists', () => { - RepoStore.files.push(RepoHelper.serializeRepoEntity('tree', { - name: 'app', - })); - - eventHub.$emit('createNewEntry', 'app', 'tree'); - - expect(RepoStore.files.length).toBe(1); - expect(RepoStore.files[0].name).toBe('app'); - expect(RepoStore.files[0].tempFile).toBeUndefined(); - expect(RepoStore.files[0].files.length).toBe(0); - }); - }); - }); }); diff --git a/spec/javascripts/repo/components/new_dropdown/modal_spec.js b/spec/javascripts/repo/components/new_dropdown/modal_spec.js index 4c5cdc47c6e..1ff7590ec79 100644 --- a/spec/javascripts/repo/components/new_dropdown/modal_spec.js +++ b/spec/javascripts/repo/components/new_dropdown/modal_spec.js @@ -1,8 +1,8 @@ import Vue from 'vue'; -import RepoStore from '~/repo/stores/repo_store'; +import store from '~/repo/stores'; import modal from '~/repo/components/new_dropdown/modal.vue'; -import eventHub from '~/repo/event_hub'; -import createComponent from '../../../helpers/vue_mount_component_helper'; +import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; +import { file, resetStore } from '../../helpers'; describe('new file modal component', () => { const Component = Vue.extend(modal); @@ -11,18 +11,18 @@ describe('new file modal component', () => { afterEach(() => { vm.$destroy(); - RepoStore.files = []; - RepoStore.openedFiles = []; - RepoStore.setViewToPreview(); + resetStore(vm.$store); }); ['tree', 'blob'].forEach((type) => { describe(type, () => { beforeEach(() => { - vm = createComponent(Component, { + vm = createComponentWithStore(Component, store, { type, - currentPath: RepoStore.path, - }); + path: '', + }).$mount(); + + vm.entryName = 'testing'; }); it(`sets modal title as ${type}`, () => { @@ -42,35 +42,157 @@ describe('new file modal component', () => { expect(vm.$el.querySelector('.label-light').textContent.trim()).toBe(`${title} name`); }); + + describe('createEntryInStore', () => { + it('calls createTempEntry', () => { + spyOn(vm, 'createTempEntry'); + + vm.createEntryInStore(); + + expect(vm.createTempEntry).toHaveBeenCalledWith({ + name: 'testing', + type, + }); + }); + + it('sets editMode to true', (done) => { + vm.createEntryInStore(); + + setTimeout(() => { + expect(vm.$store.state.editMode).toBeTruthy(); + + done(); + }); + }); + + it('toggles blob view', (done) => { + vm.createEntryInStore(); + + setTimeout(() => { + expect(vm.$store.state.currentBlobView).toBe('repo-editor'); + + done(); + }); + }); + + it('opens newly created file', (done) => { + vm.createEntryInStore(); + + setTimeout(() => { + expect(vm.$store.state.openFiles.length).toBe(1); + expect(vm.$store.state.openFiles[0].name).toBe(type === 'blob' ? 'testing' : '.gitkeep'); + + done(); + }); + }); + + it(`creates ${type} in the current stores path`, (done) => { + vm.$store.state.path = 'app'; + + vm.createEntryInStore(); + + setTimeout(() => { + expect(vm.$store.state.tree[0].path).toBe('app/testing'); + expect(vm.$store.state.tree[0].name).toBe('testing'); + + if (type === 'tree') { + expect(vm.$store.state.tree[0].tree.length).toBe(1); + } + + done(); + }); + }); + + if (type === 'blob') { + it('creates new file', (done) => { + vm.createEntryInStore(); + + setTimeout(() => { + expect(vm.$store.state.tree.length).toBe(1); + expect(vm.$store.state.tree[0].name).toBe('testing'); + expect(vm.$store.state.tree[0].type).toBe('blob'); + expect(vm.$store.state.tree[0].tempFile).toBeTruthy(); + + done(); + }); + }); + + it('does not create temp file when file already exists', (done) => { + vm.$store.state.tree.push(file('testing', '1', type)); + + vm.createEntryInStore(); + + setTimeout(() => { + expect(vm.$store.state.tree.length).toBe(1); + expect(vm.$store.state.tree[0].name).toBe('testing'); + expect(vm.$store.state.tree[0].type).toBe('blob'); + expect(vm.$store.state.tree[0].tempFile).toBeFalsy(); + + done(); + }); + }); + } else { + it('creates new tree', () => { + vm.createEntryInStore(); + + expect(vm.$store.state.tree.length).toBe(1); + expect(vm.$store.state.tree[0].name).toBe('testing'); + expect(vm.$store.state.tree[0].type).toBe('tree'); + expect(vm.$store.state.tree[0].tempFile).toBeTruthy(); + expect(vm.$store.state.tree[0].tree.length).toBe(1); + expect(vm.$store.state.tree[0].tree[0].name).toBe('.gitkeep'); + }); + + it('creates multiple trees when entryName has slashes', () => { + vm.entryName = 'app/test'; + vm.createEntryInStore(); + + expect(vm.$store.state.tree.length).toBe(1); + expect(vm.$store.state.tree[0].name).toBe('app'); + expect(vm.$store.state.tree[0].tree[0].name).toBe('test'); + expect(vm.$store.state.tree[0].tree[0].tree[0].name).toBe('.gitkeep'); + }); + + it('creates tree in existing tree', () => { + vm.$store.state.tree.push(file('app', '1', 'tree')); + + vm.entryName = 'app/test'; + vm.createEntryInStore(); + + expect(vm.$store.state.tree.length).toBe(1); + expect(vm.$store.state.tree[0].name).toBe('app'); + expect(vm.$store.state.tree[0].tempFile).toBeFalsy(); + expect(vm.$store.state.tree[0].tree[0].tempFile).toBeTruthy(); + expect(vm.$store.state.tree[0].tree[0].name).toBe('test'); + expect(vm.$store.state.tree[0].tree[0].tree[0].name).toBe('.gitkeep'); + }); + + it('does not create new tree when already exists', () => { + vm.$store.state.tree.push(file('app', '1', 'tree')); + + vm.entryName = 'app'; + vm.createEntryInStore(); + + expect(vm.$store.state.tree.length).toBe(1); + expect(vm.$store.state.tree[0].name).toBe('app'); + expect(vm.$store.state.tree[0].tempFile).toBeFalsy(); + expect(vm.$store.state.tree[0].tree.length).toBe(0); + }); + } + }); }); }); it('focuses field on mount', () => { document.body.innerHTML += '<div class="js-test"></div>'; - vm = createComponent(Component, { + vm = createComponentWithStore(Component, store, { type: 'tree', - currentPath: RepoStore.path, - }, '.js-test'); + path: '', + }).$mount('.js-test'); expect(document.activeElement).toBe(vm.$refs.fieldName); vm.$el.remove(); }); - - describe('createEntryInStore', () => { - it('emits createNewEntry event', () => { - spyOn(eventHub, '$emit'); - - vm = createComponent(Component, { - type: 'tree', - currentPath: RepoStore.path, - }); - vm.entryName = 'testing'; - - vm.createEntryInStore(); - - expect(eventHub.$emit).toHaveBeenCalledWith('createNewEntry', 'testing', 'tree'); - }); - }); }); diff --git a/spec/javascripts/repo/components/new_dropdown/upload_spec.js b/spec/javascripts/repo/components/new_dropdown/upload_spec.js new file mode 100644 index 00000000000..bf7893029b1 --- /dev/null +++ b/spec/javascripts/repo/components/new_dropdown/upload_spec.js @@ -0,0 +1,103 @@ +import Vue from 'vue'; +import upload from '~/repo/components/new_dropdown/upload.vue'; +import store from '~/repo/stores'; +import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; +import { resetStore } from '../../helpers'; + +describe('new dropdown upload', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(upload); + + vm = createComponentWithStore(Component, store, { + path: '', + }); + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + describe('readFile', () => { + beforeEach(() => { + spyOn(FileReader.prototype, 'readAsText'); + spyOn(FileReader.prototype, 'readAsDataURL'); + }); + + it('calls readAsText for text files', () => { + const file = { + type: 'text/html', + }; + + vm.readFile(file); + + expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(file); + }); + + it('calls readAsDataURL for non-text files', () => { + const file = { + type: 'images/png', + }; + + vm.readFile(file); + + expect(FileReader.prototype.readAsDataURL).toHaveBeenCalledWith(file); + }); + }); + + describe('createFile', () => { + const target = { + result: 'content', + }; + const binaryTarget = { + result: 'base64,base64content', + }; + const file = { + name: 'file', + }; + + it('creates new file', (done) => { + vm.createFile(target, file, true); + + vm.$nextTick(() => { + expect(vm.$store.state.tree.length).toBe(1); + expect(vm.$store.state.tree[0].name).toBe(file.name); + expect(vm.$store.state.tree[0].content).toBe(target.result); + + done(); + }); + }); + + it('creates new file in path', (done) => { + vm.$store.state.path = 'testing'; + vm.createFile(target, file, true); + + vm.$nextTick(() => { + expect(vm.$store.state.tree.length).toBe(1); + expect(vm.$store.state.tree[0].name).toBe(file.name); + expect(vm.$store.state.tree[0].content).toBe(target.result); + expect(vm.$store.state.tree[0].path).toBe(`testing/${file.name}`); + + done(); + }); + }); + + it('splits content on base64 if binary', (done) => { + vm.createFile(binaryTarget, file, false); + + vm.$nextTick(() => { + expect(vm.$store.state.tree.length).toBe(1); + expect(vm.$store.state.tree[0].name).toBe(file.name); + expect(vm.$store.state.tree[0].content).toBe(binaryTarget.result.split('base64,')[1]); + expect(vm.$store.state.tree[0].base64).toBe(true); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/repo/components/repo_commit_section_spec.js b/spec/javascripts/repo/components/repo_commit_section_spec.js index e09d593f04c..0f991e1b727 100644 --- a/spec/javascripts/repo/components/repo_commit_section_spec.js +++ b/spec/javascripts/repo/components/repo_commit_section_spec.js @@ -1,56 +1,43 @@ import Vue from 'vue'; +import store from '~/repo/stores'; +import service from '~/repo/services'; import repoCommitSection from '~/repo/components/repo_commit_section.vue'; -import RepoStore from '~/repo/stores/repo_store'; -import RepoService from '~/repo/services/repo_service'; import getSetTimeoutPromise from '../../helpers/set_timeout_promise_helper'; +import { file, resetStore } from '../helpers'; describe('RepoCommitSection', () => { - const branch = 'master'; - const projectUrl = 'projectUrl'; - let changedFiles; - let openedFiles; + let vm; - RepoStore.projectUrl = projectUrl; - - function createComponent(el) { + function createComponent() { const RepoCommitSection = Vue.extend(repoCommitSection); - return new RepoCommitSection().$mount(el); + const comp = new RepoCommitSection({ + store, + }).$mount(); + + comp.$store.state.currentBranch = 'master'; + comp.$store.state.openFiles = [file(), file()]; + comp.$store.state.openFiles.forEach(f => Object.assign(f, { + changed: true, + content: 'testing', + })); + + return comp.$mount(); } beforeEach(() => { - // Create a copy for each test because these can get modified directly - changedFiles = [{ - id: 0, - changed: true, - url: `/namespace/${projectUrl}/blob/${branch}/dir/file0.ext`, - path: 'dir/file0.ext', - newContent: 'a', - }, { - id: 1, - changed: true, - url: `/namespace/${projectUrl}/blob/${branch}/dir/file1.ext`, - path: 'dir/file1.ext', - newContent: 'b', - }]; - openedFiles = changedFiles.concat([{ - id: 2, - url: `/namespace/${projectUrl}/blob/${branch}/dir/file2.ext`, - path: 'dir/file2.ext', - changed: false, - }]); + vm = createComponent(); }); - it('renders a commit section', () => { - RepoStore.isCommitable = true; - RepoStore.currentBranch = branch; - RepoStore.targetBranch = branch; - RepoStore.openedFiles = openedFiles; + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); - const vm = createComponent(); + it('renders a commit section', () => { const changedFileElements = [...vm.$el.querySelectorAll('.changed-files > li')]; - const commitMessage = vm.$el.querySelector('#commit-message'); - const submitCommit = vm.$refs.submitCommit; + const submitCommit = vm.$el.querySelector('.btn'); const targetBranch = vm.$el.querySelector('.target-branch'); expect(vm.$el.querySelector(':scope > form')).toBeTruthy(); @@ -58,160 +45,70 @@ describe('RepoCommitSection', () => { expect(changedFileElements.length).toEqual(2); changedFileElements.forEach((changedFile, i) => { - expect(changedFile.textContent.trim()).toEqual(changedFiles[i].path); + expect(changedFile.textContent.trim()).toEqual(vm.$store.getters.changedFiles[i].path); }); - expect(commitMessage.tagName).toEqual('TEXTAREA'); - expect(commitMessage.name).toEqual('commit-message'); - expect(submitCommit.type).toEqual('submit'); expect(submitCommit.disabled).toBeTruthy(); expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeFalsy(); expect(vm.$el.querySelector('.commit-summary').textContent.trim()).toEqual('Commit 2 files'); expect(targetBranch.querySelector(':scope > label').textContent.trim()).toEqual('Target branch'); - expect(targetBranch.querySelector('.help-block').textContent.trim()).toEqual(branch); - }); - - it('does not render if not isCommitable', () => { - RepoStore.isCommitable = false; - RepoStore.openedFiles = [{ - id: 0, - changed: true, - }]; - - const vm = createComponent(); - - expect(vm.$el.innerHTML).toBeFalsy(); - }); - - it('does not render if no changedFiles', () => { - RepoStore.isCommitable = true; - RepoStore.openedFiles = []; - - const vm = createComponent(); - - expect(vm.$el.innerHTML).toBeFalsy(); + expect(targetBranch.querySelector('.help-block').textContent.trim()).toEqual('master'); }); describe('when submitting', () => { - let el; - let vm; - const projectId = 'projectId'; - const commitMessage = 'commitMessage'; - - beforeEach((done) => { - RepoStore.isCommitable = true; - RepoStore.currentBranch = branch; - RepoStore.targetBranch = branch; - RepoStore.openedFiles = openedFiles; - RepoStore.projectId = projectId; - - // We need to append to body to get form `submit` events working - // Otherwise we run into, "Form submission canceled because the form is not connected" - // See https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm - el = document.createElement('div'); - document.body.appendChild(el); - - vm = createComponent(el); - vm.commitMessage = commitMessage; - - spyOn(vm, 'tryCommit').and.callThrough(); - spyOn(vm, 'redirectToNewMr').and.stub(); - spyOn(vm, 'redirectToBranch').and.stub(); - spyOn(RepoService, 'commitFiles').and.returnValue(Promise.resolve()); - spyOn(RepoService, 'getBranch').and.returnValue(Promise.resolve({ - commit: { - id: 1, - short_id: 1, - }, - })); - - // Wait for the vm data to be in place - Vue.nextTick(() => { - done(); - }); - }); + let changedFiles; - afterEach(() => { - vm.$destroy(); - el.remove(); - RepoStore.openedFiles = []; - }); + beforeEach(() => { + vm.commitMessage = 'testing'; + changedFiles = JSON.parse(JSON.stringify(vm.$store.getters.changedFiles)); - it('shows commit message', () => { - const commitMessageEl = vm.$el.querySelector('#commit-message'); - expect(commitMessageEl.value).toBe(commitMessage); + spyOn(service, 'commit').and.returnValue(Promise.resolve({ + short_id: '1', + stats: {}, + })); }); it('allows you to submit', () => { - const submitCommit = vm.$refs.submitCommit; - expect(submitCommit.disabled).toBeFalsy(); + expect(vm.$el.querySelector('.btn').disabled).toBeTruthy(); }); - it('shows commit submit and summary if commitMessage and spinner if submitCommitsLoading', (done) => { - const submitCommit = vm.$refs.submitCommit; - submitCommit.click(); + it('submits commit', (done) => { + vm.makeCommit(); // Wait for the branch check to finish getSetTimeoutPromise() .then(() => Vue.nextTick()) .then(() => { - expect(vm.tryCommit).toHaveBeenCalled(); - expect(submitCommit.querySelector('.js-commit-loading-icon')).toBeTruthy(); - expect(vm.redirectToBranch).toHaveBeenCalled(); - - const args = RepoService.commitFiles.calls.allArgs()[0]; - const { commit_message, actions, branch: payloadBranch } = args[0]; + const args = service.commit.calls.allArgs()[0]; + const { commit_message, actions, branch: payloadBranch } = args[1]; - expect(commit_message).toBe(commitMessage); + expect(commit_message).toBe('testing'); expect(actions.length).toEqual(2); - expect(payloadBranch).toEqual(branch); + expect(payloadBranch).toEqual('master'); expect(actions[0].action).toEqual('update'); expect(actions[1].action).toEqual('update'); - expect(actions[0].content).toEqual(openedFiles[0].newContent); - expect(actions[1].content).toEqual(openedFiles[1].newContent); - expect(actions[0].file_path).toEqual(openedFiles[0].path); - expect(actions[1].file_path).toEqual(openedFiles[1].path); + expect(actions[0].content).toEqual(changedFiles[0].content); + expect(actions[1].content).toEqual(changedFiles[1].content); + expect(actions[0].file_path).toEqual(changedFiles[0].path); + expect(actions[1].file_path).toEqual(changedFiles[1].path); }) .then(done) .catch(done.fail); }); it('redirects to MR creation page if start new MR checkbox checked', (done) => { + spyOn(gl.utils, 'visitUrl'); vm.startNewMR = true; - Vue.nextTick() - .then(() => { - const submitCommit = vm.$refs.submitCommit; - submitCommit.click(); - }) - // Wait for the branch check to finish - .then(() => getSetTimeoutPromise()) + vm.makeCommit(); + + getSetTimeoutPromise() + .then(() => Vue.nextTick()) .then(() => { - expect(vm.redirectToNewMr).toHaveBeenCalled(); + expect(gl.utils.visitUrl).toHaveBeenCalled(); }) .then(done) .catch(done.fail); }); }); - - describe('methods', () => { - describe('resetCommitState', () => { - it('should reset store vars and scroll to top', () => { - const vm = { - submitCommitsLoading: true, - changedFiles: new Array(10), - openedFiles: new Array(3), - commitMessage: 'commitMessage', - editMode: true, - }; - - repoCommitSection.methods.resetCommitState.call(vm); - - expect(vm.submitCommitsLoading).toEqual(false); - expect(vm.changedFiles).toEqual([]); - expect(vm.commitMessage).toEqual(''); - expect(vm.editMode).toEqual(false); - }); - }); - }); }); diff --git a/spec/javascripts/repo/components/repo_edit_button_spec.js b/spec/javascripts/repo/components/repo_edit_button_spec.js index dff2fac191d..44018464b35 100644 --- a/spec/javascripts/repo/components/repo_edit_button_spec.js +++ b/spec/javascripts/repo/components/repo_edit_button_spec.js @@ -1,45 +1,83 @@ import Vue from 'vue'; +import store from '~/repo/stores'; import repoEditButton from '~/repo/components/repo_edit_button.vue'; -import RepoStore from '~/repo/stores/repo_store'; +import { file, resetStore } from '../helpers'; describe('RepoEditButton', () => { - function createComponent() { + let vm; + + beforeEach(() => { + const f = file(); const RepoEditButton = Vue.extend(repoEditButton); - return new RepoEditButton().$mount(); - } + vm = new RepoEditButton({ + store, + }); + + f.active = true; + vm.$store.dispatch('setInitialData', { + canCommit: true, + onTopOfBranch: true, + }); + vm.$store.state.openFiles.push(f); + }); afterEach(() => { - RepoStore.openedFiles = []; + vm.$destroy(); + + resetStore(vm.$store); }); - it('renders an edit button that toggles the view state', (done) => { - RepoStore.isCommitable = true; - RepoStore.changedFiles = []; - RepoStore.binary = false; - RepoStore.openedFiles = [{}, {}]; + it('renders an edit button', () => { + vm.$mount(); + + expect(vm.$el.querySelector('.btn')).not.toBeNull(); + expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Edit'); + }); - const vm = createComponent(); + it('renders edit button with cancel text', () => { + vm.$store.state.editMode = true; - expect(vm.$el.tagName).toEqual('BUTTON'); - expect(vm.$el.textContent).toMatch('Edit'); + vm.$mount(); - spyOn(vm, 'editCancelClicked').and.callThrough(); + expect(vm.$el.querySelector('.btn')).not.toBeNull(); + expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Cancel edit'); + }); - vm.$el.click(); + it('toggles edit mode on click', (done) => { + vm.$mount(); + + vm.$el.querySelector('.btn').click(); + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Cancel edit'); - Vue.nextTick(() => { - expect(vm.editCancelClicked).toHaveBeenCalled(); - expect(vm.$el.textContent).toMatch('Cancel edit'); done(); }); }); - it('does not render if not isCommitable', () => { - RepoStore.isCommitable = false; + describe('discardPopupOpen', () => { + beforeEach(() => { + vm.$store.state.discardPopupOpen = true; + vm.$store.state.editMode = true; + vm.$store.state.openFiles[0].changed = true; + + vm.$mount(); + }); + + it('renders popup', () => { + expect(vm.$el.querySelector('.modal')).not.toBeNull(); + }); + + it('removes all changed files', (done) => { + vm.$el.querySelector('.btn-warning').click(); - const vm = createComponent(); + vm.$nextTick(() => { + expect(vm.$store.getters.changedFiles.length).toBe(0); + expect(vm.$el.querySelector('.modal')).toBeNull(); - expect(vm.$el.innerHTML).toBeUndefined(); + done(); + }); + }); }); }); diff --git a/spec/javascripts/repo/components/repo_editor_spec.js b/spec/javascripts/repo/components/repo_editor_spec.js index a25a600b3be..979d2185076 100644 --- a/spec/javascripts/repo/components/repo_editor_spec.js +++ b/spec/javascripts/repo/components/repo_editor_spec.js @@ -1,54 +1,56 @@ import Vue from 'vue'; -import RepoStore from '~/repo/stores/repo_store'; +import store from '~/repo/stores'; import repoEditor from '~/repo/components/repo_editor.vue'; +import { file, resetStore } from '../helpers'; describe('RepoEditor', () => { + let vm; + beforeEach(() => { + const f = file(); const RepoEditor = Vue.extend(repoEditor); - this.vm = new RepoEditor().$mount(); + vm = new RepoEditor({ + store, + }); + + f.active = true; + f.tempFile = true; + vm.$store.state.openFiles.push(f); + vm.$store.getters.activeFile.html = 'testing'; + vm.monaco = true; + + vm.$mount(); }); afterEach(() => { - RepoStore.openedFiles = []; + vm.$destroy(); + + resetStore(vm.$store); }); it('renders an ide container', (done) => { - this.vm.openedFiles = ['idiidid']; - this.vm.binary = false; - Vue.nextTick(() => { - expect(this.vm.shouldHideEditor).toBe(false); - expect(this.vm.$el.id).toEqual('ide'); - expect(this.vm.$el.tagName).toBe('DIV'); + expect(vm.shouldHideEditor).toBeFalsy(); + expect(vm.$el.textContent.trim()).toBe(''); + done(); }); }); - describe('when there are no open files', () => { - it('does not render the ide', (done) => { - this.vm.openedFiles = []; + describe('when open file is binary and not raw', () => { + beforeEach((done) => { + vm.$store.getters.activeFile.binary = true; - Vue.nextTick(() => { - expect(this.vm.shouldHideEditor).toBe(true); - expect(this.vm.$el.tagName).not.toBeDefined(); - done(); - }); + Vue.nextTick(done); }); - }); - describe('when open file is binary and not raw', () => { - it('does not render the IDE', (done) => { - this.vm.binary = true; - this.vm.activeFile = { - raw: false, - }; - - Vue.nextTick(() => { - expect(this.vm.shouldHideEditor).toBe(true); - expect(this.vm.$el.tagName).not.toBeDefined(); - done(); - }); + it('does not render the IDE', () => { + expect(vm.shouldHideEditor).toBeTruthy(); + }); + + it('shows activeFile html', () => { + expect(vm.$el.textContent.trim()).toBe('testing'); }); }); }); diff --git a/spec/javascripts/repo/components/repo_file_buttons_spec.js b/spec/javascripts/repo/components/repo_file_buttons_spec.js index 111c83ee50d..d6e255e4810 100644 --- a/spec/javascripts/repo/components/repo_file_buttons_spec.js +++ b/spec/javascripts/repo/components/repo_file_buttons_spec.js @@ -1,72 +1,49 @@ import Vue from 'vue'; +import store from '~/repo/stores'; import repoFileButtons from '~/repo/components/repo_file_buttons.vue'; -import RepoStore from '~/repo/stores/repo_store'; +import { file, resetStore } from '../helpers'; describe('RepoFileButtons', () => { - const activeFile = { - extension: 'md', - url: 'url', - raw_path: 'raw_path', - blame_path: 'blame_path', - commits_path: 'commits_path', - permalink: 'permalink', - }; + const activeFile = file(); + let vm; function createComponent() { const RepoFileButtons = Vue.extend(repoFileButtons); - return new RepoFileButtons().$mount(); + activeFile.rawPath = 'test'; + activeFile.blamePath = 'test'; + activeFile.commitsPath = 'test'; + activeFile.active = true; + store.state.openFiles.push(activeFile); + + return new RepoFileButtons({ + store, + }).$mount(); } afterEach(() => { - RepoStore.openedFiles = []; - }); - - it('renders Raw, Blame, History, Permalink and Preview toggle', () => { - const activeFileLabel = 'activeFileLabel'; - RepoStore.openedFiles = new Array(1); - RepoStore.activeFile = activeFile; - RepoStore.activeFileLabel = activeFileLabel; - RepoStore.editMode = true; - RepoStore.binary = false; + vm.$destroy(); - const vm = createComponent(); - const raw = vm.$el.querySelector('.raw'); - const blame = vm.$el.querySelector('.blame'); - const history = vm.$el.querySelector('.history'); - - expect(raw.href).toMatch(`/${activeFile.raw_path}`); - expect(raw.textContent.trim()).toEqual('Raw'); - expect(blame.href).toMatch(`/${activeFile.blame_path}`); - expect(blame.textContent.trim()).toEqual('Blame'); - expect(history.href).toMatch(`/${activeFile.commits_path}`); - expect(history.textContent.trim()).toEqual('History'); - expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual('Permalink'); - expect(vm.$el.querySelector('.preview').textContent.trim()).toEqual(activeFileLabel); + resetStore(vm.$store); }); - it('triggers rawPreviewToggle on preview click', () => { - RepoStore.openedFiles = new Array(1); - RepoStore.activeFile = activeFile; - RepoStore.editMode = true; - - const vm = createComponent(); - const preview = vm.$el.querySelector('.preview'); - - spyOn(vm, 'rawPreviewToggle'); - - preview.click(); - - expect(vm.rawPreviewToggle).toHaveBeenCalled(); - }); + it('renders Raw, Blame, History, Permalink and Preview toggle', (done) => { + vm = createComponent(); - it('does not render preview toggle if not canPreview', () => { - activeFile.extension = 'js'; - RepoStore.openedFiles = new Array(1); - RepoStore.activeFile = activeFile; + vm.$nextTick(() => { + const raw = vm.$el.querySelector('.raw'); + const blame = vm.$el.querySelector('.blame'); + const history = vm.$el.querySelector('.history'); - const vm = createComponent(); + expect(raw.href).toMatch(`/${activeFile.rawPath}`); + expect(raw.textContent.trim()).toEqual('Raw'); + expect(blame.href).toMatch(`/${activeFile.blamePath}`); + expect(blame.textContent.trim()).toEqual('Blame'); + expect(history.href).toMatch(`/${activeFile.commitsPath}`); + expect(history.textContent.trim()).toEqual('History'); + expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual('Permalink'); - expect(vm.$el.querySelector('.preview')).toBeFalsy(); + done(); + }); }); }); diff --git a/spec/javascripts/repo/components/repo_file_spec.js b/spec/javascripts/repo/components/repo_file_spec.js index 8403df9be64..c45f8a18d1f 100644 --- a/spec/javascripts/repo/components/repo_file_spec.js +++ b/spec/javascripts/repo/components/repo_file_spec.js @@ -1,32 +1,29 @@ import Vue from 'vue'; +import store from '~/repo/stores'; import repoFile from '~/repo/components/repo_file.vue'; -import RepoStore from '~/repo/stores/repo_store'; -import eventHub from '~/repo/event_hub'; -import { file } from '../mock_data'; +import { file, resetStore } from '../helpers'; describe('RepoFile', () => { const updated = 'updated'; - const otherFile = { - id: 'test', - html: '<p class="file-content">html</p>', - pageTitle: 'otherpageTitle', - }; + let vm; function createComponent(propsData) { const RepoFile = Vue.extend(repoFile); return new RepoFile({ + store, propsData, }).$mount(); } - beforeEach(() => { - RepoStore.openedFiles = []; + afterEach(() => { + resetStore(vm.$store); }); it('renders link, icon, name and last commit details', () => { const RepoFile = Vue.extend(repoFile); - const vm = new RepoFile({ + vm = new RepoFile({ + store, propsData: { file: file(), }, @@ -47,23 +44,17 @@ describe('RepoFile', () => { }); it('does render if hasFiles is true and is loading tree', () => { - const vm = createComponent({ + vm = createComponent({ file: file(), }); expect(vm.$el.querySelector('.fa-spin.fa-spinner')).toBeFalsy(); }); - it('sets the document title correctly', () => { - RepoStore.setActiveFiles(otherFile); - - expect(document.title.trim()).toEqual(otherFile.pageTitle); - }); - it('renders a spinner if the file is loading', () => { const f = file(); f.loading = true; - const vm = createComponent({ + vm = createComponent({ file: f, }); @@ -71,32 +62,34 @@ describe('RepoFile', () => { expect(vm.$el.querySelector('.fa-spin.fa-spinner').style.marginLeft).toEqual(`${vm.file.level * 16}px`); }); - it('does not render commit message and datetime if mini', () => { - RepoStore.openedFiles.push(file()); - - const vm = createComponent({ + it('does not render commit message and datetime if mini', (done) => { + vm = createComponent({ file: file(), }); + vm.$store.state.openFiles.push(vm.file); - expect(vm.$el.querySelector('.commit-message')).toBeFalsy(); - expect(vm.$el.querySelector('.commit-update')).toBeFalsy(); + vm.$nextTick(() => { + expect(vm.$el.querySelector('.commit-message')).toBeFalsy(); + expect(vm.$el.querySelector('.commit-update')).toBeFalsy(); + + done(); + }); }); - it('fires linkClicked when the link is clicked', () => { - const vm = createComponent({ + it('fires clickedTreeRow when the link is clicked', () => { + vm = createComponent({ file: file(), }); - spyOn(vm, 'linkClicked'); + spyOn(vm, 'clickedTreeRow'); vm.$el.click(); - expect(vm.linkClicked).toHaveBeenCalledWith(vm.file); + expect(vm.clickedTreeRow).toHaveBeenCalledWith(vm.file); }); describe('submodule', () => { let f; - let vm; beforeEach(() => { f = file('submodule name', '123456789'); @@ -119,20 +112,4 @@ describe('RepoFile', () => { expect(vm.$el.querySelector('td').textContent.replace(/\s+/g, ' ')).toContain('submodule name @ 12345678'); }); }); - - describe('methods', () => { - describe('linkClicked', () => { - it('$emits fileNameClicked with file obj', () => { - spyOn(eventHub, '$emit'); - - const vm = createComponent({ - file: file(), - }); - - vm.linkClicked(vm.file); - - expect(eventHub.$emit).toHaveBeenCalledWith('fileNameClicked', vm.file); - }); - }); - }); }); diff --git a/spec/javascripts/repo/components/repo_loading_file_spec.js b/spec/javascripts/repo/components/repo_loading_file_spec.js index e9f95a02028..031f2a9c0b2 100644 --- a/spec/javascripts/repo/components/repo_loading_file_spec.js +++ b/spec/javascripts/repo/components/repo_loading_file_spec.js @@ -1,13 +1,16 @@ import Vue from 'vue'; -import RepoStore from '~/repo/stores/repo_store'; +import store from '~/repo/stores'; import repoLoadingFile from '~/repo/components/repo_loading_file.vue'; +import { resetStore } from '../helpers'; describe('RepoLoadingFile', () => { - function createComponent(propsData) { + let vm; + + function createComponent() { const RepoLoadingFile = Vue.extend(repoLoadingFile); return new RepoLoadingFile({ - propsData, + store, }).$mount(); } @@ -30,33 +33,30 @@ describe('RepoLoadingFile', () => { } afterEach(() => { - RepoStore.openedFiles = []; + vm.$destroy(); + + resetStore(vm.$store); }); it('renders 3 columns of animated LoC', () => { - const vm = createComponent({ - loading: { - tree: true, - }, - hasFiles: false, - }); + vm = createComponent(); const columns = [...vm.$el.querySelectorAll('td')]; expect(columns.length).toEqual(3); assertColumns(columns); }); - it('renders 1 column of animated LoC if isMini', () => { - RepoStore.openedFiles = new Array(1); - const vm = createComponent({ - loading: { - tree: true, - }, - hasFiles: false, - }); - const columns = [...vm.$el.querySelectorAll('td')]; + it('renders 1 column of animated LoC if isMini', (done) => { + vm = createComponent(); + vm.$store.state.openFiles.push('test'); - expect(columns.length).toEqual(1); - assertColumns(columns); + vm.$nextTick(() => { + const columns = [...vm.$el.querySelectorAll('td')]; + + expect(columns.length).toEqual(1); + assertColumns(columns); + + done(); + }); }); }); diff --git a/spec/javascripts/repo/components/repo_prev_directory_spec.js b/spec/javascripts/repo/components/repo_prev_directory_spec.js index 4c064f21084..7f82ae36a64 100644 --- a/spec/javascripts/repo/components/repo_prev_directory_spec.js +++ b/spec/javascripts/repo/components/repo_prev_directory_spec.js @@ -1,47 +1,45 @@ import Vue from 'vue'; +import store from '~/repo/stores'; import repoPrevDirectory from '~/repo/components/repo_prev_directory.vue'; -import eventHub from '~/repo/event_hub'; +import { resetStore } from '../helpers'; describe('RepoPrevDirectory', () => { - function createComponent(propsData) { + let vm; + const parentLink = 'parent'; + function createComponent() { const RepoPrevDirectory = Vue.extend(repoPrevDirectory); - return new RepoPrevDirectory({ - propsData, - }).$mount(); - } - - it('renders a prev dir link', () => { - const prevUrl = 'prevUrl'; - const vm = createComponent({ - prevUrl, + const comp = new RepoPrevDirectory({ + store, }); - const link = vm.$el.querySelector('a'); - spyOn(vm, 'linkClicked'); + comp.$store.state.parentTreeUrl = parentLink; - expect(link.href).toMatch(`/${prevUrl}`); - expect(link.textContent).toEqual('...'); + return comp.$mount(); + } + + beforeEach(() => { + vm = createComponent(); + }); - link.click(); + afterEach(() => { + vm.$destroy(); - expect(vm.linkClicked).toHaveBeenCalledWith(prevUrl); + resetStore(vm.$store); }); - describe('methods', () => { - describe('linkClicked', () => { - it('$emits linkclicked with prevUrl', () => { - const prevUrl = 'prevUrl'; - const vm = createComponent({ - prevUrl, - }); + it('renders a prev dir link', () => { + const link = vm.$el.querySelector('a'); - spyOn(eventHub, '$emit'); + expect(link.href).toMatch(`/${parentLink}`); + expect(link.textContent).toEqual('...'); + }); - vm.linkClicked(prevUrl); + it('clicking row triggers getTreeData', () => { + spyOn(vm, 'getTreeData'); - expect(eventHub.$emit).toHaveBeenCalledWith('goToPreviousDirectoryClicked', prevUrl); - }); - }); + vm.$el.querySelector('td').click(); + + expect(vm.getTreeData).toHaveBeenCalledWith({ endpoint: parentLink }); }); }); diff --git a/spec/javascripts/repo/components/repo_preview_spec.js b/spec/javascripts/repo/components/repo_preview_spec.js index 4920cf02083..8d1a87494cf 100644 --- a/spec/javascripts/repo/components/repo_preview_spec.js +++ b/spec/javascripts/repo/components/repo_preview_spec.js @@ -1,23 +1,37 @@ import Vue from 'vue'; +import store from '~/repo/stores'; import repoPreview from '~/repo/components/repo_preview.vue'; -import RepoStore from '~/repo/stores/repo_store'; +import { file, resetStore } from '../helpers'; describe('RepoPreview', () => { + let vm; + function createComponent() { + const f = file(); const RepoPreview = Vue.extend(repoPreview); - return new RepoPreview().$mount(); + const comp = new RepoPreview({ + store, + }); + + f.active = true; + f.html = 'test'; + + comp.$store.state.openFiles.push(f); + + return comp.$mount(); } - it('renders a div with the activeFile html', () => { - const activeFile = { - html: '<p class="file-content">html</p>', - }; - RepoStore.activeFile = activeFile; + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); - const vm = createComponent(); + it('renders a div with the activeFile html', () => { + vm = createComponent(); expect(vm.$el.tagName).toEqual('DIV'); - expect(vm.$el.innerHTML).toContain(activeFile.html); + expect(vm.$el.innerHTML).toContain('test'); }); }); diff --git a/spec/javascripts/repo/components/repo_sidebar_spec.js b/spec/javascripts/repo/components/repo_sidebar_spec.js index 148f275e03d..7cb4dace491 100644 --- a/spec/javascripts/repo/components/repo_sidebar_spec.js +++ b/spec/javascripts/repo/components/repo_sidebar_spec.js @@ -1,32 +1,31 @@ import Vue from 'vue'; -import Helper from '~/repo/helpers/repo_helper'; -import RepoService from '~/repo/services/repo_service'; -import RepoStore from '~/repo/stores/repo_store'; +import store from '~/repo/stores'; import repoSidebar from '~/repo/components/repo_sidebar.vue'; -import { file } from '../mock_data'; +import { file, resetStore } from '../helpers'; describe('RepoSidebar', () => { let vm; - function createComponent() { + beforeEach(() => { const RepoSidebar = Vue.extend(repoSidebar); - return new RepoSidebar().$mount(); - } + vm = new RepoSidebar({ + store, + }); + + vm.$store.state.isRoot = true; + vm.$store.state.tree.push(file()); + + vm.$mount(); + }); afterEach(() => { vm.$destroy(); - RepoStore.files = []; - RepoStore.openedFiles = []; + resetStore(vm.$store); }); it('renders a sidebar', () => { - RepoStore.files = [file()]; - RepoStore.openedFiles = []; - RepoStore.isRoot = true; - - vm = createComponent(); const thead = vm.$el.querySelector('thead'); const tbody = vm.$el.querySelector('tbody'); @@ -41,139 +40,36 @@ describe('RepoSidebar', () => { expect(tbody.querySelector('.file')).toBeTruthy(); }); - it('does not render a thead, renders repo-file-options and sets sidebar-mini class if isMini', () => { - RepoStore.openedFiles = [{ - id: 0, - }]; - vm = createComponent(); - - expect(vm.$el.classList.contains('sidebar-mini')).toBeTruthy(); - expect(vm.$el.querySelector('thead')).toBeTruthy(); - expect(vm.$el.querySelector('thead .repo-file-options')).toBeTruthy(); - }); - - it('renders 5 loading files if tree is loading and not hasFiles', () => { - RepoStore.loading.tree = true; - RepoStore.files = []; - vm = createComponent(); + it('does not render a thead, renders repo-file-options and sets sidebar-mini class if isMini', (done) => { + vm.$store.state.openFiles.push(vm.$store.state.tree[0]); - expect(vm.$el.querySelectorAll('tbody .loading-file').length).toEqual(5); - }); - - it('renders a prev directory if is not root', () => { - RepoStore.files = [file()]; - RepoStore.isRoot = false; - RepoStore.loading.tree = false; - vm = createComponent(); - - expect(vm.$el.querySelector('tbody .prev-directory')).toBeTruthy(); - }); + Vue.nextTick(() => { + expect(vm.$el.classList.contains('sidebar-mini')).toBeTruthy(); + expect(vm.$el.querySelector('thead')).toBeTruthy(); + expect(vm.$el.querySelector('thead .repo-file-options')).toBeTruthy(); - describe('flattendFiles', () => { - it('returns a flattend array of files', () => { - const f = file(); - f.files.push(file('testing 123')); - const files = [f, file()]; - vm = createComponent(); - vm.files = files; - - expect(vm.flattendFiles.length).toBe(3); - expect(vm.flattendFiles[1].name).toBe('testing 123'); + done(); }); }); - describe('methods', () => { - describe('fileClicked', () => { - it('should fetch data for new file', () => { - spyOn(Helper, 'getContent').and.callThrough(); - RepoStore.files = [file()]; - RepoStore.isRoot = true; - vm = createComponent(); - - vm.fileClicked(RepoStore.files[0]); - - expect(Helper.getContent).toHaveBeenCalledWith(RepoStore.files[0]); - }); - - it('should not fetch data for already opened files', () => { - const f = file(); - spyOn(Helper, 'getFileFromPath').and.returnValue(f); - spyOn(RepoStore, 'setActiveFiles'); - vm = createComponent(); - vm.fileClicked(f); + it('renders 5 loading files if tree is loading', (done) => { + vm.$store.state.tree = []; + vm.$store.state.loading = true; - expect(RepoStore.setActiveFiles).toHaveBeenCalledWith(f); - }); + Vue.nextTick(() => { + expect(vm.$el.querySelectorAll('tbody .loading-file').length).toEqual(5); - it('should hide files in directory if already open', () => { - spyOn(Helper, 'setDirectoryToClosed').and.callThrough(); - const f = file(); - f.opened = true; - f.type = 'tree'; - RepoStore.files = [f]; - vm = createComponent(); - - vm.fileClicked(RepoStore.files[0]); - - expect(Helper.setDirectoryToClosed).toHaveBeenCalledWith(RepoStore.files[0]); - }); - - describe('submodule', () => { - it('opens submodule project URL', () => { - spyOn(gl.utils, 'visitUrl'); - - const f = file(); - f.type = 'submodule'; - - vm = createComponent(); - - vm.fileClicked(f); - - expect(gl.utils.visitUrl).toHaveBeenCalledWith('url'); - }); - }); - }); - - describe('goToPreviousDirectoryClicked', () => { - it('should hide files in directory if already open', () => { - const prevUrl = 'foo/bar'; - vm = createComponent(); - - vm.goToPreviousDirectoryClicked(prevUrl); - - expect(RepoService.url).toEqual(prevUrl); - }); + done(); }); + }); - describe('back button', () => { - beforeEach(() => { - const f = file(); - const file2 = Object.assign({}, file()); - file2.url = 'test'; - RepoStore.files = [f, file2]; - RepoStore.openedFiles = []; - RepoStore.isRoot = true; - - vm = createComponent(); - }); - - it('render previous file when using back button', () => { - spyOn(Helper, 'getContent').and.callThrough(); - - vm.fileClicked(RepoStore.files[1]); - expect(Helper.getContent).toHaveBeenCalledWith(RepoStore.files[1]); - - history.pushState({ - key: Math.random(), - }, '', RepoStore.files[1].url); - const popEvent = document.createEvent('Event'); - popEvent.initEvent('popstate', true, true); - window.dispatchEvent(popEvent); + it('renders a prev directory if is not root', (done) => { + vm.$store.state.isRoot = false; - expect(Helper.getContent.calls.mostRecent().args[0].url).toContain(RepoStore.files[1].url); + Vue.nextTick(() => { + expect(vm.$el.querySelector('tbody .prev-directory')).toBeTruthy(); - window.history.pushState({}, null, '/'); - }); + done(); }); }); }); diff --git a/spec/javascripts/repo/components/repo_spec.js b/spec/javascripts/repo/components/repo_spec.js index 3558a155728..b32d2c13af8 100644 --- a/spec/javascripts/repo/components/repo_spec.js +++ b/spec/javascripts/repo/components/repo_spec.js @@ -1,9 +1,8 @@ import Vue from 'vue'; +import store from '~/repo/stores'; import repo from '~/repo/components/repo.vue'; -import RepoStore from '~/repo/stores/repo_store'; -import Service from '~/repo/services/repo_service'; -import eventHub from '~/repo/event_hub'; -import createComponent from '../../helpers/vue_mount_component_helper'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { file, resetStore } from '../helpers'; describe('repo component', () => { let vm; @@ -11,86 +10,26 @@ describe('repo component', () => { beforeEach(() => { const Component = Vue.extend(repo); - RepoStore.currentBranch = 'master'; - - vm = createComponent(Component); + vm = createComponentWithStore(Component, store).$mount(); }); afterEach(() => { vm.$destroy(); - RepoStore.currentBranch = ''; + resetStore(vm.$store); }); - describe('createNewBranch', () => { - beforeEach(() => { - spyOn(history, 'pushState'); - }); - - describe('success', () => { - beforeEach(() => { - spyOn(Service, 'createBranch').and.returnValue(Promise.resolve({ - data: { - name: 'test', - }, - })); - }); - - it('calls createBranch with branchName', () => { - eventHub.$emit('createNewBranch', 'test'); - - expect(Service.createBranch).toHaveBeenCalledWith({ - branch: 'test', - ref: RepoStore.currentBranch, - }); - }); - - it('pushes new history state', (done) => { - RepoStore.currentBranch = 'master'; - - spyOn(vm, 'getCurrentLocation').and.returnValue('http://test.com/master'); - - eventHub.$emit('createNewBranch', 'test'); - - setTimeout(() => { - expect(history.pushState).toHaveBeenCalledWith(jasmine.anything(), '', 'http://test.com/test'); - done(); - }); - }); - - it('updates stores currentBranch', (done) => { - eventHub.$emit('createNewBranch', 'test'); - - setTimeout(() => { - expect(RepoStore.currentBranch).toBe('test'); - - done(); - }); - }); - }); - - describe('failure', () => { - beforeEach(() => { - spyOn(Service, 'createBranch').and.returnValue(Promise.reject({ - response: { - data: { - message: 'test', - }, - }, - })); - }); - - it('emits createNewBranchError event', (done) => { - spyOn(eventHub, '$emit').and.callThrough(); + it('does not render panel right when no files open', () => { + expect(vm.$el.querySelector('.panel-right')).toBeNull(); + }); - eventHub.$emit('createNewBranch', 'test'); + it('renders panel right when files are open', (done) => { + vm.$store.state.tree.push(file()); - setTimeout(() => { - expect(eventHub.$emit).toHaveBeenCalledWith('createNewBranchError', 'test'); + Vue.nextTick(() => { + expect(vm.$el.querySelector('.panel-right')).toBeNull(); - done(); - }); - }); + done(); }); }); }); diff --git a/spec/javascripts/repo/components/repo_tab_spec.js b/spec/javascripts/repo/components/repo_tab_spec.js index 37e297437f0..df0ca55aafc 100644 --- a/spec/javascripts/repo/components/repo_tab_spec.js +++ b/spec/javascripts/repo/components/repo_tab_spec.js @@ -1,47 +1,64 @@ import Vue from 'vue'; +import store from '~/repo/stores'; import repoTab from '~/repo/components/repo_tab.vue'; -import RepoStore from '~/repo/stores/repo_store'; +import { file, resetStore } from '../helpers'; describe('RepoTab', () => { + let vm; + function createComponent(propsData) { const RepoTab = Vue.extend(repoTab); return new RepoTab({ + store, propsData, }).$mount(); } + afterEach(() => { + resetStore(vm.$store); + }); + it('renders a close link and a name link', () => { - const tab = { - url: 'url', - name: 'name', - }; - const vm = createComponent({ - tab, + vm = createComponent({ + tab: file(), }); + vm.$store.state.openFiles.push(vm.tab); const close = vm.$el.querySelector('.close-btn'); - const name = vm.$el.querySelector(`a[title="${tab.url}"]`); - - spyOn(vm, 'closeTab'); - spyOn(vm, 'tabClicked'); + const name = vm.$el.querySelector(`a[title="${vm.tab.url}"]`); expect(close.querySelector('.fa-times')).toBeTruthy(); - expect(name.textContent.trim()).toEqual(tab.name); + expect(name.textContent.trim()).toEqual(vm.tab.name); + }); - close.click(); - name.click(); + it('calls setFileActive when clicking tab', () => { + vm = createComponent({ + tab: file(), + }); + + spyOn(vm, 'setFileActive'); + + vm.$el.click(); - expect(vm.closeTab).toHaveBeenCalledWith(tab); - expect(vm.tabClicked).toHaveBeenCalledWith(tab); + expect(vm.setFileActive).toHaveBeenCalledWith(vm.tab); + }); + + it('calls closeFile when clicking close button', () => { + vm = createComponent({ + tab: file(), + }); + + spyOn(vm, 'closeFile'); + + vm.$el.querySelector('.close-btn').click(); + + expect(vm.closeFile).toHaveBeenCalledWith({ file: vm.tab }); }); it('renders an fa-circle icon if tab is changed', () => { - const tab = { - url: 'url', - name: 'name', - changed: true, - }; - const vm = createComponent({ + const tab = file(); + tab.changed = true; + vm = createComponent({ tab, }); @@ -50,38 +67,41 @@ describe('RepoTab', () => { describe('methods', () => { describe('closeTab', () => { - it('returns undefined and does not $emit if file is changed', () => { - const tab = { - url: 'url', - name: 'name', - changed: true, - }; - const vm = createComponent({ + it('does not close tab if is changed', (done) => { + const tab = file(); + tab.changed = true; + tab.opened = true; + vm = createComponent({ tab, }); - - spyOn(RepoStore, 'removeFromOpenedFiles'); + vm.$store.state.openFiles.push(tab); + vm.$store.dispatch('setFileActive', tab); vm.$el.querySelector('.close-btn').click(); - expect(RepoStore.removeFromOpenedFiles).not.toHaveBeenCalled(); + vm.$nextTick(() => { + expect(tab.opened).toBeTruthy(); + + done(); + }); }); - it('$emits tabclosed event with file obj', () => { - const tab = { - url: 'url', - name: 'name', - changed: false, - }; - const vm = createComponent({ + it('closes tab when clicking close btn', (done) => { + const tab = file('lose'); + tab.opened = true; + vm = createComponent({ tab, }); - - spyOn(RepoStore, 'removeFromOpenedFiles'); + vm.$store.state.openFiles.push(tab); + vm.$store.dispatch('setFileActive', tab); vm.$el.querySelector('.close-btn').click(); - expect(RepoStore.removeFromOpenedFiles).toHaveBeenCalledWith(tab); + vm.$nextTick(() => { + expect(tab.opened).toBeFalsy(); + + done(); + }); }); }); }); diff --git a/spec/javascripts/repo/components/repo_tabs_spec.js b/spec/javascripts/repo/components/repo_tabs_spec.js index 431129bc866..d0246cc72e6 100644 --- a/spec/javascripts/repo/components/repo_tabs_spec.js +++ b/spec/javascripts/repo/components/repo_tabs_spec.js @@ -1,35 +1,38 @@ import Vue from 'vue'; -import RepoStore from '~/repo/stores/repo_store'; +import store from '~/repo/stores'; import repoTabs from '~/repo/components/repo_tabs.vue'; +import { file, resetStore } from '../helpers'; describe('RepoTabs', () => { - const openedFiles = [{ - id: 0, - active: true, - }, { - id: 1, - }]; + const openedFiles = [file(), file()]; + let vm; function createComponent() { const RepoTabs = Vue.extend(repoTabs); - return new RepoTabs().$mount(); + return new RepoTabs({ + store, + }).$mount(); } afterEach(() => { - RepoStore.openedFiles = []; + resetStore(vm.$store); }); - it('renders a list of tabs', () => { - RepoStore.openedFiles = openedFiles; + it('renders a list of tabs', (done) => { + vm = createComponent(); + openedFiles[0].active = true; + vm.$store.state.openFiles = openedFiles; - const vm = createComponent(); - const tabs = [...vm.$el.querySelectorAll(':scope > li')]; + vm.$nextTick(() => { + const tabs = [...vm.$el.querySelectorAll(':scope > li')]; - expect(vm.$el.id).toEqual('tabs'); - expect(tabs.length).toEqual(3); - expect(tabs[0].classList.contains('active')).toBeTruthy(); - expect(tabs[1].classList.contains('active')).toBeFalsy(); - expect(tabs[2].classList.contains('tabs-divider')).toBeTruthy(); + expect(tabs.length).toEqual(3); + expect(tabs[0].classList.contains('active')).toBeTruthy(); + expect(tabs[1].classList.contains('active')).toBeFalsy(); + expect(tabs[2].classList.contains('tabs-divider')).toBeTruthy(); + + done(); + }); }); }); diff --git a/spec/javascripts/repo/helpers.js b/spec/javascripts/repo/helpers.js new file mode 100644 index 00000000000..376c291c64b --- /dev/null +++ b/spec/javascripts/repo/helpers.js @@ -0,0 +1,20 @@ +import { decorateData } from '~/repo/stores/utils'; +import state from '~/repo/stores/state'; + +export const resetStore = (store) => { + store.replaceState(state()); +}; + +export const file = (name = 'name', id = name, type = '') => decorateData({ + id, + type, + icon: 'icon', + url: 'url', + name, + path: name, + last_commit: { + id: '123', + message: 'test', + committed_date: new Date().toISOString(), + }, +}); diff --git a/spec/javascripts/repo/mock_data.js b/spec/javascripts/repo/mock_data.js deleted file mode 100644 index 71e275caf09..00000000000 --- a/spec/javascripts/repo/mock_data.js +++ /dev/null @@ -1,14 +0,0 @@ -import RepoHelper from '~/repo/helpers/repo_helper'; - -// eslint-disable-next-line import/prefer-default-export -export const file = (name = 'name', id = name) => RepoHelper.serializeRepoEntity('blob', { - id, - icon: 'icon', - url: 'url', - name, - last_commit: { - id: '123', - message: 'test', - committed_date: new Date().toISOString(), - }, -}); diff --git a/spec/javascripts/repo/services/repo_service_spec.js b/spec/javascripts/repo/services/repo_service_spec.js deleted file mode 100644 index 6f530770525..00000000000 --- a/spec/javascripts/repo/services/repo_service_spec.js +++ /dev/null @@ -1,171 +0,0 @@ -import axios from 'axios'; -import RepoService from '~/repo/services/repo_service'; -import RepoStore from '~/repo/stores/repo_store'; -import Api from '~/api'; - -describe('RepoService', () => { - it('has default json format param', () => { - expect(RepoService.options.params.format).toBe('json'); - }); - - describe('buildParams', () => { - let newParams; - const url = 'url'; - - beforeEach(() => { - newParams = {}; - - spyOn(Object, 'assign').and.returnValue(newParams); - }); - - it('clones params', () => { - const params = RepoService.buildParams(url); - - expect(Object.assign).toHaveBeenCalledWith({}, RepoService.options.params); - - expect(params).toBe(newParams); - }); - - it('sets and returns viewer params to richif urlIsRichBlob is true', () => { - spyOn(RepoService, 'urlIsRichBlob').and.returnValue(true); - - const params = RepoService.buildParams(url); - - expect(params.viewer).toEqual('rich'); - }); - - it('returns params urlIsRichBlob is false', () => { - spyOn(RepoService, 'urlIsRichBlob').and.returnValue(false); - - const params = RepoService.buildParams(url); - - expect(params.viewer).toBeUndefined(); - }); - - it('calls urlIsRichBlob with the objects url prop if no url arg is provided', () => { - spyOn(RepoService, 'urlIsRichBlob'); - RepoService.url = url; - - RepoService.buildParams(); - - expect(RepoService.urlIsRichBlob).toHaveBeenCalledWith(url); - }); - }); - - describe('urlIsRichBlob', () => { - it('returns true for md extension', () => { - const isRichBlob = RepoService.urlIsRichBlob('url.md'); - - expect(isRichBlob).toBeTruthy(); - }); - - it('returns false for js extension', () => { - const isRichBlob = RepoService.urlIsRichBlob('url.js'); - - expect(isRichBlob).toBeFalsy(); - }); - }); - - describe('getContent', () => { - const params = {}; - const url = 'url'; - const requestPromise = Promise.resolve(); - - beforeEach(() => { - spyOn(RepoService, 'buildParams').and.returnValue(params); - spyOn(axios, 'get').and.returnValue(requestPromise); - }); - - it('calls buildParams and axios.get', () => { - const request = RepoService.getContent(url); - - expect(RepoService.buildParams).toHaveBeenCalledWith(url); - expect(axios.get).toHaveBeenCalledWith(url, { - params, - }); - expect(request).toBe(requestPromise); - }); - - it('uses object url prop if no url arg is provided', () => { - RepoService.url = url; - - RepoService.getContent(); - - expect(axios.get).toHaveBeenCalledWith(url, { - params, - }); - }); - }); - - describe('getBase64Content', () => { - const url = 'url'; - const response = { data: 'data' }; - - beforeEach(() => { - spyOn(RepoService, 'bufferToBase64'); - spyOn(axios, 'get').and.returnValue(Promise.resolve(response)); - }); - - it('calls axios.get and bufferToBase64 on completion', (done) => { - const request = RepoService.getBase64Content(url); - - expect(axios.get).toHaveBeenCalledWith(url, { - responseType: 'arraybuffer', - }); - expect(request).toEqual(jasmine.any(Promise)); - - request.then(() => { - expect(RepoService.bufferToBase64).toHaveBeenCalledWith(response.data); - done(); - }).catch(done.fail); - }); - }); - - describe('commitFiles', () => { - it('calls commitMultiple and .then commitFlash', (done) => { - const projectId = 'projectId'; - const payload = {}; - RepoStore.projectId = projectId; - - spyOn(Api, 'commitMultiple').and.returnValue(Promise.resolve()); - spyOn(RepoService, 'commitFlash'); - - const apiPromise = RepoService.commitFiles(payload); - - expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, payload); - - apiPromise.then(() => { - expect(RepoService.commitFlash).toHaveBeenCalled(); - done(); - }).catch(done.fail); - }); - }); - - describe('commitFlash', () => { - it('calls Flash with data.message', () => { - const data = { - message: 'message', - }; - spyOn(window, 'Flash'); - - RepoService.commitFlash(data); - - expect(window.Flash).toHaveBeenCalledWith(data.message); - }); - - it('calls Flash with success string if short_id and stats', () => { - const data = { - short_id: 'short_id', - stats: { - additions: '4', - deletions: '5', - }, - }; - spyOn(window, 'Flash'); - - RepoService.commitFlash(data); - - expect(window.Flash).toHaveBeenCalledWith(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice'); - }); - }); -}); diff --git a/spec/javascripts/search_autocomplete_spec.js b/spec/javascripts/search_autocomplete_spec.js index cf811af3d6c..5e55a5d2686 100644 --- a/spec/javascripts/search_autocomplete_spec.js +++ b/spec/javascripts/search_autocomplete_spec.js @@ -3,7 +3,6 @@ import '~/gl_dropdown'; import '~/search_autocomplete'; import '~/lib/utils/common_utils'; -import 'vendor/fuzzaldrin-plus'; (function() { var assertLinks, dashboardIssuesPath, dashboardMRsPath, groupIssuesPath, groupMRsPath, groupName, mockDashboardOptions, mockGroupOptions, mockProjectOptions, projectIssuesPath, projectMRsPath, projectName, userId, widget; diff --git a/spec/javascripts/sidebar/mock_data.js b/spec/javascripts/sidebar/mock_data.js index e2b6bcabc98..0682b463043 100644 --- a/spec/javascripts/sidebar/mock_data.js +++ b/spec/javascripts/sidebar/mock_data.js @@ -109,12 +109,14 @@ const sidebarMockData = { labels: [], web_url: '/root/some-project/issues/5', }, + '/gitlab-org/gitlab-shell/issues/5/toggle_subscription': {}, }, }; export default { mediator: { endpoint: '/gitlab-org/gitlab-shell/issues/5.json', + toggleSubscriptionEndpoint: '/gitlab-org/gitlab-shell/issues/5/toggle_subscription', moveIssueEndpoint: '/gitlab-org/gitlab-shell/issues/5/move', projectsAutocompleteEndpoint: '/autocomplete/projects?project_id=15', editable: true, diff --git a/spec/javascripts/sidebar/participants_spec.js b/spec/javascripts/sidebar/participants_spec.js new file mode 100644 index 00000000000..30cc549c7c0 --- /dev/null +++ b/spec/javascripts/sidebar/participants_spec.js @@ -0,0 +1,174 @@ +import Vue from 'vue'; +import participants from '~/sidebar/components/participants/participants.vue'; +import mountComponent from '../helpers/vue_mount_component_helper'; + +const PARTICIPANT = { + id: 1, + state: 'active', + username: 'marcene', + name: 'Allie Will', + web_url: 'foo.com', + avatar_url: 'gravatar.com/avatar/xxx', +}; + +const PARTICIPANT_LIST = [ + PARTICIPANT, + { ...PARTICIPANT, id: 2 }, + { ...PARTICIPANT, id: 3 }, +]; + +describe('Participants', function () { + let vm; + let Participants; + + beforeEach(() => { + Participants = Vue.extend(participants); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('collapsed sidebar state', () => { + it('shows loading spinner when loading', () => { + vm = mountComponent(Participants, { + loading: true, + }); + + expect(vm.$el.querySelector('.js-participants-collapsed-loading-icon')).toBeDefined(); + }); + + it('shows participant count when given', () => { + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + }); + const countEl = vm.$el.querySelector('.js-participants-collapsed-count'); + + expect(countEl.textContent.trim()).toBe(`${PARTICIPANT_LIST.length}`); + }); + + it('shows full participant count when there are hidden participants', () => { + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 1, + }); + const countEl = vm.$el.querySelector('.js-participants-collapsed-count'); + + expect(countEl.textContent.trim()).toBe(`${PARTICIPANT_LIST.length}`); + }); + }); + + describe('expanded sidebar state', () => { + it('shows loading spinner when loading', () => { + vm = mountComponent(Participants, { + loading: true, + }); + + expect(vm.$el.querySelector('.js-participants-expanded-loading-icon')).toBeDefined(); + }); + + it('when only showing visible participants, shows an avatar only for each participant under the limit', (done) => { + const numberOfLessParticipants = 2; + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants, + }); + vm.isShowingMoreParticipants = false; + + Vue.nextTick() + .then(() => { + const participantEls = vm.$el.querySelectorAll('.js-participants-author'); + + expect(participantEls.length).toBe(numberOfLessParticipants); + }) + .then(done) + .catch(done.fail); + }); + + it('when only showing all participants, each has an avatar', (done) => { + const numberOfLessParticipants = 2; + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants, + }); + vm.isShowingMoreParticipants = true; + + Vue.nextTick() + .then(() => { + const participantEls = vm.$el.querySelectorAll('.js-participants-author'); + + expect(participantEls.length).toBe(PARTICIPANT_LIST.length); + }) + .then(done) + .catch(done.fail); + }); + + it('does not have more participants link when they can all be shown', () => { + const numberOfLessParticipants = 100; + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants, + }); + const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button'); + + expect(PARTICIPANT_LIST.length).toBeLessThan(numberOfLessParticipants); + expect(moreParticipantLink).toBeNull(); + }); + + it('when too many participants, has more participants link to show more', (done) => { + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + vm.isShowingMoreParticipants = false; + + Vue.nextTick() + .then(() => { + const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button'); + + expect(moreParticipantLink.textContent.trim()).toBe('+ 1 more'); + }) + .then(done) + .catch(done.fail); + }); + + it('when too many participants and already showing them, has more participants link to show less', (done) => { + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + vm.isShowingMoreParticipants = true; + + Vue.nextTick() + .then(() => { + const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button'); + + expect(moreParticipantLink.textContent.trim()).toBe('- show less'); + }) + .then(done) + .catch(done.fail); + }); + + it('clicking more participants link emits event', () => { + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button'); + + expect(vm.isShowingMoreParticipants).toBe(false); + + moreParticipantLink.click(); + + expect(vm.isShowingMoreParticipants).toBe(true); + }); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_mediator_spec.js b/spec/javascripts/sidebar/sidebar_mediator_spec.js index 3aa8ca5db0d..7deb1fd2118 100644 --- a/spec/javascripts/sidebar/sidebar_mediator_spec.js +++ b/spec/javascripts/sidebar/sidebar_mediator_spec.js @@ -57,8 +57,8 @@ describe('Sidebar mediator', () => { .then(() => { expect(this.mediator.service.getProjectsAutocomplete).toHaveBeenCalledWith(searchTerm); expect(this.mediator.store.setAutocompleteProjects).toHaveBeenCalled(); - done(); }) + .then(done) .catch(done.fail); }); @@ -72,8 +72,21 @@ describe('Sidebar mediator', () => { .then(() => { expect(this.mediator.service.moveIssue).toHaveBeenCalledWith(moveToProjectId); expect(gl.utils.visitUrl).toHaveBeenCalledWith('/root/some-project/issues/5'); - done(); }) + .then(done) + .catch(done.fail); + }); + + it('toggle subscription', (done) => { + this.mediator.store.setSubscribedState(false); + spyOn(this.mediator.service, 'toggleSubscription').and.callThrough(); + + this.mediator.toggleSubscription() + .then(() => { + expect(this.mediator.service.toggleSubscription).toHaveBeenCalled(); + expect(this.mediator.store.subscribed).toEqual(true); + }) + .then(done) .catch(done.fail); }); }); diff --git a/spec/javascripts/sidebar/sidebar_service_spec.js b/spec/javascripts/sidebar/sidebar_service_spec.js index a4bd8ba8d88..7324d34d84a 100644 --- a/spec/javascripts/sidebar/sidebar_service_spec.js +++ b/spec/javascripts/sidebar/sidebar_service_spec.js @@ -7,6 +7,7 @@ describe('Sidebar service', () => { Vue.http.interceptors.push(Mock.sidebarMockInterceptor); this.service = new SidebarService({ endpoint: '/gitlab-org/gitlab-shell/issues/5.json', + toggleSubscriptionEndpoint: '/gitlab-org/gitlab-shell/issues/5/toggle_subscription', moveIssueEndpoint: '/gitlab-org/gitlab-shell/issues/5/move', projectsAutocompleteEndpoint: '/autocomplete/projects?project_id=15', }); @@ -23,6 +24,7 @@ describe('Sidebar service', () => { expect(resp).toBeDefined(); done(); }) + .then(done) .catch(done.fail); }); @@ -30,8 +32,8 @@ describe('Sidebar service', () => { this.service.update('issue[assignee_ids]', [1]) .then((resp) => { expect(resp).toBeDefined(); - done(); }) + .then(done) .catch(done.fail); }); @@ -39,8 +41,8 @@ describe('Sidebar service', () => { this.service.getProjectsAutocomplete() .then((resp) => { expect(resp).toBeDefined(); - done(); }) + .then(done) .catch(done.fail); }); @@ -48,8 +50,17 @@ describe('Sidebar service', () => { this.service.moveIssue(123) .then((resp) => { expect(resp).toBeDefined(); - done(); }) + .then(done) + .catch(done.fail); + }); + + it('toggles the subscription', (done) => { + this.service.toggleSubscription() + .then((resp) => { + expect(resp).toBeDefined(); + }) + .then(done) .catch(done.fail); }); }); diff --git a/spec/javascripts/sidebar/sidebar_store_spec.js b/spec/javascripts/sidebar/sidebar_store_spec.js index 69eb3839d67..51dee64fb93 100644 --- a/spec/javascripts/sidebar/sidebar_store_spec.js +++ b/spec/javascripts/sidebar/sidebar_store_spec.js @@ -2,21 +2,36 @@ import SidebarStore from '~/sidebar/stores/sidebar_store'; import Mock from './mock_data'; import UsersMockHelper from '../helpers/user_mock_data_helper'; -describe('Sidebar store', () => { - const assignee = { - id: 2, - name: 'gitlab user 2', - username: 'gitlab2', - avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - }; - - const anotherAssignee = { - id: 3, - name: 'gitlab user 3', - username: 'gitlab3', - avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - }; +const ASSIGNEE = { + id: 2, + name: 'gitlab user 2', + username: 'gitlab2', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', +}; + +const ANOTHER_ASSINEE = { + id: 3, + name: 'gitlab user 3', + username: 'gitlab3', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', +}; + +const PARTICIPANT = { + id: 1, + state: 'active', + username: 'marcene', + name: 'Allie Will', + web_url: 'foo.com', + avatar_url: 'gravatar.com/avatar/xxx', +}; + +const PARTICIPANT_LIST = [ + PARTICIPANT, + { ...PARTICIPANT, id: 2 }, + { ...PARTICIPANT, id: 3 }, +]; +describe('Sidebar store', () => { beforeEach(() => { this.store = new SidebarStore({ currentUser: { @@ -40,23 +55,23 @@ describe('Sidebar store', () => { }); it('adds a new assignee', () => { - this.store.addAssignee(assignee); + this.store.addAssignee(ASSIGNEE); expect(this.store.assignees.length).toEqual(1); }); it('removes an assignee', () => { - this.store.removeAssignee(assignee); + this.store.removeAssignee(ASSIGNEE); expect(this.store.assignees.length).toEqual(0); }); it('finds an existent assignee', () => { let foundAssignee; - this.store.addAssignee(assignee); - foundAssignee = this.store.findAssignee(assignee); + this.store.addAssignee(ASSIGNEE); + foundAssignee = this.store.findAssignee(ASSIGNEE); expect(foundAssignee).toBeDefined(); - expect(foundAssignee).toEqual(assignee); - foundAssignee = this.store.findAssignee(anotherAssignee); + expect(foundAssignee).toEqual(ASSIGNEE); + foundAssignee = this.store.findAssignee(ANOTHER_ASSINEE); expect(foundAssignee).toBeUndefined(); }); @@ -65,6 +80,28 @@ describe('Sidebar store', () => { expect(this.store.assignees.length).toEqual(0); }); + it('sets participants data', () => { + expect(this.store.participants.length).toEqual(0); + + this.store.setParticipantsData({ + participants: PARTICIPANT_LIST, + }); + + expect(this.store.isFetching.participants).toEqual(false); + expect(this.store.participants.length).toEqual(PARTICIPANT_LIST.length); + }); + + it('sets subcriptions data', () => { + expect(this.store.subscribed).toEqual(null); + + this.store.setSubscriptionsData({ + subscribed: true, + }); + + expect(this.store.isFetching.subscriptions).toEqual(false); + expect(this.store.subscribed).toEqual(true); + }); + it('set assigned data', () => { const users = { assignees: UsersMockHelper.createNumberRandomUsers(3), @@ -75,6 +112,14 @@ describe('Sidebar store', () => { expect(this.store.assignees.length).toEqual(3); }); + it('sets fetching state', () => { + expect(this.store.isFetching.participants).toEqual(true); + + this.store.setFetchingState('participants', false); + + expect(this.store.isFetching.participants).toEqual(false); + }); + it('set time tracking data', () => { this.store.setTimeTrackingData(Mock.time); expect(this.store.timeEstimate).toEqual(Mock.time.time_estimate); @@ -90,6 +135,14 @@ describe('Sidebar store', () => { expect(this.store.autocompleteProjects).toEqual(projects); }); + it('sets subscribed state', () => { + expect(this.store.subscribed).toEqual(null); + + this.store.setSubscribedState(true); + + expect(this.store.subscribed).toEqual(true); + }); + it('set move to project ID', () => { const projectId = 7; this.store.setMoveToProjectId(projectId); diff --git a/spec/javascripts/sidebar/sidebar_subscriptions_spec.js b/spec/javascripts/sidebar/sidebar_subscriptions_spec.js new file mode 100644 index 00000000000..7adf22b0f1f --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_subscriptions_spec.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; +import sidebarSubscriptions from '~/sidebar/components/subscriptions/sidebar_subscriptions.vue'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import eventHub from '~/sidebar/event_hub'; +import mountComponent from '../helpers/vue_mount_component_helper'; +import Mock from './mock_data'; + +describe('Sidebar Subscriptions', function () { + let vm; + let SidebarSubscriptions; + + beforeEach(() => { + SidebarSubscriptions = Vue.extend(sidebarSubscriptions); + // Setup the stores, services, etc + // eslint-disable-next-line no-new + new SidebarMediator(Mock.mediator); + }); + + afterEach(() => { + vm.$destroy(); + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + }); + + it('calls the mediator toggleSubscription on event', () => { + spyOn(SidebarMediator.prototype, 'toggleSubscription').and.returnValue(Promise.resolve()); + vm = mountComponent(SidebarSubscriptions, {}); + + eventHub.$emit('toggleSubscription'); + + expect(SidebarMediator.prototype.toggleSubscription).toHaveBeenCalled(); + }); +}); diff --git a/spec/javascripts/sidebar/subscriptions_spec.js b/spec/javascripts/sidebar/subscriptions_spec.js new file mode 100644 index 00000000000..9b33dd02fb9 --- /dev/null +++ b/spec/javascripts/sidebar/subscriptions_spec.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue'; +import mountComponent from '../helpers/vue_mount_component_helper'; + +describe('Subscriptions', function () { + let vm; + let Subscriptions; + + beforeEach(() => { + Subscriptions = Vue.extend(subscriptions); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('shows loading spinner when loading', () => { + vm = mountComponent(Subscriptions, { + loading: true, + subscribed: undefined, + }); + + expect(vm.$refs.loadingButton.loading).toBe(true); + expect(vm.$refs.loadingButton.label).toBeUndefined(); + }); + + it('has "Subscribe" text when currently not subscribed', () => { + vm = mountComponent(Subscriptions, { + subscribed: false, + }); + + expect(vm.$refs.loadingButton.label).toBe('Subscribe'); + }); + + it('has "Unsubscribe" text when currently not subscribed', () => { + vm = mountComponent(Subscriptions, { + subscribed: true, + }); + + expect(vm.$refs.loadingButton.label).toBe('Unsubscribe'); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js index 690665ae12c..33ed0cb4342 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import { statusIconEntityMap } from '~/vue_shared/ci_status_icons'; import pipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline'; import mockData from '../mock_data'; @@ -29,14 +28,6 @@ describe('MRWidgetPipeline', () => { }); describe('computed', () => { - describe('svg', () => { - it('should have the proper SVG icon', () => { - const vm = createComponent({ pipeline: mockData.pipeline }); - - expect(vm.svg).toEqual(statusIconEntityMap.icon_status_failed); - }); - }); - describe('hasPipeline', () => { it('should return true when there is a pipeline', () => { expect(Object.keys(mockData.pipeline).length).toBeGreaterThan(0); @@ -142,6 +133,7 @@ describe('MRWidgetPipeline', () => { Vue.nextTick(() => { expect(el.querySelectorAll('.js-ci-error').length).toEqual(1); expect(el.innerText).toContain('Could not connect to the CI server'); + expect(el.querySelector('.ci-status-icon svg use').getAttribute('xlink:href')).toContain('status_failed'); done(); }); }); diff --git a/spec/javascripts/vue_shared/ci_action_icons_spec.js b/spec/javascripts/vue_shared/ci_action_icons_spec.js deleted file mode 100644 index 3d53a5ab24d..00000000000 --- a/spec/javascripts/vue_shared/ci_action_icons_spec.js +++ /dev/null @@ -1,27 +0,0 @@ -import getActionIcon from '~/vue_shared/ci_action_icons'; -import cancelSVG from 'icons/_icon_action_cancel.svg'; -import retrySVG from 'icons/_icon_action_retry.svg'; -import playSVG from 'icons/_icon_action_play.svg'; -import stopSVG from 'icons/_icon_action_stop.svg'; - -describe('getActionIcon', () => { - it('should return an empty string', () => { - expect(getActionIcon()).toEqual(''); - }); - - it('should return cancel svg', () => { - expect(getActionIcon('icon_action_cancel')).toEqual(cancelSVG); - }); - - it('should return retry svg', () => { - expect(getActionIcon('icon_action_retry')).toEqual(retrySVG); - }); - - it('should return play svg', () => { - expect(getActionIcon('icon_action_play')).toEqual(playSVG); - }); - - it('should render stop svg', () => { - expect(getActionIcon('icon_action_stop')).toEqual(stopSVG); - }); -}); diff --git a/spec/javascripts/vue_shared/ci_status_icon_spec.js b/spec/javascripts/vue_shared/ci_status_icon_spec.js deleted file mode 100644 index b6621d6054d..00000000000 --- a/spec/javascripts/vue_shared/ci_status_icon_spec.js +++ /dev/null @@ -1,27 +0,0 @@ -import { borderlessStatusIconEntityMap, statusIconEntityMap } from '~/vue_shared/ci_status_icons'; - -describe('CI status icons', () => { - const statuses = [ - 'icon_status_canceled', - 'icon_status_created', - 'icon_status_failed', - 'icon_status_manual', - 'icon_status_pending', - 'icon_status_running', - 'icon_status_skipped', - 'icon_status_success', - 'icon_status_warning', - ]; - - it('should have a dictionary for borderless icons', () => { - statuses.forEach((status) => { - expect(borderlessStatusIconEntityMap[status]).toBeDefined(); - }); - }); - - it('should have a dictionary for icons', () => { - statuses.forEach((status) => { - expect(statusIconEntityMap[status]).toBeDefined(); - }); - }); -}); diff --git a/spec/javascripts/vue_shared/components/ci_badge_link_spec.js b/spec/javascripts/vue_shared/components/ci_badge_link_spec.js index ba303738f71..8762ce9903b 100644 --- a/spec/javascripts/vue_shared/components/ci_badge_link_spec.js +++ b/spec/javascripts/vue_shared/components/ci_badge_link_spec.js @@ -11,63 +11,63 @@ describe('CI Badge Link Component', () => { text: 'canceled', label: 'canceled', group: 'canceled', - icon: 'icon_status_canceled', + icon: 'status_canceled', details_path: 'status/canceled', }, created: { text: 'created', label: 'created', group: 'created', - icon: 'icon_status_created', + icon: 'status_created', details_path: 'status/created', }, failed: { text: 'failed', label: 'failed', group: 'failed', - icon: 'icon_status_failed', + icon: 'status_failed', details_path: 'status/failed', }, manual: { text: 'manual', label: 'manual action', group: 'manual', - icon: 'icon_status_manual', + icon: 'status_manual', details_path: 'status/manual', }, pending: { text: 'pending', label: 'pending', group: 'pending', - icon: 'icon_status_pending', + icon: 'status_pending', details_path: 'status/pending', }, running: { text: 'running', label: 'running', group: 'running', - icon: 'icon_status_running', + icon: 'status_running', details_path: 'status/running', }, skipped: { text: 'skipped', label: 'skipped', group: 'skipped', - icon: 'icon_status_skipped', + icon: 'status_skipped', details_path: 'status/skipped', }, success_warining: { text: 'passed', label: 'passed', group: 'success_with_warnings', - icon: 'icon_status_warning', + icon: 'status_warning', details_path: 'status/warning', }, success: { text: 'passed', label: 'passed', group: 'passed', - icon: 'icon_status_success', + icon: 'status_success', details_path: 'status/passed', }, }; diff --git a/spec/javascripts/vue_shared/components/icon_spec.js b/spec/javascripts/vue_shared/components/icon_spec.js new file mode 100644 index 00000000000..104da4473ce --- /dev/null +++ b/spec/javascripts/vue_shared/components/icon_spec.js @@ -0,0 +1,48 @@ +import Vue from 'vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +describe('Sprite Icon Component', function () { + describe('Initialization', function () { + let icon; + + beforeEach(function () { + const IconComponent = Vue.extend(Icon); + + icon = mountComponent(IconComponent, { + name: 'test', + size: 99, + cssClasses: 'extraclasses', + }); + }); + + afterEach(() => { + icon.$destroy(); + }); + + it('should return a defined Vue component', function () { + expect(icon).toBeDefined(); + }); + + it('should have <svg> as a child element', function () { + expect(icon.$el.tagName).toBe('svg'); + }); + + it('should have <use> as a child element with the correct href', function () { + expect(icon.$el.firstChild.tagName).toBe('use'); + expect(icon.$el.firstChild.getAttribute('xlink:href')).toBe(`${gon.sprite_icons}#test`); + }); + + it('should properly compute iconSizeClass', function () { + expect(icon.iconSizeClass).toBe('s99'); + }); + + it('should properly render img css', function () { + const classList = icon.$el.classList; + const containsSizeClass = classList.contains('s99'); + const containsCustomClass = classList.contains('extraclasses'); + expect(containsSizeClass).toBe(true); + expect(containsCustomClass).toBe(true); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/markdown/field_spec.js b/spec/javascripts/vue_shared/components/markdown/field_spec.js index 60a5c2ae74e..65c49b9f30b 100644 --- a/spec/javascripts/vue_shared/components/markdown/field_spec.js +++ b/spec/javascripts/vue_shared/components/markdown/field_spec.js @@ -42,12 +42,14 @@ describe('Markdown field component', () => { beforeEach(() => { spyOn(Vue.http, 'post').and.callFake(() => new Promise((resolve) => { - resolve({ - json() { - return { - body: '<p>markdown preview</p>', - }; - }, + setTimeout(() => { + resolve({ + json() { + return { + body: '<p>markdown preview</p>', + }; + }, + }); }); })); diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js index 52e450e9ba5..8450ad9dbcb 100644 --- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js +++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js @@ -11,6 +11,7 @@ describe('User Avatar Link Component', function () { imgCssClasses: 'myextraavatarclass', tooltipText: 'tooltip text', tooltipPlacement: 'bottom', + username: 'username', }; const UserAvatarLinkComponent = Vue.extend(UserAvatarLink); @@ -47,4 +48,42 @@ describe('User Avatar Link Component', function () { expect(this.userAvatarLink[key]).toBeDefined(); }); }); + + describe('no username', function () { + beforeEach(function (done) { + this.userAvatarLink.username = ''; + + Vue.nextTick(done); + }); + + it('should only render image tag in link', function () { + const childElements = this.userAvatarLink.$el.childNodes; + expect(childElements[0].tagName).toBe('IMG'); + + // Vue will render the hidden component as <!----> + expect(childElements[1].tagName).toBeUndefined(); + }); + + it('should render avatar image tooltip', function () { + expect(this.userAvatarLink.$el.querySelector('img').dataset.originalTitle).toEqual(this.propsData.tooltipText); + }); + }); + + describe('username', function () { + it('should not render avatar image tooltip', function () { + expect(this.userAvatarLink.$el.querySelector('img').dataset.originalTitle).toEqual(''); + }); + + it('should render username prop in <span>', function () { + expect(this.userAvatarLink.$el.querySelector('span').innerText.trim()).toEqual(this.propsData.username); + }); + + it('should render text tooltip for <span>', function () { + expect(this.userAvatarLink.$el.querySelector('span').dataset.originalTitle).toEqual(this.propsData.tooltipText); + }); + + it('should render text tooltip placement for <span>', function () { + expect(this.userAvatarLink.$el.querySelector('span').getAttribute('tooltip-placement')).toEqual(this.propsData.tooltipPlacement); + }); + }); }); diff --git a/spec/lib/additional_email_headers_interceptor_spec.rb b/spec/lib/additional_email_headers_interceptor_spec.rb index 580450eef1e..b5c1a360ba9 100644 --- a/spec/lib/additional_email_headers_interceptor_spec.rb +++ b/spec/lib/additional_email_headers_interceptor_spec.rb @@ -1,12 +1,29 @@ require 'spec_helper' describe AdditionalEmailHeadersInterceptor do - it 'adds Auto-Submitted header' do - mail = ActionMailer::Base.mail(to: 'test@mail.com', from: 'info@mail.com', body: 'hello').deliver + let(:mail) do + ActionMailer::Base.mail(to: 'test@mail.com', from: 'info@mail.com', body: 'hello') + end + + before do + mail.deliver_now + end + it 'adds Auto-Submitted header' do expect(mail.header['To'].value).to eq('test@mail.com') expect(mail.header['From'].value).to eq('info@mail.com') expect(mail.header['Auto-Submitted'].value).to eq('auto-generated') expect(mail.header['X-Auto-Response-Suppress'].value).to eq('All') end + + context 'when the same mail object is sent twice' do + before do + mail.deliver_now + end + + it 'does not add the Auto-Submitted header twice' do + expect(mail.header['Auto-Submitted'].value).to eq('auto-generated') + expect(mail.header['X-Auto-Response-Suppress'].value).to eq('All') + end + end end diff --git a/spec/lib/banzai/filter/issue_reference_filter_spec.rb b/spec/lib/banzai/filter/issue_reference_filter_spec.rb index 9c74c9b8c99..3c98b18f99b 100644 --- a/spec/lib/banzai/filter/issue_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/issue_reference_filter_spec.rb @@ -317,6 +317,68 @@ describe Banzai::Filter::IssueReferenceFilter do end end + context 'group context' do + let(:group) { create(:group) } + let(:context) { { project: nil, group: group } } + + it 'ignores shorthanded issue reference' do + reference = "##{issue.iid}" + text = "Fixed #{reference}" + + expect(reference_filter(text, context).to_html).to eq(text) + end + + it 'ignores valid references when cross-reference project uses external tracker' do + expect_any_instance_of(described_class).to receive(:find_object) + .with(project, issue.iid) + .and_return(nil) + + reference = "#{project.full_path}##{issue.iid}" + text = "Issue #{reference}" + + expect(reference_filter(text, context).to_html).to eq(text) + end + + it 'links to a valid reference for complete cross-reference' do + reference = "#{project.full_path}##{issue.iid}" + doc = reference_filter("See #{reference}", context) + + expect(doc.css('a').first.attr('href')).to eq helper.url_for_issue(issue.iid, project) + end + + it 'ignores reference for shorthand cross-reference' do + reference = "#{project.path}##{issue.iid}" + text = "See #{reference}" + + expect(reference_filter(text, context).to_html).to eq(text) + end + + it 'links to a valid reference for url cross-reference' do + reference = helper.url_for_issue(issue.iid, project) + "#note_123" + + doc = reference_filter("See #{reference}", context) + + expect(doc.css('a').first.attr('href')).to eq(helper.url_for_issue(issue.iid, project) + "#note_123") + end + + it 'links to a valid reference for cross-reference in link href' do + reference = "#{helper.url_for_issue(issue.iid, project) + "#note_123"}" + reference_link = %{<a href="#{reference}">Reference</a>} + + doc = reference_filter("See #{reference_link}", context) + + expect(doc.css('a').first.attr('href')).to eq helper.url_for_issue(issue.iid, project) + "#note_123" + end + + it 'links to a valid reference for issue reference in the link href' do + reference = issue.to_reference(group) + reference_link = %{<a href="#{reference}">Reference</a>} + doc = reference_filter("See #{reference_link}", context) + + expect(doc.css('a').first.attr('href')).to eq helper.url_for_issue(issue.iid, project) + end + end + describe '#issues_per_project' do context 'using an internal issue tracker' do it 'returns a Hash containing the issues per project' do diff --git a/spec/lib/banzai/filter/label_reference_filter_spec.rb b/spec/lib/banzai/filter/label_reference_filter_spec.rb index 2cd30a5e302..862b1fe3fd3 100644 --- a/spec/lib/banzai/filter/label_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/label_reference_filter_spec.rb @@ -594,4 +594,16 @@ describe Banzai::Filter::LabelReferenceFilter do expect(reference_filter(act).to_html).to eq exp end end + + describe 'group context' do + it 'points to referenced project issues page' do + project = create(:project) + label = create(:label, project: project) + reference = "#{project.full_path}~#{label.name}" + + result = reference_filter("See #{reference}", { project: nil, group: create(:group) } ) + + expect(result.css('a').first.attr('href')).to eq(urls.project_issues_url(project, label_name: label.name)) + end + end end diff --git a/spec/lib/banzai/filter/merge_request_reference_filter_spec.rb b/spec/lib/banzai/filter/merge_request_reference_filter_spec.rb index ed2788f8a33..158844e25ae 100644 --- a/spec/lib/banzai/filter/merge_request_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/merge_request_reference_filter_spec.rb @@ -214,4 +214,14 @@ describe Banzai::Filter::MergeRequestReferenceFilter do expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(merge.to_reference(project))} \(diffs, comment 123\)<\/a>\.\)/) end end + + context 'group context' do + it 'links to a valid reference' do + reference = "#{project.full_path}!#{merge.iid}" + + result = reference_filter("See #{reference}", { project: nil, group: create(:group) } ) + + expect(result.css('a').first.attr('href')).to eq(urls.project_merge_request_url(project, merge)) + end + end end diff --git a/spec/lib/banzai/filter/milestone_reference_filter_spec.rb b/spec/lib/banzai/filter/milestone_reference_filter_spec.rb index fe7a8c84c9e..84578668133 100644 --- a/spec/lib/banzai/filter/milestone_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/milestone_reference_filter_spec.rb @@ -343,4 +343,15 @@ describe Banzai::Filter::MilestoneReferenceFilter do expect(doc.css('a')).to be_empty end end + + context 'group context' do + it 'links to a valid reference' do + milestone = create(:milestone, project: project) + reference = "#{project.full_path}%#{milestone.iid}" + + result = reference_filter("See #{reference}", { project: nil, group: create(:group) } ) + + expect(result.css('a').first.attr('href')).to eq(urls.milestone_url(milestone)) + end + end end diff --git a/spec/lib/banzai/filter/snippet_reference_filter_spec.rb b/spec/lib/banzai/filter/snippet_reference_filter_spec.rb index 90ac4c7b238..3a07a6dc179 100644 --- a/spec/lib/banzai/filter/snippet_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/snippet_reference_filter_spec.rb @@ -201,4 +201,14 @@ describe Banzai::Filter::SnippetReferenceFilter do expect(reference_filter(act).to_html).to match(/<a.+>#{Regexp.escape(invalidate_reference(reference))}<\/a>/) end end + + context 'group context' do + it 'links to a valid reference' do + reference = "#{project.full_path}$#{snippet.id}" + + result = reference_filter("See #{reference}", { project: nil, group: create(:group) } ) + + expect(result.css('a').first.attr('href')).to eq(urls.project_snippet_url(project, snippet)) + end + end end diff --git a/spec/lib/banzai/filter/user_reference_filter_spec.rb b/spec/lib/banzai/filter/user_reference_filter_spec.rb index 34dac1db69a..fc03741976e 100644 --- a/spec/lib/banzai/filter/user_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/user_reference_filter_spec.rb @@ -208,6 +208,39 @@ describe Banzai::Filter::UserReferenceFilter do end end + context 'in group context' do + let(:group) { create(:group) } + let(:group_member) { create(:user) } + + before do + group.add_developer(group_member) + end + + let(:context) { { author: group_member, project: nil, group: group } } + + it 'supports a special @all mention' do + reference = User.reference_prefix + 'all' + doc = reference_filter("Hey #{reference}", context) + + expect(doc.css('a').length).to eq(1) + expect(doc.css('a').first.attr('href')).to eq urls.group_url(group) + end + + it 'supports mentioning a single user' do + reference = group_member.to_reference + doc = reference_filter("Hey #{reference}", context) + + expect(doc.css('a').first.attr('href')).to eq urls.user_url(group_member) + end + + it 'supports mentioning a group' do + reference = group.to_reference + doc = reference_filter("Hey #{reference}", context) + + expect(doc.css('a').first.attr('href')).to eq urls.user_url(group) + end + end + describe '#namespaces' do it 'returns a Hash containing all Namespaces' do document = Nokogiri::HTML.fragment("<p>#{user.to_reference}</p>") diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index af1db2c3455..54a853c9ce3 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -5,7 +5,7 @@ describe Gitlab::Auth do describe 'constants' do it 'API_SCOPES contains all scopes for API access' do - expect(subject::API_SCOPES).to eq [:api, :read_user] + expect(subject::API_SCOPES).to eq %i[api read_user sudo] end it 'OPENID_SCOPES contains all scopes for OpenID Connect' do @@ -19,7 +19,7 @@ describe Gitlab::Auth do it 'optional_scopes contains all non-default scopes' do stub_container_registry_config(enabled: true) - expect(subject.optional_scopes).to eq %i[read_user read_registry openid] + expect(subject.optional_scopes).to eq %i[read_user sudo read_registry openid] end context 'registry_scopes' do @@ -164,7 +164,7 @@ describe Gitlab::Auth do personal_access_token = create(:personal_access_token, scopes: ['api']) expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: '') - expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(personal_access_token.user, nil, :personal_token, full_authentication_abilities)) + expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(personal_access_token.user, nil, :personal_access_token, full_authentication_abilities)) end context 'when registry is enabled' do @@ -176,7 +176,7 @@ describe Gitlab::Auth do personal_access_token = create(:personal_access_token, scopes: ['read_registry']) expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: '') - expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(personal_access_token.user, nil, :personal_token, [:read_container_image])) + expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(personal_access_token.user, nil, :personal_access_token, [:read_container_image])) end end @@ -184,14 +184,14 @@ describe Gitlab::Auth do impersonation_token = create(:personal_access_token, :impersonation, scopes: ['api']) expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: '') - expect(gl_auth.find_for_git_client('', impersonation_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(impersonation_token.user, nil, :personal_token, full_authentication_abilities)) + expect(gl_auth.find_for_git_client('', impersonation_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(impersonation_token.user, nil, :personal_access_token, full_authentication_abilities)) end it 'limits abilities based on scope' do personal_access_token = create(:personal_access_token, scopes: ['read_user']) expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: '') - expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(personal_access_token.user, nil, :personal_token, [])) + expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(personal_access_token.user, nil, :personal_access_token, [])) end it 'fails if password is nil' do @@ -234,7 +234,7 @@ describe Gitlab::Auth do it 'throws an error suggesting user create a PAT when internal auth is disabled' do allow_any_instance_of(ApplicationSetting).to receive(:password_authentication_enabled?) { false } - expect { gl_auth.find_for_git_client('foo', 'bar', project: nil, ip: 'ip') }.to raise_error(Gitlab::Auth::MissingPersonalTokenError) + expect { gl_auth.find_for_git_client('foo', 'bar', project: nil, ip: 'ip') }.to raise_error(Gitlab::Auth::MissingPersonalAccessTokenError) end end diff --git a/spec/lib/gitlab/ci/cron_parser_spec.rb b/spec/lib/gitlab/ci/cron_parser_spec.rb index 809fda11879..2a3f7807fdb 100644 --- a/spec/lib/gitlab/ci/cron_parser_spec.rb +++ b/spec/lib/gitlab/ci/cron_parser_spec.rb @@ -77,8 +77,20 @@ describe Gitlab::Ci::CronParser do it_behaves_like "returns time in the future" - it 'converts time in server time zone' do - expect(subject.hour).to eq(hour_in_utc) + context 'when PST (Pacific Standard Time)' do + it 'converts time in server time zone' do + Timecop.freeze(Time.utc(2017, 1, 1)) do + expect(subject.hour).to eq(hour_in_utc) + end + end + end + + context 'when PDT (Pacific Daylight Time)' do + it 'converts time in server time zone' do + Timecop.freeze(Time.utc(2017, 6, 1)) do + expect(subject.hour).to eq(hour_in_utc) + end + end end end end @@ -100,8 +112,20 @@ describe Gitlab::Ci::CronParser do it_behaves_like "returns time in the future" - it 'converts time in server time zone' do - expect(subject.hour).to eq(hour_in_utc) + context 'when CET (Central European Time)' do + it 'converts time in server time zone' do + Timecop.freeze(Time.utc(2017, 1, 1)) do + expect(subject.hour).to eq(hour_in_utc) + end + end + end + + context 'when CEST (Central European Summer Time)' do + it 'converts time in server time zone' do + Timecop.freeze(Time.utc(2017, 6, 1)) do + expect(subject.hour).to eq(hour_in_utc) + end + end end end @@ -111,8 +135,20 @@ describe Gitlab::Ci::CronParser do it_behaves_like "returns time in the future" - it 'converts time in server time zone' do - expect(subject.hour).to eq(hour_in_utc) + context 'when EST (Eastern Standard Time)' do + it 'converts time in server time zone' do + Timecop.freeze(Time.utc(2017, 1, 1)) do + expect(subject.hour).to eq(hour_in_utc) + end + end + end + + context 'when EDT (Eastern Daylight Time)' do + it 'converts time in server time zone' do + Timecop.freeze(Time.utc(2017, 6, 1)) do + expect(subject.hour).to eq(hour_in_utc) + end + end end end end diff --git a/spec/lib/gitlab/ci/status/build/cancelable_spec.rb b/spec/lib/gitlab/ci/status/build/cancelable_spec.rb index 5a7a42d84c0..9cdebaa5cf2 100644 --- a/spec/lib/gitlab/ci/status/build/cancelable_spec.rb +++ b/spec/lib/gitlab/ci/status/build/cancelable_spec.rb @@ -66,7 +66,7 @@ describe Gitlab::Ci::Status::Build::Cancelable do end describe '#action_icon' do - it { expect(subject.action_icon).to eq 'icon_action_cancel' } + it { expect(subject.action_icon).to eq 'cancel' } end describe '#action_title' do diff --git a/spec/lib/gitlab/ci/status/build/factory_spec.rb b/spec/lib/gitlab/ci/status/build/factory_spec.rb index 8768302eda1..2b32e47e9ba 100644 --- a/spec/lib/gitlab/ci/status/build/factory_spec.rb +++ b/spec/lib/gitlab/ci/status/build/factory_spec.rb @@ -30,7 +30,7 @@ describe Gitlab::Ci::Status::Build::Factory do it 'fabricates status with correct details' do expect(status.text).to eq 'passed' - expect(status.icon).to eq 'icon_status_success' + expect(status.icon).to eq 'status_success' expect(status.favicon).to eq 'favicon_status_success' expect(status.label).to eq 'passed' expect(status).to have_details @@ -57,7 +57,7 @@ describe Gitlab::Ci::Status::Build::Factory do it 'fabricates status with correct details' do expect(status.text).to eq 'failed' - expect(status.icon).to eq 'icon_status_failed' + expect(status.icon).to eq 'status_failed' expect(status.favicon).to eq 'favicon_status_failed' expect(status.label).to eq 'failed' expect(status).to have_details @@ -84,7 +84,7 @@ describe Gitlab::Ci::Status::Build::Factory do it 'fabricates status with correct details' do expect(status.text).to eq 'failed' - expect(status.icon).to eq 'icon_status_warning' + expect(status.icon).to eq 'warning' expect(status.favicon).to eq 'favicon_status_failed' expect(status.label).to eq 'failed (allowed to fail)' expect(status).to have_details @@ -113,7 +113,7 @@ describe Gitlab::Ci::Status::Build::Factory do it 'fabricates status with correct details' do expect(status.text).to eq 'canceled' - expect(status.icon).to eq 'icon_status_canceled' + expect(status.icon).to eq 'status_canceled' expect(status.favicon).to eq 'favicon_status_canceled' expect(status.label).to eq 'canceled' expect(status).to have_details @@ -139,7 +139,7 @@ describe Gitlab::Ci::Status::Build::Factory do it 'fabricates status with correct details' do expect(status.text).to eq 'running' - expect(status.icon).to eq 'icon_status_running' + expect(status.icon).to eq 'status_running' expect(status.favicon).to eq 'favicon_status_running' expect(status.label).to eq 'running' expect(status).to have_details @@ -165,7 +165,7 @@ describe Gitlab::Ci::Status::Build::Factory do it 'fabricates status with correct details' do expect(status.text).to eq 'pending' - expect(status.icon).to eq 'icon_status_pending' + expect(status.icon).to eq 'status_pending' expect(status.favicon).to eq 'favicon_status_pending' expect(status.label).to eq 'pending' expect(status).to have_details @@ -190,7 +190,7 @@ describe Gitlab::Ci::Status::Build::Factory do it 'fabricates status with correct details' do expect(status.text).to eq 'skipped' - expect(status.icon).to eq 'icon_status_skipped' + expect(status.icon).to eq 'status_skipped' expect(status.favicon).to eq 'favicon_status_skipped' expect(status.label).to eq 'skipped' expect(status).to have_details @@ -219,7 +219,7 @@ describe Gitlab::Ci::Status::Build::Factory do it 'fabricates status with correct details' do expect(status.text).to eq 'manual' expect(status.group).to eq 'manual' - expect(status.icon).to eq 'icon_status_manual' + expect(status.icon).to eq 'status_manual' expect(status.favicon).to eq 'favicon_status_manual' expect(status.label).to include 'manual play action' expect(status).to have_details @@ -274,7 +274,7 @@ describe Gitlab::Ci::Status::Build::Factory do it 'fabricates status with correct details' do expect(status.text).to eq 'manual' expect(status.group).to eq 'manual' - expect(status.icon).to eq 'icon_status_manual' + expect(status.icon).to eq 'status_manual' expect(status.favicon).to eq 'favicon_status_manual' expect(status.label).to eq 'manual stop action (not allowed)' expect(status).to have_details diff --git a/spec/lib/gitlab/ci/status/build/failed_allowed_spec.rb b/spec/lib/gitlab/ci/status/build/failed_allowed_spec.rb index 20f71459738..79a65fc67e8 100644 --- a/spec/lib/gitlab/ci/status/build/failed_allowed_spec.rb +++ b/spec/lib/gitlab/ci/status/build/failed_allowed_spec.rb @@ -18,7 +18,7 @@ describe Gitlab::Ci::Status::Build::FailedAllowed do describe '#icon' do it 'returns a warning icon' do - expect(subject.icon).to eq 'icon_status_warning' + expect(subject.icon).to eq 'warning' end end diff --git a/spec/lib/gitlab/ci/status/build/play_spec.rb b/spec/lib/gitlab/ci/status/build/play_spec.rb index 32b2e62e4e0..81d5f553fd1 100644 --- a/spec/lib/gitlab/ci/status/build/play_spec.rb +++ b/spec/lib/gitlab/ci/status/build/play_spec.rb @@ -46,7 +46,7 @@ describe Gitlab::Ci::Status::Build::Play do end describe '#action_icon' do - it { expect(subject.action_icon).to eq 'icon_action_play' } + it { expect(subject.action_icon).to eq 'play' } end describe '#action_title' do diff --git a/spec/lib/gitlab/ci/status/build/retryable_spec.rb b/spec/lib/gitlab/ci/status/build/retryable_spec.rb index 21026f2c968..14d42e0d70f 100644 --- a/spec/lib/gitlab/ci/status/build/retryable_spec.rb +++ b/spec/lib/gitlab/ci/status/build/retryable_spec.rb @@ -66,7 +66,7 @@ describe Gitlab::Ci::Status::Build::Retryable do end describe '#action_icon' do - it { expect(subject.action_icon).to eq 'icon_action_retry' } + it { expect(subject.action_icon).to eq 'retry' } end describe '#action_title' do diff --git a/spec/lib/gitlab/ci/status/build/stop_spec.rb b/spec/lib/gitlab/ci/status/build/stop_spec.rb index e0425103f41..18e250772f0 100644 --- a/spec/lib/gitlab/ci/status/build/stop_spec.rb +++ b/spec/lib/gitlab/ci/status/build/stop_spec.rb @@ -38,7 +38,7 @@ describe Gitlab::Ci::Status::Build::Stop do end describe '#action_icon' do - it { expect(subject.action_icon).to eq 'icon_action_stop' } + it { expect(subject.action_icon).to eq 'stop' } end describe '#action_title' do diff --git a/spec/lib/gitlab/ci/status/canceled_spec.rb b/spec/lib/gitlab/ci/status/canceled_spec.rb index 530639a5897..dc74d7e28c5 100644 --- a/spec/lib/gitlab/ci/status/canceled_spec.rb +++ b/spec/lib/gitlab/ci/status/canceled_spec.rb @@ -14,7 +14,7 @@ describe Gitlab::Ci::Status::Canceled do end describe '#icon' do - it { expect(subject.icon).to eq 'icon_status_canceled' } + it { expect(subject.icon).to eq 'status_canceled' } end describe '#favicon' do diff --git a/spec/lib/gitlab/ci/status/created_spec.rb b/spec/lib/gitlab/ci/status/created_spec.rb index aef982e17f1..ce4333f2aca 100644 --- a/spec/lib/gitlab/ci/status/created_spec.rb +++ b/spec/lib/gitlab/ci/status/created_spec.rb @@ -14,7 +14,7 @@ describe Gitlab::Ci::Status::Created do end describe '#icon' do - it { expect(subject.icon).to eq 'icon_status_created' } + it { expect(subject.icon).to eq 'status_created' } end describe '#favicon' do diff --git a/spec/lib/gitlab/ci/status/failed_spec.rb b/spec/lib/gitlab/ci/status/failed_spec.rb index 9a25743885c..a4a92117c7f 100644 --- a/spec/lib/gitlab/ci/status/failed_spec.rb +++ b/spec/lib/gitlab/ci/status/failed_spec.rb @@ -14,7 +14,7 @@ describe Gitlab::Ci::Status::Failed do end describe '#icon' do - it { expect(subject.icon).to eq 'icon_status_failed' } + it { expect(subject.icon).to eq 'status_failed' } end describe '#favicon' do diff --git a/spec/lib/gitlab/ci/status/manual_spec.rb b/spec/lib/gitlab/ci/status/manual_spec.rb index 6fdc3801d71..0463f2e1aff 100644 --- a/spec/lib/gitlab/ci/status/manual_spec.rb +++ b/spec/lib/gitlab/ci/status/manual_spec.rb @@ -14,7 +14,7 @@ describe Gitlab::Ci::Status::Manual do end describe '#icon' do - it { expect(subject.icon).to eq 'icon_status_manual' } + it { expect(subject.icon).to eq 'status_manual' } end describe '#favicon' do diff --git a/spec/lib/gitlab/ci/status/pending_spec.rb b/spec/lib/gitlab/ci/status/pending_spec.rb index ffc53f0506b..0e25358dd8a 100644 --- a/spec/lib/gitlab/ci/status/pending_spec.rb +++ b/spec/lib/gitlab/ci/status/pending_spec.rb @@ -14,7 +14,7 @@ describe Gitlab::Ci::Status::Pending do end describe '#icon' do - it { expect(subject.icon).to eq 'icon_status_pending' } + it { expect(subject.icon).to eq 'status_pending' } end describe '#favicon' do diff --git a/spec/lib/gitlab/ci/status/running_spec.rb b/spec/lib/gitlab/ci/status/running_spec.rb index 0babf1fb54e..9c9d431bb5d 100644 --- a/spec/lib/gitlab/ci/status/running_spec.rb +++ b/spec/lib/gitlab/ci/status/running_spec.rb @@ -14,7 +14,7 @@ describe Gitlab::Ci::Status::Running do end describe '#icon' do - it { expect(subject.icon).to eq 'icon_status_running' } + it { expect(subject.icon).to eq 'status_running' } end describe '#favicon' do diff --git a/spec/lib/gitlab/ci/status/skipped_spec.rb b/spec/lib/gitlab/ci/status/skipped_spec.rb index 670747c9f0b..63694ca0ea6 100644 --- a/spec/lib/gitlab/ci/status/skipped_spec.rb +++ b/spec/lib/gitlab/ci/status/skipped_spec.rb @@ -14,7 +14,7 @@ describe Gitlab::Ci::Status::Skipped do end describe '#icon' do - it { expect(subject.icon).to eq 'icon_status_skipped' } + it { expect(subject.icon).to eq 'status_skipped' } end describe '#favicon' do diff --git a/spec/lib/gitlab/ci/status/success_spec.rb b/spec/lib/gitlab/ci/status/success_spec.rb index ff65b074808..2f67df71c4f 100644 --- a/spec/lib/gitlab/ci/status/success_spec.rb +++ b/spec/lib/gitlab/ci/status/success_spec.rb @@ -14,7 +14,7 @@ describe Gitlab::Ci::Status::Success do end describe '#icon' do - it { expect(subject.icon).to eq 'icon_status_success' } + it { expect(subject.icon).to eq 'status_success' } end describe '#favicon' do diff --git a/spec/lib/gitlab/ci/status/success_warning_spec.rb b/spec/lib/gitlab/ci/status/success_warning_spec.rb index 7e2269397c6..4582354e739 100644 --- a/spec/lib/gitlab/ci/status/success_warning_spec.rb +++ b/spec/lib/gitlab/ci/status/success_warning_spec.rb @@ -14,7 +14,7 @@ describe Gitlab::Ci::Status::SuccessWarning do end describe '#icon' do - it { expect(subject.icon).to eq 'icon_status_warning' } + it { expect(subject.icon).to eq 'status_warning' } end describe '#group' do diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb index 5fa94999d25..7aeb85b8f5a 100644 --- a/spec/lib/gitlab/database_spec.rb +++ b/spec/lib/gitlab/database_spec.rb @@ -256,4 +256,26 @@ describe Gitlab::Database do expect(described_class.false_value).to eq 0 end end + + describe '#sanitize_timestamp' do + let(:max_timestamp) { Time.at((1 << 31) - 1) } + + subject { described_class.sanitize_timestamp(timestamp) } + + context 'with a timestamp smaller than MAX_TIMESTAMP_VALUE' do + let(:timestamp) { max_timestamp - 10.years } + + it 'returns the given timestamp' do + expect(subject).to eq(timestamp) + end + end + + context 'with a timestamp larger than MAX_TIMESTAMP_VALUE' do + let(:timestamp) { max_timestamp + 1.second } + + it 'returns MAX_TIMESTAMP_VALUE' do + expect(subject).to eq(max_timestamp) + end + end + end end diff --git a/spec/lib/gitlab/diff/position_spec.rb b/spec/lib/gitlab/diff/position_spec.rb index 245f24e96d4..677eb373d22 100644 --- a/spec/lib/gitlab/diff/position_spec.rb +++ b/spec/lib/gitlab/diff/position_spec.rb @@ -364,6 +364,43 @@ describe Gitlab::Diff::Position do end end + describe "position for a missing ref" do + let(:diff_refs) do + Gitlab::Diff::DiffRefs.new( + base_sha: "not_existing_sha", + head_sha: "existing_sha" + ) + end + + subject do + described_class.new( + old_path: "files/ruby/feature.rb", + new_path: "files/ruby/feature.rb", + old_line: 3, + new_line: nil, + diff_refs: diff_refs + ) + end + + describe "#diff_file" do + it "does not raise exception" do + expect { subject.diff_file(project.repository) }.not_to raise_error + end + end + + describe "#diff_line" do + it "does not raise exception" do + expect { subject.diff_line(project.repository) }.not_to raise_error + end + end + + describe "#line_code" do + it "does not raise exception" do + expect { subject.line_code(project.repository) }.not_to raise_error + end + end + end + describe "position for a file in the initial commit" do let(:commit) { project.commit("1a0b36b3cdad1d2ee32457c102a8c0b7056fa863") } diff --git a/spec/lib/gitlab/git/blob_spec.rb b/spec/lib/gitlab/git/blob_spec.rb index 412a0093d97..c04a9688503 100644 --- a/spec/lib/gitlab/git/blob_spec.rb +++ b/spec/lib/gitlab/git/blob_spec.rb @@ -143,6 +143,16 @@ describe Gitlab::Git::Blob, seed_helper: true do expect(blob.loaded_size).to eq(blob_size) end end + + context 'when sha references a tree' do + it 'returns nil' do + tree = Gitlab::Git::Commit.find(repository, 'master').tree + + blob = Gitlab::Git::Blob.raw(repository, tree.oid) + + expect(blob).to be_nil + end + end end describe '.raw' do @@ -226,6 +236,51 @@ describe Gitlab::Git::Blob, seed_helper: true do end end + describe '.batch_lfs_pointers' do + let(:tree_object) { Gitlab::Git::Commit.find(repository, 'master').tree } + + let(:non_lfs_blob) do + Gitlab::Git::Blob.find( + repository, + 'master', + 'README.md' + ) + end + + let(:lfs_blob) do + Gitlab::Git::Blob.find( + repository, + '33bcff41c232a11727ac6d660bd4b0c2ba86d63d', + 'files/lfs/image.jpg' + ) + end + + it 'returns a list of Gitlab::Git::Blob' do + blobs = described_class.batch_lfs_pointers(repository, [lfs_blob.id]) + + expect(blobs.count).to eq(1) + expect(blobs).to all( be_a(Gitlab::Git::Blob) ) + end + + it 'silently ignores tree objects' do + blobs = described_class.batch_lfs_pointers(repository, [tree_object.oid]) + + expect(blobs).to eq([]) + end + + it 'silently ignores non lfs objects' do + blobs = described_class.batch_lfs_pointers(repository, [non_lfs_blob.id]) + + expect(blobs).to eq([]) + end + + it 'avoids loading large blobs into memory' do + expect(repository).not_to receive(:lookup) + + described_class.batch_lfs_pointers(repository, [non_lfs_blob.id]) + end + end + describe 'encoding' do context 'file with russian text' do let(:blob) { Gitlab::Git::Blob.find(repository, SeedRepo::Commit::ID, "encoding/russian.rb") } diff --git a/spec/lib/gitlab/git/branch_spec.rb b/spec/lib/gitlab/git/branch_spec.rb index 318a7b7a332..708870060e7 100644 --- a/spec/lib/gitlab/git/branch_spec.rb +++ b/spec/lib/gitlab/git/branch_spec.rb @@ -7,6 +7,38 @@ describe Gitlab::Git::Branch, seed_helper: true do it { is_expected.to be_kind_of Array } + describe '.find' do + subject { described_class.find(repository, branch) } + + before do + allow(repository).to receive(:find_branch).with(branch) + .and_call_original + end + + context 'when finding branch via branch name' do + let(:branch) { 'master' } + + it 'returns a branch object' do + expect(subject).to be_a(described_class) + expect(subject.name).to eq(branch) + + expect(repository).to have_received(:find_branch).with(branch) + end + end + + context 'when the branch is already a branch' do + let(:commit) { repository.commit('master') } + let(:branch) { described_class.new(repository, 'master', commit.sha, commit) } + + it 'returns a branch object' do + expect(subject).to be_a(described_class) + expect(subject).to eq(branch) + + expect(repository).not_to have_received(:find_branch).with(branch) + end + end + end + describe '#size' do subject { super().size } it { is_expected.to eq(SeedRepo::Repo::BRANCHES.size) } diff --git a/spec/lib/gitlab/git/lfs_changes_spec.rb b/spec/lib/gitlab/git/lfs_changes_spec.rb new file mode 100644 index 00000000000..c2d2c6e1bc8 --- /dev/null +++ b/spec/lib/gitlab/git/lfs_changes_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe Gitlab::Git::LfsChanges do + let(:project) { create(:project, :repository) } + let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' } + let(:blob_object_id) { '0c304a93cb8430108629bbbcaa27db3343299bc0' } + + subject { described_class.new(project.repository, newrev) } + + describe 'new_pointers' do + before do + allow_any_instance_of(Gitlab::Git::RevList).to receive(:new_objects).and_return([blob_object_id]) + end + + it 'uses rev-list to find new objects' do + rev_list = double + allow(Gitlab::Git::RevList).to receive(:new).and_return(rev_list) + + expect(rev_list).to receive(:new_objects).and_return([]) + + subject.new_pointers + end + + it 'filters new objects to find lfs pointers' do + expect(Gitlab::Git::Blob).to receive(:batch_lfs_pointers).with(project.repository, [blob_object_id]) + + subject.new_pointers(object_limit: 1) + end + + it 'limits new_objects using object_limit' do + expect(Gitlab::Git::Blob).to receive(:batch_lfs_pointers).with(project.repository, []) + + subject.new_pointers(object_limit: 0) + end + end + + describe 'all_pointers' do + it 'uses rev-list to find all objects' do + rev_list = double + allow(Gitlab::Git::RevList).to receive(:new).and_return(rev_list) + allow(rev_list).to receive(:all_objects).and_return([blob_object_id]) + + expect(Gitlab::Git::Blob).to receive(:batch_lfs_pointers).with(project.repository, [blob_object_id]) + + subject.all_pointers + end + end +end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index e3d320631bc..1d4d0c300eb 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -559,10 +559,10 @@ describe Gitlab::Git::Repository, seed_helper: true do end end - describe "#remote_delete" do + describe "#remove_remote" do before(:all) do @repo = Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '') - @repo.remote_delete("expendable") + @repo.remove_remote("expendable") end it "should remove the remote" do @@ -575,14 +575,16 @@ describe Gitlab::Git::Repository, seed_helper: true do end end - describe "#remote_add" do + describe "#remote_update" do before(:all) do @repo = Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '') - @repo.remote_add("new_remote", SeedHelper::GITLAB_GIT_TEST_REPO_URL) + @repo.remote_update("expendable", url: TEST_NORMAL_REPO_PATH) end it "should add the remote" do - expect(@repo.rugged.remotes.each_name.to_a).to include("new_remote") + expect(@repo.rugged.remotes["expendable"].url).to( + eq(TEST_NORMAL_REPO_PATH) + ) end after(:all) do @@ -591,21 +593,58 @@ describe Gitlab::Git::Repository, seed_helper: true do end end - describe "#remote_update" do - before(:all) do - @repo = Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '') - @repo.remote_update("expendable", url: TEST_NORMAL_REPO_PATH) + describe '#fetch_mirror' do + let(:new_repository) do + Gitlab::Git::Repository.new('default', 'my_project.git', '') end - it "should add the remote" do - expect(@repo.rugged.remotes["expendable"].url).to( - eq(TEST_NORMAL_REPO_PATH) - ) + subject { new_repository.fetch_mirror(repository.path) } + + before do + Gitlab::Shell.new.add_repository('default', 'my_project') end - after(:all) do - FileUtils.rm_rf(TEST_MUTABLE_REPO_PATH) - ensure_seeds + after do + Gitlab::Shell.new.remove_repository(TestEnv.repos_path, 'my_project') + end + + it 'fetches a url as a mirror remote' do + subject + + expect(refs(new_repository.path)).to eq(refs(repository.path)) + end + + context 'with keep-around refs' do + let(:sha) { SeedRepo::Commit::ID } + let(:keep_around_ref) { "refs/keep-around/#{sha}" } + let(:tmp_ref) { "refs/tmp/#{SecureRandom.hex}" } + + before do + repository.rugged.references.create(keep_around_ref, sha, force: true) + repository.rugged.references.create(tmp_ref, sha, force: true) + end + + it 'includes the temporary and keep-around refs' do + subject + + expect(refs(new_repository.path)).to include(keep_around_ref) + expect(refs(new_repository.path)).to include(tmp_ref) + end + end + end + + describe '#remote_tags' do + let(:target_commit_id) { SeedRepo::Commit::ID } + + subject { repository.remote_tags('upstream') } + + it 'gets the remote tags' do + expect(repository).to receive(:list_remote_tags).with('upstream') + .and_return(["#{target_commit_id}\trefs/tags/v0.0.1\n"]) + + expect(subject.first).to be_an_instance_of(Gitlab::Git::Tag) + expect(subject.first.name).to eq('v0.0.1') + expect(subject.first.dereferenced_target.id).to eq(target_commit_id) end end @@ -1135,8 +1174,35 @@ describe Gitlab::Git::Repository, seed_helper: true do end end + describe '#merged_branch_names' do + context 'when branch names are passed' do + it 'only returns the names we are asking' do + names = repository.merged_branch_names(%w[merge-test]) + + expect(names).to contain_exactly('merge-test') + end + + it 'does not return unmerged branch names' do + names = repository.merged_branch_names(%w[feature]) + + expect(names).to be_empty + end + end + + context 'when no branch names are specified' do + it 'returns all merged branch names' do + names = repository.merged_branch_names + + expect(names).to include('merge-test') + expect(names).to include('fix-mode') + expect(names).not_to include('feature') + end + end + end + describe "#ls_files" do let(:master_file_paths) { repository.ls_files("master") } + let(:utf8_file_paths) { repository.ls_files("ls-files-utf8") } let(:not_existed_branch) { repository.ls_files("not_existed_branch") } it "read every file paths of master branch" do @@ -1158,6 +1224,10 @@ describe Gitlab::Git::Repository, seed_helper: true do it "returns empty array when not existed branch" do expect(not_existed_branch.length).to equal(0) end + + it "returns valid utf-8 data" do + expect(utf8_file_paths.map { |file| file.force_encoding('utf-8') }).to all(be_valid_encoding) + end end describe "#copy_gitattributes" do @@ -1310,6 +1380,24 @@ describe Gitlab::Git::Repository, seed_helper: true do end end + describe '#batch_existence' do + let(:refs) { ['deadbeef', SeedRepo::RubyBlob::ID, '909e6157199'] } + + it 'returns existing refs back' do + result = repository.batch_existence(refs) + + expect(result).to eq([SeedRepo::RubyBlob::ID]) + end + + context 'existing: true' do + it 'inverts meaning and returns non-existing refs' do + result = repository.batch_existence(refs, existing: false) + + expect(result).to eq(%w(deadbeef 909e6157199)) + end + end + end + describe '#local_branches' do before(:all) do @repo = Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '') @@ -1583,38 +1671,71 @@ describe Gitlab::Git::Repository, seed_helper: true do subject { repository.ff_merge(user, source_sha, target_branch) } - it 'performs a ff_merge' do - expect(subject.newrev).to eq(source_sha) - expect(subject.repo_created).to be(false) - expect(subject.branch_created).to be(false) + shared_examples '#ff_merge' do + it 'performs a ff_merge' do + expect(subject.newrev).to eq(source_sha) + expect(subject.repo_created).to be(false) + expect(subject.branch_created).to be(false) - expect(repository.commit(target_branch).id).to eq(source_sha) - end + expect(repository.commit(target_branch).id).to eq(source_sha) + end + + context 'with a non-existing target branch' do + subject { repository.ff_merge(user, source_sha, 'this-isnt-real') } + + it 'throws an ArgumentError' do + expect { subject }.to raise_error(ArgumentError) + end + end + + context 'with a non-existing source commit' do + let(:source_sha) { 'f001' } + + it 'throws an ArgumentError' do + expect { subject }.to raise_error(ArgumentError) + end + end + + context 'when the source sha is not a descendant of the branch head' do + let(:source_sha) { '1a0b36b3cdad1d2ee32457c102a8c0b7056fa863' } - context 'with a non-existing target branch' do - subject { repository.ff_merge(user, source_sha, 'this-isnt-real') } + it "doesn't perform the ff_merge" do + expect { subject }.to raise_error(Gitlab::Git::CommitError) - it 'throws an ArgumentError' do - expect { subject }.to raise_error(ArgumentError) + expect(repository.commit(target_branch).id).to eq(branch_head) + end end end - context 'with a non-existing source commit' do - let(:source_sha) { 'f001' } + context 'with gitaly' do + it "calls Gitaly's OperationService" do + expect_any_instance_of(Gitlab::GitalyClient::OperationService) + .to receive(:user_ff_branch).with(user, source_sha, target_branch) + .and_return(nil) - it 'throws an ArgumentError' do - expect { subject }.to raise_error(ArgumentError) + subject end + + it_behaves_like '#ff_merge' end - context 'when the source sha is not a descendant of the branch head' do - let(:source_sha) { '1a0b36b3cdad1d2ee32457c102a8c0b7056fa863' } + context 'without gitaly', :skip_gitaly_mock do + it_behaves_like '#ff_merge' + end + end - it "doesn't perform the ff_merge" do - expect { subject }.to raise_error(Gitlab::Git::CommitError) + describe '#fetch' do + let(:git_path) { Gitlab.config.git.bin_path } + let(:remote_name) { 'my_remote' } - expect(repository.commit(target_branch).id).to eq(branch_head) - end + subject { repository.fetch(remote_name) } + + it 'fetches the remote and returns true if the command was successful' do + expect(repository).to receive(:popen) + .with(%W(#{git_path} fetch #{remote_name}), repository.path) + .and_return(['', 0]) + + expect(subject).to be(true) end end @@ -1693,4 +1814,10 @@ describe Gitlab::Git::Repository, seed_helper: true do sha = Rugged::Commit.create(repo, options) repo.lookup(sha) end + + def refs(dir) + IO.popen(%W[git -C #{dir} for-each-ref], &:read).split("\n").map do |line| + line.split("\t").last + end + end end diff --git a/spec/lib/gitlab/git/rev_list_spec.rb b/spec/lib/gitlab/git/rev_list_spec.rb index c0eac98d718..643a4b2d03e 100644 --- a/spec/lib/gitlab/git/rev_list_spec.rb +++ b/spec/lib/gitlab/git/rev_list_spec.rb @@ -2,53 +2,82 @@ require 'spec_helper' describe Gitlab::Git::RevList do let(:project) { create(:project, :repository) } + let(:rev_list) { described_class.new(newrev: 'newrev', path_to_repo: project.repository.path_to_repo) } before do - expect(Gitlab::Git::Env).to receive(:all).and_return({ + allow(Gitlab::Git::Env).to receive(:all).and_return({ GIT_OBJECT_DIRECTORY: 'foo', GIT_ALTERNATE_OBJECT_DIRECTORIES: 'bar' }) end - context "#new_refs" do - let(:rev_list) { described_class.new(newrev: 'newrev', path_to_repo: project.repository.path_to_repo) } + def stub_popen_rev_list(*additional_args, output:) + expect(rev_list).to receive(:popen).with([ + Gitlab.config.git.bin_path, + "--git-dir=#{project.repository.path_to_repo}", + 'rev-list', + *additional_args + ], + nil, + { + 'GIT_OBJECT_DIRECTORY' => 'foo', + 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => 'bar' + }).and_return([output, 0]) + end + context "#new_refs" do it 'calls out to `popen`' do - expect(rev_list).to receive(:popen).with([ - Gitlab.config.git.bin_path, - "--git-dir=#{project.repository.path_to_repo}", - 'rev-list', - 'newrev', - '--not', - '--all' - ], - nil, - { - 'GIT_OBJECT_DIRECTORY' => 'foo', - 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => 'bar' - }).and_return(["sha1\nsha2", 0]) + stub_popen_rev_list('newrev', '--not', '--all', output: "sha1\nsha2") expect(rev_list.new_refs).to eq(%w[sha1 sha2]) end end + context '#new_objects' do + it 'fetches list of newly pushed objects using rev-list' do + stub_popen_rev_list('newrev', '--not', '--all', '--objects', output: "sha1\nsha2") + + expect(rev_list.new_objects).to eq(%w[sha1 sha2]) + end + + it 'can skip pathless objects' do + stub_popen_rev_list('newrev', '--not', '--all', '--objects', output: "sha1\nsha2 path/to/file") + + expect(rev_list.new_objects(require_path: true)).to eq(%w[sha2]) + end + + it 'can return a lazy enumerator' do + stub_popen_rev_list('newrev', '--not', '--all', '--objects', output: "sha1\nsha2") + + expect(rev_list.new_objects(lazy: true)).to be_a Enumerator::Lazy + end + + it 'can accept list of references to exclude' do + stub_popen_rev_list('newrev', '--not', 'master', '--objects', output: "sha1\nsha2") + + expect(rev_list.new_objects(not_in: ['master'])).to eq(%w[sha1 sha2]) + end + + it 'handles empty list of references to exclude as listing all known objects' do + stub_popen_rev_list('newrev', '--objects', output: "sha1\nsha2") + + expect(rev_list.new_objects(not_in: [])).to eq(%w[sha1 sha2]) + end + end + + context '#all_objects' do + it 'fetches list of all pushed objects using rev-list' do + stub_popen_rev_list('--all', '--objects', output: "sha1\nsha2") + + expect(rev_list.all_objects.force).to eq(%w[sha1 sha2]) + end + end + context "#missed_ref" do let(:rev_list) { described_class.new(oldrev: 'oldrev', newrev: 'newrev', path_to_repo: project.repository.path_to_repo) } it 'calls out to `popen`' do - expect(rev_list).to receive(:popen).with([ - Gitlab.config.git.bin_path, - "--git-dir=#{project.repository.path_to_repo}", - 'rev-list', - '--max-count=1', - 'oldrev', - '^newrev' - ], - nil, - { - 'GIT_OBJECT_DIRECTORY' => 'foo', - 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => 'bar' - }).and_return(["sha1\nsha2", 0]) + stub_popen_rev_list('--max-count=1', 'oldrev', '^newrev', output: "sha1\nsha2") expect(rev_list.missed_ref).to eq(%w[sha1 sha2]) end diff --git a/spec/lib/gitlab/gitaly_client/operation_service_spec.rb b/spec/lib/gitlab/gitaly_client/operation_service_spec.rb index e144e28b5d8..d9ec28ab02e 100644 --- a/spec/lib/gitlab/gitaly_client/operation_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/operation_service_spec.rb @@ -89,4 +89,38 @@ describe Gitlab::GitalyClient::OperationService do end end end + + describe '#user_ff_branch' do + let(:target_branch) { 'my-branch' } + let(:source_sha) { 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660' } + let(:request) do + Gitaly::UserFFBranchRequest.new( + repository: repository.gitaly_repository, + branch: target_branch, + commit_id: source_sha, + user: gitaly_user + ) + end + let(:branch_update) do + Gitaly::OperationBranchUpdate.new( + commit_id: source_sha, + repo_created: false, + branch_created: false + ) + end + let(:response) { Gitaly::UserFFBranchResponse.new(branch_update: branch_update) } + + subject { client.user_ff_branch(user, source_sha, target_branch) } + + it 'sends a user_ff_branch message and returns a BranchUpdate object' do + expect_any_instance_of(Gitaly::OperationService::Stub) + .to receive(:user_ff_branch).with(request, kind_of(Hash)) + .and_return(response) + + expect(subject).to be_a(Gitlab::Git::OperationService::BranchUpdate) + expect(subject.newrev).to eq(source_sha) + expect(subject.repo_created).to be(false) + expect(subject.branch_created).to be(false) + end + end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 29baa70d5ae..96efdd0949b 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -148,9 +148,18 @@ deploy_keys: - deploy_keys_projects - projects cluster: -- project +- cluster_projects +- projects - user -- service +- provider_gcp +- platform_kubernetes +cluster_projects: +- projects +- clusters +provider_gcp: +- cluster +platform_kubernetes: +- cluster services: - project - service_hook @@ -182,6 +191,7 @@ project: - tags - chat_services - cluster +- cluster_project - creator - group - namespace @@ -195,6 +205,7 @@ project: - mattermost_slash_commands_service - slack_slash_commands_service - irker_service +- packagist_service - pivotaltracker_service - prometheus_service - hipchat_service @@ -286,3 +297,6 @@ timelogs: - user push_event_payload: - event +issue_assignees: +- issue +- assignee
\ No newline at end of file diff --git a/spec/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json index 1115fb218d6..9a68bbb379c 100644 --- a/spec/lib/gitlab/import_export/project.json +++ b/spec/lib/gitlab/import_export/project.json @@ -43,7 +43,7 @@ "issues": [ { "id": 40, - "title": "Voluptatem amet doloribus deleniti eos maxime repudiandae molestias.", + "title": "Voluptatem", "assignee_id": 1, "author_id": 22, "project_id": 5, @@ -60,6 +60,12 @@ "due_date": null, "moved_to_id": null, "test_ee_field": "test", + "issue_assignees": [ + { + "user_id": 1, + "issue_id": 1 + } + ], "milestone": { "id": 1, "title": "test milestone", 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 4301eee17dc..76b01b6a1ec 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -63,6 +63,10 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do expect(issue.reload.updated_at.to_s).to eq('2016-06-14 15:02:47 UTC') end + it 'has issue assignees' do + expect(Issue.where(title: 'Voluptatem').first.issue_assignees).not_to be_empty + end + it 'contains the merge access levels on a protected branch' do expect(ProtectedBranch.first.merge_access_levels).not_to be_empty end diff --git a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb index d9b86e1bf34..8da768ebd07 100644 --- a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb @@ -77,6 +77,10 @@ describe Gitlab::ImportExport::ProjectTreeSaver do expect(saved_project_json['issues'].first['notes']).not_to be_empty end + it 'has issue assignees' do + expect(saved_project_json['issues'].first['issue_assignees']).not_to be_empty + end + it 'has author on issue comments' do expect(saved_project_json['issues'].first['notes'].first['author']).not_to be_empty end diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 121c0ed04ed..4b79e9f18c6 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -313,30 +313,47 @@ Ci::PipelineSchedule: - deleted_at - created_at - updated_at -Gcp::Cluster: +Clusters::Cluster: - id -- project_id - user_id -- service_id - enabled +- name +- provider_type +- platform_type +- created_at +- updated_at +Clusters::Project: +- id +- project_id +- cluster_id +- created_at +- updated_at +Clusters::Providers::Gcp: +- id +- cluster_id - status - status_reason -- project_namespace +- gcp_project_id +- zone +- num_nodes +- machine_type +- operation_id - endpoint +- encrypted_access_token +- encrypted_access_token_iv +- created_at +- updated_at +Clusters::Platforms::Kubernetes: +- id +- cluster_id +- api_url - ca_cert -- encrypted_kubernetes_token -- encrypted_kubernetes_token_iv +- namespace - username - encrypted_password - encrypted_password_iv -- gcp_project_id -- gcp_cluster_zone -- gcp_cluster_name -- gcp_cluster_size -- gcp_machine_type -- gcp_operation_id -- encrypted_gcp_token -- encrypted_gcp_token_iv +- encrypted_token +- encrypted_token_iv - created_at - updated_at DeployKey: @@ -506,3 +523,6 @@ ProjectAutoDevops: - project_id - created_at - updated_at +IssueAssignee: +- user_id +- issue_id
\ No newline at end of file diff --git a/spec/lib/gitlab/ldap/authentication_spec.rb b/spec/lib/gitlab/ldap/authentication_spec.rb index 01b6282af0c..9d57a46c12b 100644 --- a/spec/lib/gitlab/ldap/authentication_spec.rb +++ b/spec/lib/gitlab/ldap/authentication_spec.rb @@ -1,8 +1,8 @@ require 'spec_helper' describe Gitlab::LDAP::Authentication do - let(:user) { create(:omniauth_user, extern_uid: dn) } - let(:dn) { 'uid=john,ou=people,dc=example,dc=com' } + let(:dn) { 'uid=John Smith, ou=People, dc=example, dc=com' } + let(:user) { create(:omniauth_user, extern_uid: Gitlab::LDAP::Person.normalize_dn(dn)) } let(:login) { 'john' } let(:password) { 'password' } diff --git a/spec/lib/gitlab/ldap/user_spec.rb b/spec/lib/gitlab/ldap/user_spec.rb index 9a4705d1cee..260df6e4dae 100644 --- a/spec/lib/gitlab/ldap/user_spec.rb +++ b/spec/lib/gitlab/ldap/user_spec.rb @@ -44,23 +44,25 @@ describe Gitlab::LDAP::User do end describe '.find_by_uid_and_provider' do + let(:dn) { 'CN=John Ã…ström, CN=Users, DC=Example, DC=com' } + it 'retrieves the correct user' do special_info = { name: 'John Ã…ström', email: 'john@example.com', nickname: 'jastrom' } - special_hash = OmniAuth::AuthHash.new(uid: 'CN=John Ã…ström,CN=Users,DC=Example,DC=com', provider: 'ldapmain', info: special_info) + special_hash = OmniAuth::AuthHash.new(uid: dn, provider: 'ldapmain', info: special_info) special_chars_user = described_class.new(special_hash) user = special_chars_user.save - expect(described_class.find_by_uid_and_provider(special_hash.uid, special_hash.provider)).to eq user + expect(described_class.find_by_uid_and_provider(dn, 'ldapmain')).to eq user end end describe 'find or create' do it "finds the user if already existing" do - create(:omniauth_user, extern_uid: 'uid=John Smith,ou=People,dc=example,dc=com', provider: 'ldapmain') + create(:omniauth_user, extern_uid: 'uid=john smith,ou=people,dc=example,dc=com', provider: 'ldapmain') expect { ldap_user.save }.not_to change { User.count } end diff --git a/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb b/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb index b576d7173f5..0803ce42fac 100644 --- a/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb +++ b/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb @@ -4,35 +4,30 @@ describe Gitlab::Metrics::SidekiqMiddleware do let(:middleware) { described_class.new } let(:message) { { 'args' => ['test'], 'enqueued_at' => Time.new(2016, 6, 23, 6, 59).to_f } } - describe '#call' do - it 'tracks the transaction' do - worker = double(:worker, class: double(:class, name: 'TestWorker')) + def run(worker, message) + expect(Gitlab::Metrics::Transaction).to receive(:new) + .with('TestWorker#perform') + .and_call_original + + expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set) + .with(:sidekiq_queue_duration, instance_of(Float)) - expect(Gitlab::Metrics::Transaction).to receive(:new) - .with('TestWorker#perform') - .and_call_original + expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish) - expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set) - .with(:sidekiq_queue_duration, instance_of(Float)) + middleware.call(worker, message, :test) { nil } + end - expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish) + describe '#call' do + it 'tracks the transaction' do + worker = double(:worker, class: double(:class, name: 'TestWorker')) - middleware.call(worker, message, :test) { nil } + run(worker, message) end it 'tracks the transaction (for messages without `enqueued_at`)' do worker = double(:worker, class: double(:class, name: 'TestWorker')) - expect(Gitlab::Metrics::Transaction).to receive(:new) - .with('TestWorker#perform') - .and_call_original - - expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set) - .with(:sidekiq_queue_duration, instance_of(Float)) - - expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish) - - middleware.call(worker, {}, :test) { nil } + run(worker, {}) end it 'tracks any raised exceptions' do @@ -50,5 +45,18 @@ describe Gitlab::Metrics::SidekiqMiddleware do expect { middleware.call(worker, message, :test) } .to raise_error(RuntimeError) end + + it 'tags the metrics accordingly' do + tags = { one: 1, two: 2 } + worker = double(:worker, class: double(:class, name: 'TestWorker')) + allow(worker).to receive(:metrics_tags).and_return(tags) + + tags.each do |tag, value| + expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:add_tag) + .with(tag, value) + end + + run(worker, message) + end end end diff --git a/spec/lib/gitlab/middleware/go_spec.rb b/spec/lib/gitlab/middleware/go_spec.rb index cab662819ac..67121937398 100644 --- a/spec/lib/gitlab/middleware/go_spec.rb +++ b/spec/lib/gitlab/middleware/go_spec.rb @@ -17,89 +17,115 @@ describe Gitlab::Middleware::Go do describe 'when go-get=1' do let(:current_user) { nil } - context 'with simple 2-segment project path' do - let!(:project) { create(:project, :private) } + shared_examples 'go-get=1' do |enabled_protocol:| + context 'with simple 2-segment project path' do + let!(:project) { create(:project, :private) } - context 'with subpackages' do - let(:path) { "#{project.full_path}/subpackage" } + context 'with subpackages' do + let(:path) { "#{project.full_path}/subpackage" } - it 'returns the full project path' do - expect_response_with_path(go, project.full_path) - end - end - - context 'without subpackages' do - let(:path) { project.full_path } - - it 'returns the full project path' do - expect_response_with_path(go, project.full_path) + it 'returns the full project path' do + expect_response_with_path(go, enabled_protocol, project.full_path) + end end - end - end - context 'with a nested project path' do - let(:group) { create(:group, :nested) } - let!(:project) { create(:project, :public, namespace: group) } + context 'without subpackages' do + let(:path) { project.full_path } - shared_examples 'a nested project' do - context 'when the project is public' do it 'returns the full project path' do - expect_response_with_path(go, project.full_path) + expect_response_with_path(go, enabled_protocol, project.full_path) end end + end - context 'when the project is private' do - before do - project.update_attribute(:visibility_level, Project::PRIVATE) - end + context 'with a nested project path' do + let(:group) { create(:group, :nested) } + let!(:project) { create(:project, :public, namespace: group) } - context 'with access to the project' do - let(:current_user) { project.creator } + shared_examples 'a nested project' do + context 'when the project is public' do + it 'returns the full project path' do + expect_response_with_path(go, enabled_protocol, project.full_path) + end + end + context 'when the project is private' do before do - project.team.add_master(current_user) + project.update_attribute(:visibility_level, Project::PRIVATE) end - it 'returns the full project path' do - expect_response_with_path(go, project.full_path) + context 'with access to the project' do + let(:current_user) { project.creator } + + before do + project.team.add_master(current_user) + end + + it 'returns the full project path' do + expect_response_with_path(go, enabled_protocol, project.full_path) + end end - end - context 'without access to the project' do - it 'returns the 2-segment group path' do - expect_response_with_path(go, group.full_path) + context 'without access to the project' do + it 'returns the 2-segment group path' do + expect_response_with_path(go, enabled_protocol, group.full_path) + end end end end - end - context 'with subpackages' do - let(:path) { "#{project.full_path}/subpackage" } + context 'with subpackages' do + let(:path) { "#{project.full_path}/subpackage" } - it_behaves_like 'a nested project' - end + it_behaves_like 'a nested project' + end + + context 'with a subpackage that is not a valid project path' do + let(:path) { "#{project.full_path}/---subpackage" } - context 'with a subpackage that is not a valid project path' do - let(:path) { "#{project.full_path}/---subpackage" } + it_behaves_like 'a nested project' + end + + context 'without subpackages' do + let(:path) { project.full_path } - it_behaves_like 'a nested project' + it_behaves_like 'a nested project' + end end - context 'without subpackages' do - let(:path) { project.full_path } + context 'with a bogus path' do + let(:path) { "http:;url=http://www.example.com'http-equiv='refresh'x='?go-get=1" } + + it 'skips go-import generation' do + expect(app).to receive(:call).and_return('no-go') - it_behaves_like 'a nested project' + go + end + end + end + + context 'with SSH disabled' do + before do + stub_application_setting(enabled_git_access_protocol: 'http') end + + include_examples 'go-get=1', enabled_protocol: :http end - context 'with a bogus path' do - let(:path) { "http:;url=http://www.example.com'http-equiv='refresh'x='?go-get=1" } + context 'with HTTP disabled' do + before do + stub_application_setting(enabled_git_access_protocol: 'ssh') + end - it 'skips go-import generation' do - expect(app).to receive(:call).and_return('no-go') + include_examples 'go-get=1', enabled_protocol: :ssh + end - go + context 'with nothing disabled' do + before do + stub_application_setting(enabled_git_access_protocol: nil) end + + include_examples 'go-get=1', enabled_protocol: nil end end @@ -113,10 +139,16 @@ describe Gitlab::Middleware::Go do middleware.call(env) end - def expect_response_with_path(response, path) + def expect_response_with_path(response, protocol, path) + repository_url = case protocol + when :ssh + "ssh://git@#{Gitlab.config.gitlab.host}/#{path}.git" + when :http, nil + "http://#{Gitlab.config.gitlab.host}/#{path}.git" + end expect(response[0]).to eq(200) expect(response[1]['Content-Type']).to eq('text/html') - expected_body = %{<html><head><meta name="go-import" content="#{Gitlab.config.gitlab.host}/#{path} git http://#{Gitlab.config.gitlab.host}/#{path}.git" /></head></html>} + expected_body = %{<html><head><meta name="go-import" content="#{Gitlab.config.gitlab.host}/#{path} git #{repository_url}" /></head></html>} expect(response[2].body).to eq([expected_body]) end end diff --git a/spec/lib/gitlab/middleware/read_only_spec.rb b/spec/lib/gitlab/middleware/read_only_spec.rb index 742a792a1af..86be06ff595 100644 --- a/spec/lib/gitlab/middleware/read_only_spec.rb +++ b/spec/lib/gitlab/middleware/read_only_spec.rb @@ -83,6 +83,13 @@ describe Gitlab::Middleware::ReadOnly do expect(subject).to disallow_request end + it 'expects POST of new file that looks like an LFS batch url to be disallowed' do + response = request.post('/root/gitlab-ce/new/master/app/info/lfs/objects/batch') + + expect(response).to be_a_redirect + expect(subject).to disallow_request + end + context 'whitelisted requests' do it 'expects DELETE request to logout to be allowed' do response = request.delete('/users/sign_out') @@ -104,6 +111,25 @@ describe Gitlab::Middleware::ReadOnly do expect(response).not_to be_a_redirect expect(subject).not_to disallow_request end + + it 'expects a POST request to git-upload-pack URL to be allowed' do + response = request.post('/root/rouge.git/git-upload-pack') + + expect(response).not_to be_a_redirect + expect(subject).not_to disallow_request + end + + it 'expects requests to sidekiq admin to be allowed' do + response = request.post('/admin/sidekiq') + + expect(response).not_to be_a_redirect + expect(subject).not_to disallow_request + + response = request.get('/admin/sidekiq') + + expect(response).not_to be_a_redirect + expect(subject).not_to disallow_request + end end end diff --git a/spec/lib/gitlab/o_auth/user_spec.rb b/spec/lib/gitlab/o_auth/user_spec.rb index db26e16e3b2..c7471a21fda 100644 --- a/spec/lib/gitlab/o_auth/user_spec.rb +++ b/spec/lib/gitlab/o_auth/user_spec.rb @@ -4,7 +4,7 @@ describe Gitlab::OAuth::User do let(:oauth_user) { described_class.new(auth_hash) } let(:gl_user) { oauth_user.gl_user } let(:uid) { 'my-uid' } - let(:dn) { 'uid=user1,ou=People,dc=example' } + let(:dn) { 'uid=user1,ou=people,dc=example' } let(:provider) { 'my-provider' } let(:auth_hash) { OmniAuth::AuthHash.new(uid: uid, provider: provider, info: info_hash) } let(:info_hash) do diff --git a/spec/lib/gitlab/saml/user_spec.rb b/spec/lib/gitlab/saml/user_spec.rb index 1c23fb5f285..1765980e977 100644 --- a/spec/lib/gitlab/saml/user_spec.rb +++ b/spec/lib/gitlab/saml/user_spec.rb @@ -7,7 +7,7 @@ describe Gitlab::Saml::User do let(:saml_user) { described_class.new(auth_hash) } let(:gl_user) { saml_user.gl_user } let(:uid) { 'my-uid' } - let(:dn) { 'uid=user1,ou=People,dc=example' } + let(:dn) { 'uid=user1,ou=people,dc=example' } let(:provider) { 'saml' } let(:raw_info_attr) { { 'groups' => %w(Developers Freelancers Designers) } } let(:auth_hash) { OmniAuth::AuthHash.new(uid: uid, provider: provider, info: info_hash, extra: { raw_info: OneLogin::RubySaml::Attributes.new(raw_info_attr) }) } diff --git a/spec/lib/gitlab/sidekiq_middleware/memory_killer_spec.rb b/spec/lib/gitlab/sidekiq_middleware/memory_killer_spec.rb new file mode 100644 index 00000000000..8fdbbacd04d --- /dev/null +++ b/spec/lib/gitlab/sidekiq_middleware/memory_killer_spec.rb @@ -0,0 +1,63 @@ +require 'spec_helper' + +describe Gitlab::SidekiqMiddleware::MemoryKiller do + subject { described_class.new } + let(:pid) { 999 } + + let(:worker) { double(:worker, class: 'TestWorker') } + let(:job) { { 'jid' => 123 } } + let(:queue) { 'test_queue' } + + def run + thread = subject.call(worker, job, queue) { nil } + thread&.join + end + + before do + allow(subject).to receive(:get_rss).and_return(10.kilobytes) + allow(subject).to receive(:pid).and_return(pid) + end + + context 'when MAX_RSS is set to 0' do + before do + stub_const("#{described_class}::MAX_RSS", 0) + end + + it 'does nothing' do + expect(subject).not_to receive(:sleep) + + run + end + end + + context 'when MAX_RSS is exceeded' do + before do + stub_const("#{described_class}::MAX_RSS", 5.kilobytes) + end + + it 'sends the STP, TERM and KILL signals at expected times' do + expect(subject).to receive(:sleep).with(15 * 60).ordered + expect(Process).to receive(:kill).with('SIGSTP', pid).ordered + + expect(subject).to receive(:sleep).with(30).ordered + expect(Process).to receive(:kill).with('SIGTERM', pid).ordered + + expect(subject).to receive(:sleep).with(10).ordered + expect(Process).to receive(:kill).with('SIGKILL', pid).ordered + + run + end + end + + context 'when MAX_RSS is not exceeded' do + before do + stub_const("#{described_class}::MAX_RSS", 15.kilobytes) + end + + it 'does nothing' do + expect(subject).not_to receive(:sleep) + + run + end + end +end diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index a7b65e94706..a4c1113ae37 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -60,9 +60,9 @@ describe Gitlab::UsageData do deploy_keys deployments environments - gcp_clusters - gcp_clusters_enabled - gcp_clusters_disabled + clusters + clusters_enabled + clusters_disabled in_review_folder groups issues diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb index 80bf7986ee0..249c77dc636 100644 --- a/spec/lib/gitlab/workhorse_spec.rb +++ b/spec/lib/gitlab/workhorse_spec.rb @@ -268,7 +268,8 @@ describe Gitlab::Workhorse do GL_ID: "user-#{user.id}", GL_USERNAME: user.username, GL_REPOSITORY: "project-#{project.id}", - RepoPath: repo_path + RepoPath: repo_path, + ShowAllRefs: false } end @@ -282,7 +283,8 @@ describe Gitlab::Workhorse do GL_ID: "user-#{user.id}", GL_USERNAME: user.username, GL_REPOSITORY: "wiki-#{project.id}", - RepoPath: repo_path + RepoPath: repo_path, + ShowAllRefs: false } end @@ -324,6 +326,12 @@ describe Gitlab::Workhorse do expect(subject).to include(gitaly_params) end + + context 'show_all_refs enabled' do + subject { described_class.git_http_ok(repository, false, user, action, show_all_refs: true) } + + it { is_expected.to include(ShowAllRefs: true) } + end end context "when git_receive_pack action is passed" do @@ -336,6 +344,12 @@ describe Gitlab::Workhorse do let(:action) { 'info_refs' } it { expect(subject).to include(gitaly_params) } + + context 'show_all_refs enabled' do + subject { described_class.git_http_ok(repository, false, user, action, show_all_refs: true) } + + it { is_expected.to include(ShowAllRefs: true) } + end end context 'when action passed is not supported by Gitaly' do diff --git a/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb b/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb index b4b83b70d1c..a0fb86345f3 100644 --- a/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb +++ b/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb @@ -39,14 +39,6 @@ describe SystemCheck::App::GitUserDefaultSSHConfigCheck do it { is_expected.to eq(expected_result) } end - - it 'skips GitLab read-only instances' do - stub_user - stub_home_dir - allow(Gitlab::Database).to receive(:read_only?).and_return(true) - - is_expected.to be_truthy - end end describe '#check?' do diff --git a/spec/migrations/migrate_gcp_clusters_to_new_clusters_architectures_spec.rb b/spec/migrations/migrate_gcp_clusters_to_new_clusters_architectures_spec.rb new file mode 100644 index 00000000000..9f41534441b --- /dev/null +++ b/spec/migrations/migrate_gcp_clusters_to_new_clusters_architectures_spec.rb @@ -0,0 +1,166 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20171013104327_migrate_gcp_clusters_to_new_clusters_architectures.rb') + +describe MigrateGcpClustersToNewClustersArchitectures, :migration do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:service) { create(:kubernetes_service, project: project) } + + context 'when cluster is being created' do + let(:project_id) { project.id } + let(:user_id) { user.id } + let(:service_id) { service.id } + let(:status) { 2 } # creating + let(:gcp_cluster_size) { 1 } + let(:created_at) { "'2017-10-17 20:24:02'" } + let(:updated_at) { "'2017-10-17 20:28:44'" } + let(:enabled) { true } + let(:status_reason) { "''" } + let(:project_namespace) { "'sample-app'" } + let(:endpoint) { 'NULL' } + let(:ca_cert) { 'NULL' } + let(:encrypted_kubernetes_token) { 'NULL' } + let(:encrypted_kubernetes_token_iv) { 'NULL' } + let(:username) { 'NULL' } + let(:encrypted_password) { 'NULL' } + let(:encrypted_password_iv) { 'NULL' } + let(:gcp_project_id) { "'gcp_project_id'" } + let(:gcp_cluster_zone) { "'gcp_cluster_zone'" } + let(:gcp_cluster_name) { "'gcp_cluster_name'" } + let(:gcp_machine_type) { "'gcp_machine_type'" } + let(:gcp_operation_id) { 'NULL' } + let(:encrypted_gcp_token) { "'encrypted_gcp_token'" } + let(:encrypted_gcp_token_iv) { "'encrypted_gcp_token_iv'" } + + let(:cluster) { Clusters::Cluster.last } + let(:cluster_id) { cluster.id } + + before do + ActiveRecord::Base.connection.execute <<-SQL + INSERT INTO gcp_clusters (project_id, user_id, service_id, status, gcp_cluster_size, created_at, updated_at, enabled, status_reason, project_namespace, endpoint, ca_cert, encrypted_kubernetes_token, encrypted_kubernetes_token_iv, username, encrypted_password, encrypted_password_iv, gcp_project_id, gcp_cluster_zone, gcp_cluster_name, gcp_machine_type, gcp_operation_id, encrypted_gcp_token, encrypted_gcp_token_iv) + VALUES (#{project_id}, #{user_id}, #{service_id}, #{status}, #{gcp_cluster_size}, #{created_at}, #{updated_at}, #{enabled}, #{status_reason}, #{project_namespace}, #{endpoint}, #{ca_cert}, #{encrypted_kubernetes_token}, #{encrypted_kubernetes_token_iv}, #{username}, #{encrypted_password}, #{encrypted_password_iv}, #{gcp_project_id}, #{gcp_cluster_zone}, #{gcp_cluster_name}, #{gcp_machine_type}, #{gcp_operation_id}, #{encrypted_gcp_token}, #{encrypted_gcp_token_iv}); + SQL + end + + it 'correctly migrate to new clusters architectures' do + migrate! + + expect(Clusters::Cluster.count).to eq(1) + expect(Clusters::Project.count).to eq(1) + expect(Clusters::Providers::Gcp.count).to eq(1) + expect(Clusters::Platforms::Kubernetes.count).to eq(1) + + expect(cluster.user).to eq(user) + expect(cluster.enabled).to be_truthy + expect(cluster.name).to eq(gcp_cluster_name.delete!("'")) + expect(cluster.provider_type).to eq('gcp') + expect(cluster.platform_type).to eq('kubernetes') + + expect(cluster.project).to eq(project) + expect(project.cluster).to eq(cluster) + + expect(cluster.provider_gcp.cluster).to eq(cluster) + expect(cluster.provider_gcp.status).to eq(status) + expect(cluster.provider_gcp.status_reason).to eq(tr(status_reason)) + expect(cluster.provider_gcp.gcp_project_id).to eq(tr(gcp_project_id)) + expect(cluster.provider_gcp.zone).to eq(tr(gcp_cluster_zone)) + expect(cluster.provider_gcp.num_nodes).to eq(gcp_cluster_size) + expect(cluster.provider_gcp.machine_type).to eq(tr(gcp_machine_type)) + expect(cluster.provider_gcp.operation_id).to be_nil + expect(cluster.provider_gcp.endpoint).to be_nil + expect(cluster.provider_gcp.encrypted_access_token).to eq(tr(encrypted_gcp_token)) + expect(cluster.provider_gcp.encrypted_access_token_iv).to eq(tr(encrypted_gcp_token_iv)) + + expect(cluster.platform_kubernetes.cluster).to eq(cluster) + expect(cluster.platform_kubernetes.api_url).to be_nil + expect(cluster.platform_kubernetes.ca_cert).to be_nil + expect(cluster.platform_kubernetes.namespace).to eq(tr(project_namespace)) + expect(cluster.platform_kubernetes.username).to be_nil + expect(cluster.platform_kubernetes.encrypted_password).to be_nil + expect(cluster.platform_kubernetes.encrypted_password_iv).to be_nil + expect(cluster.platform_kubernetes.encrypted_token).to be_nil + expect(cluster.platform_kubernetes.encrypted_token_iv).to be_nil + end + end + + context 'when cluster has been created' do + let(:project_id) { project.id } + let(:user_id) { user.id } + let(:service_id) { service.id } + let(:status) { 3 } # created + let(:gcp_cluster_size) { 1 } + let(:created_at) { "'2017-10-17 20:24:02'" } + let(:updated_at) { "'2017-10-17 20:28:44'" } + let(:enabled) { true } + let(:status_reason) { "'general error'" } + let(:project_namespace) { "'sample-app'" } + let(:endpoint) { "'111.111.111.111'" } + let(:ca_cert) { "'ca_cert'" } + let(:encrypted_kubernetes_token) { "'encrypted_kubernetes_token'" } + let(:encrypted_kubernetes_token_iv) { "'encrypted_kubernetes_token_iv'" } + let(:username) { "'username'" } + let(:encrypted_password) { "'encrypted_password'" } + let(:encrypted_password_iv) { "'encrypted_password_iv'" } + let(:gcp_project_id) { "'gcp_project_id'" } + let(:gcp_cluster_zone) { "'gcp_cluster_zone'" } + let(:gcp_cluster_name) { "'gcp_cluster_name'" } + let(:gcp_machine_type) { "'gcp_machine_type'" } + let(:gcp_operation_id) { "'gcp_operation_id'" } + let(:encrypted_gcp_token) { "'encrypted_gcp_token'" } + let(:encrypted_gcp_token_iv) { "'encrypted_gcp_token_iv'" } + + let(:cluster) { Clusters::Cluster.last } + let(:cluster_id) { cluster.id } + + before do + ActiveRecord::Base.connection.execute <<-SQL + INSERT INTO gcp_clusters (project_id, user_id, service_id, status, gcp_cluster_size, created_at, updated_at, enabled, status_reason, project_namespace, endpoint, ca_cert, encrypted_kubernetes_token, encrypted_kubernetes_token_iv, username, encrypted_password, encrypted_password_iv, gcp_project_id, gcp_cluster_zone, gcp_cluster_name, gcp_machine_type, gcp_operation_id, encrypted_gcp_token, encrypted_gcp_token_iv) + VALUES (#{project_id}, #{user_id}, #{service_id}, #{status}, #{gcp_cluster_size}, #{created_at}, #{updated_at}, #{enabled}, #{status_reason}, #{project_namespace}, #{endpoint}, #{ca_cert}, #{encrypted_kubernetes_token}, #{encrypted_kubernetes_token_iv}, #{username}, #{encrypted_password}, #{encrypted_password_iv}, #{gcp_project_id}, #{gcp_cluster_zone}, #{gcp_cluster_name}, #{gcp_machine_type}, #{gcp_operation_id}, #{encrypted_gcp_token}, #{encrypted_gcp_token_iv}); + SQL + end + + it 'correctly migrate to new clusters architectures' do + migrate! + + expect(Clusters::Cluster.count).to eq(1) + expect(Clusters::Project.count).to eq(1) + expect(Clusters::Providers::Gcp.count).to eq(1) + expect(Clusters::Platforms::Kubernetes.count).to eq(1) + + expect(cluster.user).to eq(user) + expect(cluster.enabled).to be_truthy + expect(cluster.name).to eq(tr(gcp_cluster_name)) + expect(cluster.provider_type).to eq('gcp') + expect(cluster.platform_type).to eq('kubernetes') + + expect(cluster.project).to eq(project) + expect(project.cluster).to eq(cluster) + + expect(cluster.provider_gcp.cluster).to eq(cluster) + expect(cluster.provider_gcp.status).to eq(status) + expect(cluster.provider_gcp.status_reason).to eq(tr(status_reason)) + expect(cluster.provider_gcp.gcp_project_id).to eq(tr(gcp_project_id)) + expect(cluster.provider_gcp.zone).to eq(tr(gcp_cluster_zone)) + expect(cluster.provider_gcp.num_nodes).to eq(gcp_cluster_size) + expect(cluster.provider_gcp.machine_type).to eq(tr(gcp_machine_type)) + expect(cluster.provider_gcp.operation_id).to eq(tr(gcp_operation_id)) + expect(cluster.provider_gcp.endpoint).to eq(tr(endpoint)) + expect(cluster.provider_gcp.encrypted_access_token).to eq(tr(encrypted_gcp_token)) + expect(cluster.provider_gcp.encrypted_access_token_iv).to eq(tr(encrypted_gcp_token_iv)) + + expect(cluster.platform_kubernetes.cluster).to eq(cluster) + expect(cluster.platform_kubernetes.api_url).to eq('https://' + tr(endpoint)) + expect(cluster.platform_kubernetes.ca_cert).to eq(tr(ca_cert)) + expect(cluster.platform_kubernetes.namespace).to eq(tr(project_namespace)) + expect(cluster.platform_kubernetes.username).to eq(tr(username)) + expect(cluster.platform_kubernetes.encrypted_password).to eq(tr(encrypted_password)) + expect(cluster.platform_kubernetes.encrypted_password_iv).to eq(tr(encrypted_password_iv)) + expect(cluster.platform_kubernetes.encrypted_token).to eq(tr(encrypted_kubernetes_token)) + expect(cluster.platform_kubernetes.encrypted_token_iv).to eq(tr(encrypted_kubernetes_token_iv)) + end + end + + def tr(s) + s.delete("'") + end +end diff --git a/spec/migrations/migrate_user_authentication_token_to_personal_access_token_spec.rb b/spec/migrations/migrate_user_authentication_token_to_personal_access_token_spec.rb new file mode 100644 index 00000000000..b4834705011 --- /dev/null +++ b/spec/migrations/migrate_user_authentication_token_to_personal_access_token_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' +require Rails.root.join('db', 'migrate', '20171012125712_migrate_user_authentication_token_to_personal_access_token.rb') + +describe MigrateUserAuthenticationTokenToPersonalAccessToken, :migration do + let(:users) { table(:users) } + let(:personal_access_tokens) { table(:personal_access_tokens) } + + let!(:user) { users.create!(id: 1, email: 'user@example.com', authentication_token: 'user-token', admin: false) } + let!(:admin) { users.create!(id: 2, email: 'admin@example.com', authentication_token: 'admin-token', admin: true) } + + it 'migrates private tokens to Personal Access Tokens' do + migrate! + + expect(personal_access_tokens.count).to eq(2) + + user_token = personal_access_tokens.find_by(user_id: user.id) + admin_token = personal_access_tokens.find_by(user_id: admin.id) + + expect(user_token.token).to eq('user-token') + expect(admin_token.token).to eq('admin-token') + + expect(user_token.scopes).to eq(%w[api].to_yaml) + expect(admin_token.scopes).to eq(%w[api sudo].to_yaml) + end +end diff --git a/spec/migrations/populate_merge_requests_latest_merge_request_diff_id_spec.rb b/spec/migrations/populate_merge_requests_latest_merge_request_diff_id_spec.rb new file mode 100644 index 00000000000..4ea7f441f7c --- /dev/null +++ b/spec/migrations/populate_merge_requests_latest_merge_request_diff_id_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20171026082505_populate_merge_requests_latest_merge_request_diff_id') + +describe PopulateMergeRequestsLatestMergeRequestDiffId, :migration do + let(:projects_table) { table(:projects) } + let(:merge_requests_table) { table(:merge_requests) } + let(:merge_request_diffs_table) { table(:merge_request_diffs) } + + let(:project) { projects_table.create!(name: 'gitlab', path: 'gitlab-org/gitlab-ce') } + + def create_mr!(name, diffs: 0) + merge_request = + merge_requests_table.create!(target_project_id: project.id, + target_branch: 'master', + source_project_id: project.id, + source_branch: name, + title: name) + + diffs.times do + merge_request_diffs_table.create!(merge_request_id: merge_request.id) + end + + merge_request + end + + def diffs_for(merge_request) + merge_request_diffs_table.where(merge_request_id: merge_request.id) + end + + describe '#up' do + it 'ignores MRs without diffs' do + merge_request_without_diff = create_mr!('without_diff') + + expect(merge_request_without_diff.latest_merge_request_diff_id).to be_nil + + expect { migrate! } + .not_to change { merge_request_without_diff.reload.latest_merge_request_diff_id } + end + + it 'ignores MRs that have a diff ID already set' do + merge_request_with_multiple_diffs = create_mr!('with_multiple_diffs', diffs: 3) + diff_id = diffs_for(merge_request_with_multiple_diffs).minimum(:id) + + merge_request_with_multiple_diffs.update!(latest_merge_request_diff_id: diff_id) + + expect { migrate! } + .not_to change { merge_request_with_multiple_diffs.reload.latest_merge_request_diff_id } + end + + it 'migrates multiple MR diffs to the correct values' do + merge_requests = Array.new(3).map.with_index { |_, i| create_mr!(i, diffs: 3) } + + migrate! + + merge_requests.each do |merge_request| + expect(merge_request.reload.latest_merge_request_diff_id) + .to eq(diffs_for(merge_request).maximum(:id)) + end + end + end +end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 41ecdb604f1..5ed2e1ca99a 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -1271,6 +1271,7 @@ describe Ci::Build do { key: 'CI_PROJECT_PATH_SLUG', value: project.full_path_slug, public: true }, { key: 'CI_PROJECT_NAMESPACE', value: project.namespace.full_path, public: true }, { key: 'CI_PROJECT_URL', value: project.web_url, public: true }, + { key: 'CI_PROJECT_VISIBILITY', value: 'private', public: true }, { key: 'CI_PIPELINE_ID', value: pipeline.id.to_s, public: true }, { key: 'CI_CONFIG_PATH', value: pipeline.ci_yaml_file_path, public: true }, { key: 'CI_REGISTRY_USER', value: 'gitlab-ci-token', public: true }, diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb new file mode 100644 index 00000000000..12123a3d753 --- /dev/null +++ b/spec/models/clusters/cluster_spec.rb @@ -0,0 +1,181 @@ +require 'spec_helper' + +describe Clusters::Cluster do + it { is_expected.to belong_to(:user) } + it { is_expected.to have_many(:projects) } + it { is_expected.to have_one(:provider_gcp) } + it { is_expected.to have_one(:platform_kubernetes) } + it { is_expected.to delegate_method(:status).to(:provider) } + it { is_expected.to delegate_method(:status_reason).to(:provider) } + it { is_expected.to delegate_method(:status_name).to(:provider) } + it { is_expected.to delegate_method(:on_creation?).to(:provider) } + it { is_expected.to delegate_method(:update_kubernetes_integration!).to(:platform) } + it { is_expected.to respond_to :project } + + describe '.enabled' do + subject { described_class.enabled } + + let!(:cluster) { create(:cluster, enabled: true) } + + before do + create(:cluster, enabled: false) + end + + it { is_expected.to contain_exactly(cluster) } + end + + describe '.disabled' do + subject { described_class.disabled } + + let!(:cluster) { create(:cluster, enabled: false) } + + before do + create(:cluster, enabled: true) + end + + it { is_expected.to contain_exactly(cluster) } + end + + describe 'validation' do + subject { cluster.valid? } + + context 'when validates name' do + context 'when provided by user' do + let!(:cluster) { build(:cluster, :provided_by_user, name: name) } + + context 'when name is empty' do + let(:name) { '' } + + it { is_expected.to be_falsey } + end + + context 'when name is nil' do + let(:name) { nil } + + it { is_expected.to be_falsey } + end + + context 'when name is present' do + let(:name) { 'cluster-name-1' } + + it { is_expected.to be_truthy } + end + end + + context 'when provided by gcp' do + let!(:cluster) { build(:cluster, :provided_by_gcp, name: name) } + + context 'when name is shorter than 1' do + let(:name) { '' } + + it { is_expected.to be_falsey } + end + + context 'when name is longer than 63' do + let(:name) { 'a' * 64 } + + it { is_expected.to be_falsey } + end + + context 'when name includes invalid character' do + let(:name) { '!!!!!!' } + + it { is_expected.to be_falsey } + end + + context 'when name is present' do + let(:name) { 'cluster-name-1' } + + it { is_expected.to be_truthy } + end + + context 'when record is persisted' do + let(:name) { 'cluster-name-1' } + + before do + cluster.save! + end + + context 'when name is changed' do + before do + cluster.name = 'new-cluster-name' + end + + it { is_expected.to be_falsey } + end + + context 'when name is same' do + before do + cluster.name = name + end + + it { is_expected.to be_truthy } + end + end + end + end + + context 'when validates restrict_modification' do + context 'when creation is on going' do + let!(:cluster) { create(:cluster, :providing_by_gcp) } + + it { expect(cluster.update(enabled: false)).to be_falsey } + end + + context 'when creation is done' do + let!(:cluster) { create(:cluster, :provided_by_gcp) } + + it { expect(cluster.update(enabled: false)).to be_truthy } + end + end + end + + describe '#provider' do + subject { cluster.provider } + + context 'when provider is gcp' do + let(:cluster) { create(:cluster, :provided_by_gcp) } + + it 'returns a provider' do + is_expected.to eq(cluster.provider_gcp) + expect(subject.class.name.deconstantize).to eq(Clusters::Providers.to_s) + end + end + + context 'when provider is user' do + let(:cluster) { create(:cluster, :provided_by_user) } + + it { is_expected.to be_nil } + end + end + + describe '#platform' do + subject { cluster.platform } + + context 'when platform is kubernetes' do + let(:cluster) { create(:cluster, :provided_by_user) } + + it 'returns a platform' do + is_expected.to eq(cluster.platform_kubernetes) + expect(subject.class.name.deconstantize).to eq(Clusters::Platforms.to_s) + end + end + end + + describe '#first_project' do + subject { cluster.first_project } + + context 'when cluster belongs to a project' do + let(:cluster) { create(:cluster, :project) } + let(:project) { Clusters::Project.find_by_cluster_id(cluster.id).project } + + it { is_expected.to eq(project) } + end + + context 'when cluster does not belong to projects' do + let(:cluster) { create(:cluster) } + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/models/clusters/platforms/kubernetes_spec.rb b/spec/models/clusters/platforms/kubernetes_spec.rb new file mode 100644 index 00000000000..e6ebe079ceb --- /dev/null +++ b/spec/models/clusters/platforms/kubernetes_spec.rb @@ -0,0 +1,188 @@ +require 'spec_helper' + +describe Clusters::Platforms::Kubernetes, :use_clean_rails_memory_store_caching do + include KubernetesHelpers + include ReactiveCachingHelpers + + it { is_expected.to belong_to(:cluster) } + it { is_expected.to respond_to :ca_pem } + + describe 'before_validation' do + context 'when namespace includes upper case' do + let(:kubernetes) { create(:platform_kubernetes, :configured, namespace: namespace) } + let(:namespace) { 'ABC' } + + it 'converts to lower case' do + expect(kubernetes.namespace).to eq('abc') + end + end + end + + describe 'validation' do + subject { kubernetes.valid? } + + context 'when validates namespace' do + let(:kubernetes) { build(:platform_kubernetes, :configured, namespace: namespace) } + + context 'when namespace is blank' do + let(:namespace) { '' } + + it { is_expected.to be_truthy } + end + + context 'when namespace is longer than 63' do + let(:namespace) { 'a' * 64 } + + it { is_expected.to be_falsey } + end + + context 'when namespace includes invalid character' do + let(:namespace) { '!!!!!!' } + + it { is_expected.to be_falsey } + end + + context 'when namespace is vaild' do + let(:namespace) { 'namespace-123' } + + it { is_expected.to be_truthy } + end + end + + context 'when validates api_url' do + let(:kubernetes) { build(:platform_kubernetes, :configured) } + + before do + kubernetes.api_url = api_url + end + + context 'when api_url is invalid url' do + let(:api_url) { '!!!!!!' } + + it { expect(kubernetes.save).to be_falsey } + end + + context 'when api_url is nil' do + let(:api_url) { nil } + + it { expect(kubernetes.save).to be_falsey } + end + + context 'when api_url is valid url' do + let(:api_url) { 'https://111.111.111.111' } + + it { expect(kubernetes.save).to be_truthy } + end + end + + context 'when validates token' do + let(:kubernetes) { build(:platform_kubernetes, :configured) } + + before do + kubernetes.token = token + end + + context 'when token is nil' do + let(:token) { nil } + + it { expect(kubernetes.save).to be_falsey } + end + end + end + + describe 'after_save from Clusters::Cluster' do + context 'when platform_kubernetes is being cerated' do + let(:enabled) { true } + let(:project) { create(:project) } + let(:cluster) { build(:cluster, provider_type: :gcp, platform_type: :kubernetes, platform_kubernetes: platform, provider_gcp: provider, enabled: enabled, projects: [project]) } + let(:platform) { build(:platform_kubernetes, :configured) } + let(:provider) { build(:provider_gcp) } + let(:kubernetes_service) { project.kubernetes_service } + + it 'updates KubernetesService' do + cluster.save! + + expect(kubernetes_service.active).to eq(enabled) + expect(kubernetes_service.api_url).to eq(platform.api_url) + expect(kubernetes_service.namespace).to eq(platform.namespace) + expect(kubernetes_service.ca_pem).to eq(platform.ca_cert) + end + end + + context 'when platform_kubernetes has been created' do + let(:enabled) { false } + let!(:project) { create(:project) } + let!(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } + let(:platform) { cluster.platform } + let(:kubernetes_service) { project.kubernetes_service } + + it 'updates KubernetesService' do + cluster.update(enabled: enabled) + + expect(kubernetes_service.active).to eq(enabled) + end + end + + context 'when kubernetes_service has been configured without cluster integration' do + let!(:project) { create(:project) } + let(:cluster) { build(:cluster, provider_type: :gcp, platform_type: :kubernetes, platform_kubernetes: platform, provider_gcp: provider, projects: [project]) } + let(:platform) { build(:platform_kubernetes, :configured, api_url: 'https://111.111.111.111') } + let(:provider) { build(:provider_gcp) } + + before do + create(:kubernetes_service, project: project) + end + + it 'raises an error' do + expect { cluster.save! }.to raise_error('Kubernetes service already configured') + end + end + end + + describe '#actual_namespace' do + subject { kubernetes.actual_namespace } + + let!(:cluster) { create(:cluster, :project, platform_kubernetes: kubernetes) } + let(:project) { cluster.project } + let(:kubernetes) { create(:platform_kubernetes, :configured, namespace: namespace) } + + context 'when namespace is present' do + let(:namespace) { 'namespace-123' } + + it { is_expected.to eq(namespace) } + end + + context 'when namespace is not present' do + let(:namespace) { nil } + + it { is_expected.to eq("#{project.path}-#{project.id}") } + end + end + + describe '.namespace_for_project' do + subject { described_class.namespace_for_project(project) } + + let(:project) { create(:project) } + + it { is_expected.to eq("#{project.path}-#{project.id}") } + end + + describe '#default_namespace' do + subject { kubernetes.default_namespace } + + let(:kubernetes) { create(:platform_kubernetes, :configured) } + + context 'when cluster belongs to a project' do + let!(:cluster) { create(:cluster, :project, platform_kubernetes: kubernetes) } + let(:project) { cluster.project } + + it { is_expected.to eq("#{project.path}-#{project.id}") } + end + + context 'when cluster belongs to nothing' do + let!(:cluster) { create(:cluster, platform_kubernetes: kubernetes) } + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/models/clusters/project_spec.rb b/spec/models/clusters/project_spec.rb new file mode 100644 index 00000000000..7d75d6ab345 --- /dev/null +++ b/spec/models/clusters/project_spec.rb @@ -0,0 +1,6 @@ +require 'spec_helper' + +describe Clusters::Project do + it { is_expected.to belong_to(:cluster) } + it { is_expected.to belong_to(:project) } +end diff --git a/spec/models/clusters/providers/gcp_spec.rb b/spec/models/clusters/providers/gcp_spec.rb new file mode 100644 index 00000000000..99eb8c46e9a --- /dev/null +++ b/spec/models/clusters/providers/gcp_spec.rb @@ -0,0 +1,183 @@ +require 'spec_helper' + +describe Clusters::Providers::Gcp do + it { is_expected.to belong_to(:cluster) } + it { is_expected.to validate_presence_of(:zone) } + + describe 'default_value_for' do + let(:gcp) { build(:provider_gcp) } + + it "has default value" do + expect(gcp.zone).to eq('us-central1-a') + expect(gcp.num_nodes).to eq(3) + expect(gcp.machine_type).to eq('n1-standard-4') + end + end + + describe 'validation' do + subject { gcp.valid? } + + context 'when validates gcp_project_id' do + let(:gcp) { build(:provider_gcp, gcp_project_id: gcp_project_id) } + + context 'when gcp_project_id is shorter than 1' do + let(:gcp_project_id) { '' } + + it { is_expected.to be_falsey } + end + + context 'when gcp_project_id is longer than 63' do + let(:gcp_project_id) { 'a' * 64 } + + it { is_expected.to be_falsey } + end + + context 'when gcp_project_id includes invalid character' do + let(:gcp_project_id) { '!!!!!!' } + + it { is_expected.to be_falsey } + end + + context 'when gcp_project_id is valid' do + let(:gcp_project_id) { 'gcp-project-1' } + + it { is_expected.to be_truthy } + end + end + + context 'when validates num_nodes' do + let(:gcp) { build(:provider_gcp, num_nodes: num_nodes) } + + context 'when num_nodes is string' do + let(:num_nodes) { 'A3' } + + it { is_expected.to be_falsey } + end + + context 'when num_nodes is nil' do + let(:num_nodes) { nil } + + it { is_expected.to be_falsey } + end + + context 'when num_nodes is smaller than 1' do + let(:num_nodes) { 0 } + + it { is_expected.to be_falsey } + end + + context 'when num_nodes is valid' do + let(:num_nodes) { 3 } + + it { is_expected.to be_truthy } + end + end + end + + describe '#state_machine' do + context 'when any => [:created]' do + let(:gcp) { build(:provider_gcp, :creating) } + + before do + gcp.make_created + end + + it 'nullify access_token and operation_id' do + expect(gcp.access_token).to be_nil + expect(gcp.operation_id).to be_nil + expect(gcp).to be_created + end + end + + context 'when any => [:creating]' do + let(:gcp) { build(:provider_gcp) } + + context 'when operation_id is present' do + let(:operation_id) { 'operation-xxx' } + + before do + gcp.make_creating(operation_id) + end + + it 'sets operation_id' do + expect(gcp.operation_id).to eq(operation_id) + expect(gcp).to be_creating + end + end + + context 'when operation_id is nil' do + let(:operation_id) { nil } + + it 'raises an error' do + expect { gcp.make_creating(operation_id) } + .to raise_error('operation_id is required') + end + end + end + + context 'when any => [:errored]' do + let(:gcp) { build(:provider_gcp, :creating) } + let(:status_reason) { 'err msg' } + + it 'nullify access_token and operation_id' do + gcp.make_errored(status_reason) + + expect(gcp.access_token).to be_nil + expect(gcp.operation_id).to be_nil + expect(gcp.status_reason).to eq(status_reason) + expect(gcp).to be_errored + end + + context 'when status_reason is nil' do + let(:gcp) { build(:provider_gcp, :errored) } + + it 'does not set status_reason' do + gcp.make_errored(nil) + + expect(gcp.status_reason).not_to be_nil + end + end + end + end + + describe '#on_creation?' do + subject { gcp.on_creation? } + + context 'when status is creating' do + let(:gcp) { create(:provider_gcp, :creating) } + + it { is_expected.to be_truthy } + end + + context 'when status is created' do + let(:gcp) { create(:provider_gcp, :created) } + + it { is_expected.to be_falsey } + end + end + + describe '#api_client' do + subject { gcp.api_client } + + context 'when status is creating' do + let(:gcp) { build(:provider_gcp, :creating) } + + it 'returns Cloud Platform API clinet' do + expect(subject).to be_an_instance_of(GoogleApi::CloudPlatform::Client) + expect(subject.access_token).to eq(gcp.access_token) + end + end + + context 'when status is created' do + let(:gcp) { build(:provider_gcp, :created) } + + it { is_expected.to be_nil } + end + + context 'when status is errored' do + let(:gcp) { build(:provider_gcp, :errored) } + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/models/concerns/routable_spec.rb b/spec/models/concerns/routable_spec.rb index ab8773b7ede..3106207811a 100644 --- a/spec/models/concerns/routable_spec.rb +++ b/spec/models/concerns/routable_spec.rb @@ -134,6 +134,7 @@ describe Group, 'Routable' do context 'with RequestStore active', :request_store do it 'does not load the route table more than once' do + group.expires_full_path_cache expect(group).to receive(:uncached_full_path).once.and_call_original 3.times { group.full_path } diff --git a/spec/models/concerns/subscribable_spec.rb b/spec/models/concerns/subscribable_spec.rb index 28ff8158e0e..45dfb136aea 100644 --- a/spec/models/concerns/subscribable_spec.rb +++ b/spec/models/concerns/subscribable_spec.rb @@ -6,6 +6,12 @@ describe Subscribable, 'Subscribable' do let(:user_1) { create(:user) } describe '#subscribed?' do + context 'without user' do + it 'returns false' do + expect(resource.subscribed?(nil, project)).to be_falsey + end + end + context 'without project' do it 'returns false when no subscription exists' do expect(resource.subscribed?(user_1)).to be_falsey diff --git a/spec/models/concerns/token_authenticatable_spec.rb b/spec/models/concerns/token_authenticatable_spec.rb index 882afeccfc6..dfb83578fce 100644 --- a/spec/models/concerns/token_authenticatable_spec.rb +++ b/spec/models/concerns/token_authenticatable_spec.rb @@ -12,7 +12,7 @@ shared_examples 'TokenAuthenticatable' do end describe User, 'TokenAuthenticatable' do - let(:token_field) { :authentication_token } + let(:token_field) { :rss_token } it_behaves_like 'TokenAuthenticatable' describe 'ensures authentication token' do diff --git a/spec/models/email_spec.rb b/spec/models/email_spec.rb index b32dd31ae6d..47eb0717c0c 100644 --- a/spec/models/email_spec.rb +++ b/spec/models/email_spec.rb @@ -40,4 +40,12 @@ describe Email do expect(user.emails.confirmed.count).to eq 1 end end + + describe 'delegation' do + let(:user) { create(:user) } + + it 'delegates to :user' do + expect(build(:email, user: user).username).to eq user.username + end + end end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index e1be23541e8..1ce1d595c60 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -18,7 +18,6 @@ describe Environment do it { is_expected.to validate_length_of(:slug).is_at_most(24) } it { is_expected.to validate_length_of(:external_url).is_at_most(255) } - it { is_expected.to validate_uniqueness_of(:external_url).scoped_to(:project_id) } describe '.order_by_last_deployed_at' do let(:project) { create(:project, :repository) } @@ -547,6 +546,15 @@ describe Environment do expect(environment.slug).to eq(original_slug) end + + it "regenerates the slug if nil" do + environment = build(:environment, slug: nil) + + new_slug = environment.slug + + expect(new_slug).not_to be_nil + expect(environment.slug).to eq(new_slug) + end end describe '#generate_slug' do @@ -583,6 +591,12 @@ describe Environment do it 'returns a path that uses the slug and does not have spaces' do expect(environment.ref_path).to start_with('refs/environments/staging-review-1-') end + + it "doesn't change when the slug is nil initially" do + environment.slug = nil + + expect(environment.ref_path).to eq(environment.ref_path) + end end describe '#external_url_for' do diff --git a/spec/models/fork_network_spec.rb b/spec/models/fork_network_spec.rb index 605ccd6db06..a43baf1820a 100644 --- a/spec/models/fork_network_spec.rb +++ b/spec/models/fork_network_spec.rb @@ -24,6 +24,16 @@ describe ForkNetwork do end end + describe '#merge_requests' do + it 'finds merge requests within the fork network' do + project = create(:project) + forked_project = fork_project(project) + merge_request = create(:merge_request, source_project: forked_project, target_project: project) + + expect(project.fork_network.merge_requests).to include(merge_request) + end + end + context 'for a deleted project' do it 'keeps the fork network' do project = create(:project, :public) diff --git a/spec/models/gcp/cluster_spec.rb b/spec/models/gcp/cluster_spec.rb deleted file mode 100644 index 8f39fff6394..00000000000 --- a/spec/models/gcp/cluster_spec.rb +++ /dev/null @@ -1,264 +0,0 @@ -require 'spec_helper' - -describe Gcp::Cluster do - it { is_expected.to belong_to(:project) } - it { is_expected.to belong_to(:user) } - it { is_expected.to belong_to(:service) } - - it { is_expected.to validate_presence_of(:gcp_cluster_zone) } - - describe '.enabled' do - subject { described_class.enabled } - - let!(:cluster) { create(:gcp_cluster, enabled: true) } - - before do - create(:gcp_cluster, enabled: false) - end - - it { is_expected.to contain_exactly(cluster) } - end - - describe '.disabled' do - subject { described_class.disabled } - - let!(:cluster) { create(:gcp_cluster, enabled: false) } - - before do - create(:gcp_cluster, enabled: true) - end - - it { is_expected.to contain_exactly(cluster) } - end - - describe '#default_value_for' do - let(:cluster) { described_class.new } - - it { expect(cluster.gcp_cluster_zone).to eq('us-central1-a') } - it { expect(cluster.gcp_cluster_size).to eq(3) } - it { expect(cluster.gcp_machine_type).to eq('n1-standard-4') } - end - - describe '#validates' do - subject { cluster.valid? } - - context 'when validates gcp_project_id' do - let(:cluster) { build(:gcp_cluster, gcp_project_id: gcp_project_id) } - - context 'when valid' do - let(:gcp_project_id) { 'gcp-project-12345' } - - it { is_expected.to be_truthy } - end - - context 'when empty' do - let(:gcp_project_id) { '' } - - it { is_expected.to be_falsey } - end - - context 'when too long' do - let(:gcp_project_id) { 'A' * 64 } - - it { is_expected.to be_falsey } - end - - context 'when includes abnormal character' do - let(:gcp_project_id) { '!!!!!!' } - - it { is_expected.to be_falsey } - end - end - - context 'when validates gcp_cluster_name' do - let(:cluster) { build(:gcp_cluster, gcp_cluster_name: gcp_cluster_name) } - - context 'when valid' do - let(:gcp_cluster_name) { 'test-cluster' } - - it { is_expected.to be_truthy } - end - - context 'when empty' do - let(:gcp_cluster_name) { '' } - - it { is_expected.to be_falsey } - end - - context 'when too long' do - let(:gcp_cluster_name) { 'A' * 64 } - - it { is_expected.to be_falsey } - end - - context 'when includes abnormal character' do - let(:gcp_cluster_name) { '!!!!!!' } - - it { is_expected.to be_falsey } - end - end - - context 'when validates gcp_cluster_size' do - let(:cluster) { build(:gcp_cluster, gcp_cluster_size: gcp_cluster_size) } - - context 'when valid' do - let(:gcp_cluster_size) { 1 } - - it { is_expected.to be_truthy } - end - - context 'when zero' do - let(:gcp_cluster_size) { 0 } - - it { is_expected.to be_falsey } - end - end - - context 'when validates project_namespace' do - let(:cluster) { build(:gcp_cluster, project_namespace: project_namespace) } - - context 'when valid' do - let(:project_namespace) { 'default-namespace' } - - it { is_expected.to be_truthy } - end - - context 'when empty' do - let(:project_namespace) { '' } - - it { is_expected.to be_truthy } - end - - context 'when too long' do - let(:project_namespace) { 'A' * 64 } - - it { is_expected.to be_falsey } - end - - context 'when includes abnormal character' do - let(:project_namespace) { '!!!!!!' } - - it { is_expected.to be_falsey } - end - end - - context 'when validates restrict_modification' do - let(:cluster) { create(:gcp_cluster) } - - before do - cluster.make_creating! - end - - context 'when created' do - before do - cluster.make_created! - end - - it { is_expected.to be_truthy } - end - - context 'when creating' do - it { is_expected.to be_falsey } - end - end - end - - describe '#state_machine' do - let(:cluster) { build(:gcp_cluster) } - - context 'when transits to created state' do - before do - cluster.gcp_token = 'tmp' - cluster.gcp_operation_id = 'tmp' - cluster.make_created! - end - - it 'nullify gcp_token and gcp_operation_id' do - expect(cluster.gcp_token).to be_nil - expect(cluster.gcp_operation_id).to be_nil - expect(cluster).to be_created - end - end - - context 'when transits to errored state' do - let(:reason) { 'something wrong' } - - before do - cluster.make_errored!(reason) - end - - it 'sets status_reason' do - expect(cluster.status_reason).to eq(reason) - expect(cluster).to be_errored - end - end - end - - describe '#project_namespace_placeholder' do - subject { cluster.project_namespace_placeholder } - - let(:cluster) { create(:gcp_cluster) } - - it 'returns a placeholder' do - is_expected.to eq("#{cluster.project.path}-#{cluster.project.id}") - end - end - - describe '#on_creation?' do - subject { cluster.on_creation? } - - let(:cluster) { create(:gcp_cluster) } - - context 'when status is creating' do - before do - cluster.make_creating! - end - - it { is_expected.to be_truthy } - end - - context 'when status is created' do - before do - cluster.make_created! - end - - it { is_expected.to be_falsey } - end - end - - describe '#api_url' do - subject { cluster.api_url } - - let(:cluster) { create(:gcp_cluster, :created_on_gke) } - let(:api_url) { 'https://' + cluster.endpoint } - - it { is_expected.to eq(api_url) } - end - - describe '#restrict_modification' do - subject { cluster.restrict_modification } - - let(:cluster) { create(:gcp_cluster) } - - context 'when status is created' do - before do - cluster.make_created! - end - - it { is_expected.to be_truthy } - end - - context 'when status is creating' do - before do - cluster.make_creating! - end - - it { is_expected.to be_falsey } - - it 'sets error' do - is_expected.to be_falsey - expect(cluster.errors).not_to be_empty - end - end - end -end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index f36d6eeb327..0e1a7fdce0b 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -488,6 +488,47 @@ describe Group do end end + describe '#path_changed_hook' do + let(:system_hook_service) { SystemHooksService.new } + + context 'for a new group' do + let(:group) { build(:group) } + + before do + expect(group).to receive(:system_hook_service).and_return(system_hook_service) + end + + it 'does not trigger system hook' do + expect(system_hook_service).to receive(:execute_hooks_for).with(group, :create) + + group.save! + end + end + + context 'for an existing group' do + let(:group) { create(:group, path: 'old-path') } + + context 'when the path is changed' do + let(:new_path) { 'very-new-path' } + + it 'triggers the rename system hook' do + expect(group).to receive(:system_hook_service).and_return(system_hook_service) + expect(system_hook_service).to receive(:execute_hooks_for).with(group, :rename) + + group.update_attributes!(path: new_path) + end + end + + context 'when the path is not changed' do + it 'does not trigger system hook' do + expect(group).not_to receive(:system_hook_service) + + group.update_attributes!(name: 'new name') + end + end + end + end + describe '#secret_variables_for' do let(:project) { create(:project, group: group) } diff --git a/spec/models/identity_spec.rb b/spec/models/identity_spec.rb index 4ca6556d0f4..3ed048744de 100644 --- a/spec/models/identity_spec.rb +++ b/spec/models/identity_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -RSpec.describe Identity do +describe Identity do describe 'relations' do it { is_expected.to belong_to(:user) } end @@ -22,4 +22,16 @@ RSpec.describe Identity do expect(other_identity.ldap?).to be_falsey end end + + describe '.with_extern_uid' do + context 'LDAP identity' do + let!(:ldap_identity) { create(:identity, provider: 'ldapmain', extern_uid: 'uid=john smith,ou=people,dc=example,dc=com') } + + it 'finds the identity when the DN is formatted differently' do + identity = described_class.with_extern_uid('ldapmain', 'uid=John Smith, ou=People, dc=example, dc=com').first + + expect(identity).to eq(ldap_identity) + end + end + end end diff --git a/spec/models/merge_request_diff_commit_spec.rb b/spec/models/merge_request_diff_commit_spec.rb index 9d4a0ecf8c0..7709cf43200 100644 --- a/spec/models/merge_request_diff_commit_spec.rb +++ b/spec/models/merge_request_diff_commit_spec.rb @@ -2,14 +2,93 @@ require 'rails_helper' describe MergeRequestDiffCommit do let(:merge_request) { create(:merge_request) } - subject { merge_request.commits.first } + let(:project) { merge_request.project } describe '#to_hash' do + subject { merge_request.commits.first } + it 'returns the same results as Commit#to_hash, except for parent_ids' do - commit_from_repo = merge_request.project.repository.commit(subject.sha) + commit_from_repo = project.repository.commit(subject.sha) commit_from_repo_hash = commit_from_repo.to_hash.merge(parent_ids: []) expect(subject.to_hash).to eq(commit_from_repo_hash) end end + + describe '.create_bulk' do + let(:sha_attribute) { Gitlab::Database::ShaAttribute.new } + let(:merge_request_diff_id) { merge_request.merge_request_diff.id } + let(:commits) do + [ + project.commit('5937ac0a7beb003549fc5fd26fc247adbce4a52e'), + project.commit('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') + ] + end + let(:rows) do + [ + { + "message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "authored_date": "2014-02-27T10:01:38.000+01:00".to_time, + "author_name": "Dmitriy Zaporozhets", + "author_email": "dmitriy.zaporozhets@gmail.com", + "committed_date": "2014-02-27T10:01:38.000+01:00".to_time, + "committer_name": "Dmitriy Zaporozhets", + "committer_email": "dmitriy.zaporozhets@gmail.com", + "merge_request_diff_id": merge_request_diff_id, + "relative_order": 0, + "sha": sha_attribute.type_cast_for_database('5937ac0a7beb003549fc5fd26fc247adbce4a52e') + }, + { + "message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "authored_date": "2014-02-27T09:57:31.000+01:00".to_time, + "author_name": "Dmitriy Zaporozhets", + "author_email": "dmitriy.zaporozhets@gmail.com", + "committed_date": "2014-02-27T09:57:31.000+01:00".to_time, + "committer_name": "Dmitriy Zaporozhets", + "committer_email": "dmitriy.zaporozhets@gmail.com", + "merge_request_diff_id": merge_request_diff_id, + "relative_order": 1, + "sha": sha_attribute.type_cast_for_database('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') + } + ] + end + + subject { described_class.create_bulk(merge_request_diff_id, commits) } + + it 'inserts the commits into the database en masse' do + expect(Gitlab::Database).to receive(:bulk_insert) + .with(described_class.table_name, rows) + + subject + end + + context 'with dates larger than the DB limit' do + let(:commits) do + # This commit's date is "Sun Aug 17 07:12:55 292278994 +0000" + [project.commit('ba3343bc4fa403a8dfbfcab7fc1a8c29ee34bd69')] + end + let(:timestamp) { Time.at((1 << 31) - 1) } + let(:rows) do + [{ + "message": "Weird commit date\n", + "authored_date": timestamp, + "author_name": "Alejandro RodrÃguez", + "author_email": "alejorro70@gmail.com", + "committed_date": timestamp, + "committer_name": "Alejandro RodrÃguez", + "committer_email": "alejorro70@gmail.com", + "merge_request_diff_id": merge_request_diff_id, + "relative_order": 0, + "sha": sha_attribute.type_cast_for_database('ba3343bc4fa403a8dfbfcab7fc1a8c29ee34bd69') + }] + end + + it 'uses a sanitized date' do + expect(Gitlab::Database).to receive(:bulk_insert) + .with(described_class.table_name, rows) + + subject + end + end + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 73e038b61ed..476a2697605 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -86,7 +86,7 @@ describe MergeRequest do context 'when the target branch does not exist' do before do - project.repository.raw_repository.delete_branch(subject.target_branch) + project.repository.rm_branch(subject.author, subject.target_branch) end it 'returns nil' do @@ -1388,7 +1388,7 @@ describe MergeRequest do context 'when the target branch does not exist' do before do - subject.project.repository.raw_repository.delete_branch(subject.target_branch) + subject.project.repository.rm_branch(subject.author, subject.target_branch) end it 'returns nil' do @@ -1460,6 +1460,12 @@ describe MergeRequest do end describe '#merge_ongoing?' do + it 'returns true when the merge request is locked' do + merge_request = build_stubbed(:merge_request, state: :locked) + + expect(merge_request.merge_ongoing?).to be(true) + end + it 'returns true when merge_id, MR is not merged and it has no running job' do merge_request = build_stubbed(:merge_request, state: :open, merge_jid: 'foo') allow(Gitlab::SidekiqStatus).to receive(:running?).with('foo') { true } diff --git a/spec/models/project_services/kubernetes_service_spec.rb b/spec/models/project_services/kubernetes_service_spec.rb index 2298dcab55f..7617e1f89b1 100644 --- a/spec/models/project_services/kubernetes_service_spec.rb +++ b/spec/models/project_services/kubernetes_service_spec.rb @@ -99,45 +99,34 @@ describe KubernetesService, :use_clean_rails_memory_store_caching do describe '#actual_namespace' do subject { service.actual_namespace } - it "returns the default namespace" do - is_expected.to eq(service.send(:default_namespace)) - end - - context 'when namespace is specified' do - before do - service.namespace = 'my-namespace' + shared_examples 'a correctly formatted namespace' do + it 'returns a valid Kubernetes namespace name' do + expect(subject).to match(Gitlab::Regex.kubernetes_namespace_regex) + expect(subject).to eq(expected_namespace) end + end - it "returns the user-namespace" do - is_expected.to eq('my-namespace') - end + it_behaves_like 'a correctly formatted namespace' do + let(:expected_namespace) { service.send(:default_namespace) } end - context 'when service is not assigned to project' do + context 'when the project path contains forbidden characters' do before do - service.project = nil + project.path = '-a_Strange.Path--forSure' end - it "does not return namespace" do - is_expected.to be_nil + it_behaves_like 'a correctly formatted namespace' do + let(:expected_namespace) { "a-strange-path--forsure-#{project.id}" } end end - end - - describe '#actual_namespace' do - subject { service.actual_namespace } - - it "returns the default namespace" do - is_expected.to eq(service.send(:default_namespace)) - end context 'when namespace is specified' do before do service.namespace = 'my-namespace' end - it "returns the user-namespace" do - is_expected.to eq('my-namespace') + it_behaves_like 'a correctly formatted namespace' do + let(:expected_namespace) { 'my-namespace' } end end @@ -146,7 +135,7 @@ describe KubernetesService, :use_clean_rails_memory_store_caching do service.project = nil end - it "does not return namespace" do + it 'does not return namespace' do is_expected.to be_nil end end @@ -156,7 +145,7 @@ describe KubernetesService, :use_clean_rails_memory_store_caching do let(:discovery_url) { 'https://kubernetes.example.com/api/v1' } before do - stub_kubeclient_discover + stub_kubeclient_discover(service.api_url) end context 'with path prefix in api_url' do @@ -164,7 +153,7 @@ describe KubernetesService, :use_clean_rails_memory_store_caching do it 'tests with the prefix' do service.api_url = 'https://kubernetes.example.com/prefix' - stub_kubeclient_discover + stub_kubeclient_discover(service.api_url) expect(service.test[:success]).to be_truthy expect(WebMock).to have_requested(:get, discovery_url).once diff --git a/spec/models/project_services/packagist_service_spec.rb b/spec/models/project_services/packagist_service_spec.rb new file mode 100644 index 00000000000..6acee311700 --- /dev/null +++ b/spec/models/project_services/packagist_service_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe PackagistService do + describe "Associations" do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + let(:project) { create(:project) } + + let(:packagist_server) { 'https://packagist.example.com' } + let(:packagist_username) { 'theUser' } + let(:packagist_token) { 'verySecret' } + let(:packagist_hook_url) do + "#{packagist_server}/api/update-package?username=#{packagist_username}&apiToken=#{packagist_token}" + end + + let(:packagist_params) do + { + active: true, + project: project, + properties: { + username: packagist_username, + token: packagist_token, + server: packagist_server + } + } + end + + describe '#execute' do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + let(:push_sample_data) { Gitlab::DataBuilder::Push.build_sample(project, user) } + let(:packagist_service) { described_class.create(packagist_params) } + + before do + stub_request(:post, packagist_hook_url) + end + + it 'calls Packagist API' do + packagist_service.execute(push_sample_data) + + expect(a_request(:post, packagist_hook_url)).to have_been_made.once + end + end +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 74eba7e33f6..e8588975118 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -24,6 +24,7 @@ describe Project do it { is_expected.to have_one(:slack_service) } it { is_expected.to have_one(:microsoft_teams_service) } it { is_expected.to have_one(:mattermost_service) } + it { is_expected.to have_one(:packagist_service) } it { is_expected.to have_one(:pushover_service) } it { is_expected.to have_one(:asana_service) } it { is_expected.to have_many(:boards) } @@ -1922,6 +1923,20 @@ describe Project do expect(forked_project.in_fork_network_of?(other_project)).to be_falsy end end + + describe '#fork_source' do + let!(:second_fork) { fork_project(forked_project) } + + it 'returns the direct source if it exists' do + expect(second_fork.fork_source).to eq(forked_project) + end + + it 'returns the root of the fork network when the directs source was deleted' do + forked_project.destroy + + expect(second_fork.fork_source).to eq(project) + end + end end describe '#pushes_since_gc' do @@ -2452,6 +2467,7 @@ describe Project do context 'legacy storage' do let(:project) { create(:project, :repository) } let(:gitlab_shell) { Gitlab::Shell.new } + let(:project_storage) { project.send(:storage) } before do allow(project).to receive(:gitlab_shell).and_return(gitlab_shell) @@ -2493,7 +2509,7 @@ describe Project do describe '#hashed_storage?' do it 'returns false' do - expect(project.hashed_storage?).to be_falsey + expect(project.hashed_storage?(:repository)).to be_falsey end end @@ -2546,6 +2562,30 @@ describe Project do it { expect { subject }.to raise_error(StandardError) } end + + context 'gitlab pages' do + before do + expect(project_storage).to receive(:rename_repo) { true } + end + + it 'moves pages folder to new location' do + expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project) + + project.rename_repo + end + end + + context 'attachments' do + before do + expect(project_storage).to receive(:rename_repo) { true } + end + + it 'moves uploads folder to new location' do + expect_any_instance_of(Gitlab::UploadsTransfer).to receive(:rename_project) + + project.rename_repo + end + end end describe '#pages_path' do @@ -2605,8 +2645,14 @@ describe Project do end describe '#hashed_storage?' do - it 'returns true' do - expect(project.hashed_storage?).to be_truthy + it 'returns true if rolled out' do + expect(project.hashed_storage?(:attachments)).to be_truthy + end + + it 'returns false when not rolled out yet' do + project.storage_version = 1 + + expect(project.hashed_storage?(:attachments)).to be_falsey end end @@ -2649,10 +2695,6 @@ describe Project do .to receive(:execute_hooks_for) .with(project, :rename) - expect_any_instance_of(Gitlab::UploadsTransfer) - .to receive(:rename_project) - .with('foo', project.path, project.namespace.full_path) - expect(project).to receive(:expire_caches_before_rename) expect(project).to receive(:expires_full_path_cache) @@ -2673,6 +2715,32 @@ describe Project do it { expect { subject }.to raise_error(StandardError) } end + + context 'gitlab pages' do + it 'moves pages folder to new location' do + expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project) + + project.rename_repo + end + end + + context 'attachments' do + it 'keeps uploads folder location unchanged' do + expect_any_instance_of(Gitlab::UploadsTransfer).not_to receive(:rename_project) + + project.rename_repo + end + + context 'when not rolled out' do + let(:project) { create(:project, :repository, storage_version: 1) } + + it 'moves pages folder to new location' do + expect_any_instance_of(Gitlab::UploadsTransfer).to receive(:rename_project) + + project.rename_repo + end + end + end end describe '#pages_path' do diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb index f10d9383ae2..3d46434fc27 100644 --- a/spec/models/project_wiki_spec.rb +++ b/spec/models/project_wiki_spec.rb @@ -117,65 +117,74 @@ describe ProjectWiki do end describe "#find_page" do - before do - create_page("index page", "This is an awesome Gollum Wiki") - end + shared_examples 'finding a wiki page' do + before do + create_page("index page", "This is an awesome Gollum Wiki") + end - after do - destroy_page(subject.pages.first.page) - end + after do + destroy_page(subject.pages.first.page) + end - it "returns the latest version of the page if it exists" do - page = subject.find_page("index page") - expect(page.title).to eq("index page") - end + it "returns the latest version of the page if it exists" do + page = subject.find_page("index page") + expect(page.title).to eq("index page") + end - it "returns nil if the page does not exist" do - expect(subject.find_page("non-existant")).to eq(nil) + it "returns nil if the page does not exist" do + expect(subject.find_page("non-existant")).to eq(nil) + end + + it "can find a page by slug" do + page = subject.find_page("index-page") + expect(page.title).to eq("index page") + end + + it "returns a WikiPage instance" do + page = subject.find_page("index page") + expect(page).to be_a WikiPage + end end - it "can find a page by slug" do - page = subject.find_page("index-page") - expect(page.title).to eq("index page") + context 'when Gitaly wiki_find_page is enabled' do + it_behaves_like 'finding a wiki page' end - it "returns a WikiPage instance" do - page = subject.find_page("index page") - expect(page).to be_a WikiPage + context 'when Gitaly wiki_find_page is disabled', :skip_gitaly_mock do + it_behaves_like 'finding a wiki page' end end describe '#find_file' do - before do - file = Gollum::File.new(subject.wiki) - allow_any_instance_of(Gollum::Wiki) - .to receive(:file).with('image.jpg', 'master') - .and_return(file) - allow_any_instance_of(Gollum::File) - .to receive(:mime_type) - .and_return('image/jpeg') - allow_any_instance_of(Gollum::Wiki) - .to receive(:file).with('non-existant', 'master') - .and_return(nil) - end + shared_examples 'finding a wiki file' do + before do + file = File.open(Rails.root.join('spec', 'fixtures', 'dk.png')) + subject.wiki # Make sure the wiki repo exists - after do - allow_any_instance_of(Gollum::Wiki).to receive(:file).and_call_original - allow_any_instance_of(Gollum::File).to receive(:mime_type).and_call_original - end + BareRepoOperations.new(subject.repository.path_to_repo).commit_file(file, 'image.png') + end + + it 'returns the latest version of the file if it exists' do + file = subject.find_file('image.png') + expect(file.mime_type).to eq('image/png') + end + + it 'returns nil if the page does not exist' do + expect(subject.find_file('non-existant')).to eq(nil) + end - it 'returns the latest version of the file if it exists' do - file = subject.find_file('image.jpg') - expect(file.mime_type).to eq('image/jpeg') + it 'returns a Gitlab::Git::WikiFile instance' do + file = subject.find_file('image.png') + expect(file).to be_a Gitlab::Git::WikiFile + end end - it 'returns nil if the page does not exist' do - expect(subject.find_file('non-existant')).to eq(nil) + context 'when Gitaly wiki_find_file is enabled' do + it_behaves_like 'finding a wiki file' end - it 'returns a Gitlab::Git::WikiFile instance' do - file = subject.find_file('image.jpg') - expect(file).to be_a Gitlab::Git::WikiFile + context 'when Gitaly wiki_find_file is disabled', :skip_gitaly_mock do + it_behaves_like 'finding a wiki file' end end @@ -265,23 +274,33 @@ describe ProjectWiki do end describe "#delete_page" do - before do - create_page("index", "some content") - @page = subject.wiki.page(title: "index") - end + shared_examples 'deleting a wiki page' do + before do + create_page("index", "some content") + @page = subject.wiki.page(title: "index") + end - it "deletes the page" do - subject.delete_page(@page) - expect(subject.pages.count).to eq(0) - end + it "deletes the page" do + subject.delete_page(@page) + expect(subject.pages.count).to eq(0) + end - it 'updates project activity' do - subject.delete_page(@page) + it 'updates project activity' do + subject.delete_page(@page) - project.reload + project.reload - expect(project.last_activity_at).to be_within(1.minute).of(Time.now) - expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now) + expect(project.last_activity_at).to be_within(1.minute).of(Time.now) + expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now) + end + end + + context 'when Gitaly wiki_delete_page is enabled' do + it_behaves_like 'deleting a wiki page' + end + + context 'when Gitaly wiki_delete_page is disabled', :skip_gitaly_mock do + it_behaves_like 'deleting a wiki page' end end @@ -343,6 +362,6 @@ describe ProjectWiki do end def destroy_page(page) - subject.delete_page(page, commit_details) + subject.delete_page(page, "test commit") end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 874368ada67..8a6aa767ce6 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -299,6 +299,24 @@ describe Repository do it { is_expected.to be_falsey } end + + context 'when pre-loaded merged branches are provided' do + using RSpec::Parameterized::TableSyntax + + where(:branch, :pre_loaded, :expected) do + 'not-merged-branch' | ['branch-merged'] | false + 'branch-merged' | ['not-merged-branch'] | false + 'branch-merged' | ['branch-merged'] | true + 'not-merged-branch' | ['not-merged-branch'] | false + 'master' | ['master'] | false + end + + with_them do + subject { repository.merged_to_root_ref?(branch, pre_loaded) } + + it { is_expected.to eq(expected) } + end + end end describe '#can_be_merged?' do @@ -2260,4 +2278,44 @@ describe Repository do end end end + + describe 'commit cache' do + set(:project) { create(:project, :repository) } + + it 'caches based on SHA' do + # Gets the commit oid, and warms the cache + oid = project.commit.id + + expect(Gitlab::Git::Commit).not_to receive(:find).once + + project.commit_by(oid: oid) + end + + it 'caches nil values' do + expect(Gitlab::Git::Commit).to receive(:find).once + + project.commit_by(oid: '1' * 40) + project.commit_by(oid: '1' * 40) + end + end + + describe '#raw_repository' do + subject { repository.raw_repository } + + it 'returns a Gitlab::Git::Repository representation of the repository' do + expect(subject).to be_a(Gitlab::Git::Repository) + expect(subject.relative_path).to eq(project.disk_path + '.git') + expect(subject.gl_repository).to eq("project-#{project.id}") + end + + context 'with a wiki repository' do + let(:repository) { project.wiki.repository } + + it 'creates a Gitlab::Git::Repository with the proper attributes' do + expect(subject).to be_a(Gitlab::Git::Repository) + expect(subject.relative_path).to eq(project.disk_path + '.wiki.git') + expect(subject.gl_repository).to eq("wiki-#{project.id}") + end + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 1c3c9068f12..e0896d64c8f 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -346,7 +346,6 @@ describe User do describe "Respond to" do it { is_expected.to respond_to(:admin?) } it { is_expected.to respond_to(:name) } - it { is_expected.to respond_to(:private_token) } it { is_expected.to respond_to(:external?) } end @@ -526,14 +525,6 @@ describe User do end end - describe 'authentication token' do - it "has authentication token" do - user = create(:user) - - expect(user.authentication_token).not_to be_blank - end - end - describe 'ensure incoming email token' do it 'has incoming email token' do user = create(:user) @@ -2226,6 +2217,42 @@ describe User do end end + describe '#username_changed_hook' do + context 'for a new user' do + let(:user) { build(:user) } + + it 'does not trigger system hook' do + expect(user).not_to receive(:system_hook_service) + + user.save! + end + end + + context 'for an existing user' do + let(:user) { create(:user, username: 'old-username') } + + context 'when the username is changed' do + let(:new_username) { 'very-new-name' } + + it 'triggers the rename system hook' do + system_hook_service = SystemHooksService.new + expect(system_hook_service).to receive(:execute_hooks_for).with(user, :rename) + expect(user).to receive(:system_hook_service).and_return(system_hook_service) + + user.update_attributes!(username: new_username) + end + end + + context 'when the username is not changed' do + it 'does not trigger system hook' do + expect(user).not_to receive(:system_hook_service) + + user.update_attributes!(email: 'asdf@asdf.com') + end + end + end + end + describe '#sync_attribute?' do let(:user) { described_class.new } diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb index 1f14d06997e..a7227b38850 100644 --- a/spec/models/wiki_page_spec.rb +++ b/spec/models/wiki_page_spec.rb @@ -402,7 +402,7 @@ describe WikiPage do def destroy_page(title) page = wiki.wiki.page(title: title) - wiki.delete_page(page, commit_details) + wiki.delete_page(page, "test commit") end def get_slugs(page_or_dir) diff --git a/spec/policies/gcp/cluster_policy_spec.rb b/spec/policies/clusters/cluster_policy_spec.rb index e213aa3d557..4207f42b07f 100644 --- a/spec/policies/gcp/cluster_policy_spec.rb +++ b/spec/policies/clusters/cluster_policy_spec.rb @@ -1,8 +1,8 @@ require 'spec_helper' -describe Gcp::ClusterPolicy, :models do - set(:project) { create(:project) } - set(:cluster) { create(:gcp_cluster, project: project) } +describe Clusters::ClusterPolicy, :models do + let(:cluster) { create(:cluster, :project) } + let(:project) { cluster.project } let(:user) { create(:user) } let(:policy) { described_class.new(user, cluster) } diff --git a/spec/presenters/gcp/cluster_presenter_spec.rb b/spec/presenters/clusters/cluster_presenter_spec.rb index 8d86dc31582..48d4f3671c5 100644 --- a/spec/presenters/gcp/cluster_presenter_spec.rb +++ b/spec/presenters/clusters/cluster_presenter_spec.rb @@ -1,8 +1,7 @@ require 'spec_helper' -describe Gcp::ClusterPresenter do - let(:project) { create(:project) } - let(:cluster) { create(:gcp_cluster, project: project) } +describe Clusters::ClusterPresenter do + let(:cluster) { create(:cluster, :provided_by_gcp) } subject(:presenter) do described_class.new(cluster) @@ -22,14 +21,14 @@ describe Gcp::ClusterPresenter do end it 'forwards missing methods to cluster' do - expect(presenter.gcp_cluster_zone).to eq(cluster.gcp_cluster_zone) + expect(presenter.status).to eq(cluster.status) end end describe '#gke_cluster_url' do subject { described_class.new(cluster).gke_cluster_url } - it { is_expected.to include(cluster.gcp_cluster_zone) } - it { is_expected.to include(cluster.gcp_cluster_name) } + it { is_expected.to include(cluster.provider.zone) } + it { is_expected.to include(cluster.name) } end end diff --git a/spec/requests/api/doorkeeper_access_spec.rb b/spec/requests/api/doorkeeper_access_spec.rb index de7ce848a31..308134eba72 100644 --- a/spec/requests/api/doorkeeper_access_spec.rb +++ b/spec/requests/api/doorkeeper_access_spec.rb @@ -25,7 +25,7 @@ describe 'doorkeeper access' do end end - describe "authorization by private token" do + describe "authorization by OAuth token" do it "returns authentication success" do get api("/user", user) expect(response).to have_gitlab_http_status(200) @@ -39,20 +39,20 @@ describe 'doorkeeper access' do end describe "when user is blocked" do - it "returns authentication error" do + it "returns authorization error" do user.block get api("/user"), access_token: token.token - expect(response).to have_gitlab_http_status(401) + expect(response).to have_gitlab_http_status(403) end end describe "when user is ldap_blocked" do - it "returns authentication error" do + it "returns authorization error" do user.ldap_block get api("/user"), access_token: token.token - expect(response).to have_gitlab_http_status(401) + expect(response).to have_gitlab_http_status(403) end end end diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb index 9f3b5a809d7..6c0996c543d 100644 --- a/spec/requests/api/helpers_spec.rb +++ b/spec/requests/api/helpers_spec.rb @@ -28,39 +28,11 @@ describe API::Helpers do allow_any_instance_of(self.class).to receive(:options).and_return({}) end - def set_env(user_or_token, identifier) - clear_env - clear_param - env[API::APIGuard::PRIVATE_TOKEN_HEADER] = user_or_token.respond_to?(:private_token) ? user_or_token.private_token : user_or_token - env[API::Helpers::SUDO_HEADER] = identifier.to_s - end - - def set_param(user_or_token, identifier) - clear_env - clear_param - params[API::APIGuard::PRIVATE_TOKEN_PARAM] = user_or_token.respond_to?(:private_token) ? user_or_token.private_token : user_or_token - params[API::Helpers::SUDO_PARAM] = identifier.to_s - end - - def clear_env - env.delete(API::APIGuard::PRIVATE_TOKEN_HEADER) - env.delete(API::Helpers::SUDO_HEADER) - end - - def clear_param - params.delete(API::APIGuard::PRIVATE_TOKEN_PARAM) - params.delete(API::Helpers::SUDO_PARAM) - end - def warden_authenticate_returns(value) warden = double("warden", authenticate: value) env['warden'] = warden end - def doorkeeper_guard_returns(value) - allow_any_instance_of(self.class).to receive(:doorkeeper_guard) { value } - end - def error!(message, status, header) raise Exception.new("#{status} - #{message}") end @@ -69,10 +41,6 @@ describe API::Helpers do subject { current_user } describe "Warden authentication", :allow_forgery_protection do - before do - doorkeeper_guard_returns false - end - context "with invalid credentials" do context "GET request" do before do @@ -160,75 +128,32 @@ describe API::Helpers do end end - describe "when authenticating using a user's private token" do - it "returns a 401 response for an invalid token" do - env[API::APIGuard::PRIVATE_TOKEN_HEADER] = 'invalid token' - allow_any_instance_of(self.class).to receive(:doorkeeper_guard) { false } - - expect { current_user }.to raise_error /401/ - end - - it "returns a 401 response for a user without access" do - env[API::APIGuard::PRIVATE_TOKEN_HEADER] = user.private_token - allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(false) - - expect { current_user }.to raise_error /401/ - end - - it 'returns a 401 response for a user who is blocked' do - user.block! - env[API::APIGuard::PRIVATE_TOKEN_HEADER] = user.private_token - - expect { current_user }.to raise_error /401/ - end - - it "leaves user as is when sudo not specified" do - env[API::APIGuard::PRIVATE_TOKEN_HEADER] = user.private_token - - expect(current_user).to eq(user) - - clear_env - - params[API::APIGuard::PRIVATE_TOKEN_PARAM] = user.private_token - - expect(current_user).to eq(user) - end - end - describe "when authenticating using a user's personal access tokens" do let(:personal_access_token) { create(:personal_access_token, user: user) } - before do - allow_any_instance_of(self.class).to receive(:doorkeeper_guard) { false } - end - it "returns a 401 response for an invalid token" do env[API::APIGuard::PRIVATE_TOKEN_HEADER] = 'invalid token' expect { current_user }.to raise_error /401/ end - it "returns a 401 response for a user without access" do + it "returns a 403 response for a user without access" do env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(false) - expect { current_user }.to raise_error /401/ + expect { current_user }.to raise_error /403/ end - it 'returns a 401 response for a user who is blocked' do + it 'returns a 403 response for a user who is blocked' do user.block! env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token - expect { current_user }.to raise_error /401/ + expect { current_user }.to raise_error /403/ end - it "leaves user as is when sudo not specified" do + it "sets current_user" do env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token expect(current_user).to eq(user) - clear_env - params[API::APIGuard::PRIVATE_TOKEN_PARAM] = personal_access_token.token - - expect(current_user).to eq(user) end it "does not allow tokens without the appropriate scope" do @@ -252,210 +177,6 @@ describe API::Helpers do expect { current_user }.to raise_error API::APIGuard::ExpiredError end end - - context 'sudo usage' do - context 'with admin' do - context 'with header' do - context 'with id' do - it 'changes current_user to sudo' do - set_env(admin, user.id) - - expect(current_user).to eq(user) - end - - it 'memoize the current_user: sudo permissions are not run against the sudoed user' do - set_env(admin, user.id) - - expect(current_user).to eq(user) - expect(current_user).to eq(user) - end - - it 'handles sudo to oneself' do - set_env(admin, admin.id) - - expect(current_user).to eq(admin) - end - - it 'throws an error when user cannot be found' do - id = user.id + admin.id - expect(user.id).not_to eq(id) - expect(admin.id).not_to eq(id) - - set_env(admin, id) - - expect { current_user }.to raise_error(Exception) - end - end - - context 'with username' do - it 'changes current_user to sudo' do - set_env(admin, user.username) - - expect(current_user).to eq(user) - end - - it 'handles sudo to oneself' do - set_env(admin, admin.username) - - expect(current_user).to eq(admin) - end - - it "throws an error when the user cannot be found for a given username" do - username = "#{user.username}#{admin.username}" - expect(user.username).not_to eq(username) - expect(admin.username).not_to eq(username) - - set_env(admin, username) - - expect { current_user }.to raise_error(Exception) - end - end - end - - context 'with param' do - context 'with id' do - it 'changes current_user to sudo' do - set_param(admin, user.id) - - expect(current_user).to eq(user) - end - - it 'handles sudo to oneself' do - set_param(admin, admin.id) - - expect(current_user).to eq(admin) - end - - it 'handles sudo to oneself using string' do - set_env(admin, user.id.to_s) - - expect(current_user).to eq(user) - end - - it 'throws an error when user cannot be found' do - id = user.id + admin.id - expect(user.id).not_to eq(id) - expect(admin.id).not_to eq(id) - - set_param(admin, id) - - expect { current_user }.to raise_error(Exception) - end - end - - context 'with username' do - it 'changes current_user to sudo' do - set_param(admin, user.username) - - expect(current_user).to eq(user) - end - - it 'handles sudo to oneself' do - set_param(admin, admin.username) - - expect(current_user).to eq(admin) - end - - it "throws an error when the user cannot be found for a given username" do - username = "#{user.username}#{admin.username}" - expect(user.username).not_to eq(username) - expect(admin.username).not_to eq(username) - - set_param(admin, username) - - expect { current_user }.to raise_error(Exception) - end - end - end - - context 'when user is blocked' do - before do - user.block! - end - - it 'changes current_user to sudo' do - set_env(admin, user.id) - - expect(current_user).to eq(user) - end - end - end - - context 'with regular user' do - context 'with env' do - it 'changes current_user to sudo when admin and user id' do - set_env(user, admin.id) - - expect { current_user }.to raise_error(Exception) - end - - it 'changes current_user to sudo when admin and user username' do - set_env(user, admin.username) - - expect { current_user }.to raise_error(Exception) - end - end - - context 'with params' do - it 'changes current_user to sudo when admin and user id' do - set_param(user, admin.id) - - expect { current_user }.to raise_error(Exception) - end - - it 'changes current_user to sudo when admin and user username' do - set_param(user, admin.username) - - expect { current_user }.to raise_error(Exception) - end - end - end - end - end - - describe '.sudo?' do - context 'when no sudo env or param is passed' do - before do - doorkeeper_guard_returns(nil) - end - - it 'returns false' do - expect(sudo?).to be_falsy - end - end - - context 'when sudo env or param is passed', 'user is not an admin' do - before do - set_env(user, '123') - end - - it 'returns an 403 Forbidden' do - expect { sudo? }.to raise_error '403 - {"message"=>"403 Forbidden - Must be admin to use sudo"}' - end - end - - context 'when sudo env or param is passed', 'user is admin' do - context 'personal access token is used' do - before do - personal_access_token = create(:personal_access_token, user: admin) - set_env(personal_access_token.token, user.id) - end - - it 'returns an 403 Forbidden' do - expect { sudo? }.to raise_error '403 - {"message"=>"403 Forbidden - Private token must be specified in order to use sudo"}' - end - end - - context 'private access token is used' do - before do - set_env(admin.private_token, user.id) - end - - it 'returns true' do - expect(sudo?).to be_truthy - end - end - end end describe '.handle_api_exception' do @@ -582,4 +303,147 @@ describe API::Helpers do end end end + + context 'sudo' do + shared_examples 'successful sudo' do + it 'sets current_user' do + expect(current_user).to eq(user) + end + + it 'sets sudo?' do + expect(sudo?).to be_truthy + end + end + + shared_examples 'sudo' do + context 'when admin' do + before do + token.user = admin + token.save! + end + + context 'when token has sudo scope' do + before do + token.scopes = %w[sudo] + token.save! + end + + context 'when user exists' do + context 'when using header' do + context 'when providing username' do + before do + env[API::Helpers::SUDO_HEADER] = user.username + end + + it_behaves_like 'successful sudo' + end + + context 'when providing user ID' do + before do + env[API::Helpers::SUDO_HEADER] = user.id.to_s + end + + it_behaves_like 'successful sudo' + end + end + + context 'when using param' do + context 'when providing username' do + before do + params[API::Helpers::SUDO_PARAM] = user.username + end + + it_behaves_like 'successful sudo' + end + + context 'when providing user ID' do + before do + params[API::Helpers::SUDO_PARAM] = user.id.to_s + end + + it_behaves_like 'successful sudo' + end + end + end + + context 'when user does not exist' do + before do + params[API::Helpers::SUDO_PARAM] = 'nonexistent' + end + + it 'raises an error' do + expect { current_user }.to raise_error /User with ID or username 'nonexistent' Not Found/ + end + end + end + + context 'when token does not have sudo scope' do + before do + token.scopes = %w[api] + token.save! + + params[API::Helpers::SUDO_PARAM] = user.id.to_s + end + + it 'raises an error' do + expect { current_user }.to raise_error API::APIGuard::InsufficientScopeError + end + end + end + + context 'when not admin' do + before do + token.user = user + token.save! + + params[API::Helpers::SUDO_PARAM] = user.id.to_s + end + + it 'raises an error' do + expect { current_user }.to raise_error /Must be admin to use sudo/ + end + end + end + + context 'using an OAuth token' do + let(:token) { create(:oauth_access_token) } + + before do + env['HTTP_AUTHORIZATION'] = "Bearer #{token.token}" + end + + it_behaves_like 'sudo' + end + + context 'using a personal access token' do + let(:token) { create(:personal_access_token) } + + context 'passed as param' do + before do + params[API::APIGuard::PRIVATE_TOKEN_PARAM] = token.token + end + + it_behaves_like 'sudo' + end + + context 'passed as header' do + before do + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = token.token + end + + it_behaves_like 'sudo' + end + end + + context 'using warden authentication' do + before do + warden_authenticate_returns admin + env[API::Helpers::SUDO_HEADER] = user.username + end + + it 'raises an error' do + expect { current_user }.to raise_error /Must be authenticated using an OAuth or Personal Access Token to use sudo/ + end + end + end end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 28b1404a4f7..024cfe8b372 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -1061,6 +1061,30 @@ describe API::MergeRequests do end end + 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) + end + + it 'removes the merge_when_pipeline_succeeds status' do + post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/cancel_merge_when_pipeline_succeeds", user) + + expect(response).to have_gitlab_http_status(201) + end + + it 'returns 404 if the merge request is not found' do + post api("/projects/#{project.id}/merge_requests/123/merge_when_pipeline_succeeds", user) + + expect(response).to have_gitlab_http_status(404) + end + + it 'returns 404 if the merge request id is used instead of iid' do + post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge_when_pipeline_succeeds", user) + + expect(response).to have_gitlab_http_status(404) + end + end + describe 'Time tracking' do let(:issuable) { merge_request } diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb index 5fd76fce7df..47f4ccd4887 100644 --- a/spec/requests/api/runner_spec.rb +++ b/spec/requests/api/runner_spec.rb @@ -385,7 +385,7 @@ describe API::Runner do end context 'when job is made for tag' do - let!(:job) { create(:ci_build_tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) } + let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) } it 'sets branch as ref_type' do request_job @@ -436,8 +436,8 @@ describe API::Runner do end context 'when project and pipeline have multiple jobs' do - let!(:job) { create(:ci_build_tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) } - let!(:job2) { create(:ci_build_tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) } + let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) } + let!(:job2) { create(:ci_build, :tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) } let!(:test_job) { create(:ci_build, pipeline: pipeline, name: 'deploy', stage: 'deploy', stage_idx: 1) } before do @@ -458,7 +458,7 @@ describe API::Runner do end context 'when pipeline have jobs with artifacts' do - let!(:job) { create(:ci_build_tag, :artifacts, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) } + let!(:job) { create(:ci_build, :tag, :artifacts, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) } let!(:test_job) { create(:ci_build, pipeline: pipeline, name: 'deploy', stage: 'deploy', stage_idx: 1) } before do @@ -478,8 +478,8 @@ describe API::Runner do end context 'when explicit dependencies are defined' do - let!(:job) { create(:ci_build_tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) } - let!(:job2) { create(:ci_build_tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) } + let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) } + let!(:job2) { create(:ci_build, :tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) } let!(:test_job) do create(:ci_build, pipeline: pipeline, token: 'test-job-token', name: 'deploy', stage: 'deploy', stage_idx: 1, @@ -502,8 +502,8 @@ describe API::Runner do end context 'when dependencies is an empty array' do - let!(:job) { create(:ci_build_tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) } - let!(:job2) { create(:ci_build_tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) } + let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) } + let!(:job2) { create(:ci_build, :tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) } let!(:empty_dependencies_job) do create(:ci_build, pipeline: pipeline, token: 'test-job-token', name: 'empty_dependencies_job', stage: 'deploy', stage_idx: 1, diff --git a/spec/requests/api/session_spec.rb b/spec/requests/api/session_spec.rb deleted file mode 100644 index 83d09878813..00000000000 --- a/spec/requests/api/session_spec.rb +++ /dev/null @@ -1,107 +0,0 @@ -require 'spec_helper' - -describe API::Session do - let(:user) { create(:user) } - - describe "POST /session" do - context "when valid password" do - it "returns private token" do - post api("/session"), email: user.email, password: '12345678' - expect(response).to have_gitlab_http_status(201) - - expect(json_response['email']).to eq(user.email) - expect(json_response['private_token']).to eq(user.private_token) - expect(json_response['is_admin']).to eq(user.admin?) - expect(json_response['can_create_project']).to eq(user.can_create_project?) - expect(json_response['can_create_group']).to eq(user.can_create_group?) - end - - context 'with 2FA enabled' do - it 'rejects sign in attempts' do - user = create(:user, :two_factor) - - post api('/session'), email: user.email, password: user.password - - expect(response).to have_gitlab_http_status(401) - expect(response.body).to include('You have 2FA enabled.') - end - end - end - - context 'when email has case-typo and password is valid' do - it 'returns private token' do - post api('/session'), email: user.email.upcase, password: '12345678' - expect(response.status).to eq 201 - - expect(json_response['email']).to eq user.email - expect(json_response['private_token']).to eq user.private_token - expect(json_response['is_admin']).to eq user.admin? - expect(json_response['can_create_project']).to eq user.can_create_project? - expect(json_response['can_create_group']).to eq user.can_create_group? - end - end - - context 'when login has case-typo and password is valid' do - it 'returns private token' do - post api('/session'), login: user.username.upcase, password: '12345678' - expect(response.status).to eq 201 - - expect(json_response['email']).to eq user.email - expect(json_response['private_token']).to eq user.private_token - expect(json_response['is_admin']).to eq user.admin? - expect(json_response['can_create_project']).to eq user.can_create_project? - expect(json_response['can_create_group']).to eq user.can_create_group? - end - end - - context "when invalid password" do - it "returns authentication error" do - post api("/session"), email: user.email, password: '123' - expect(response).to have_gitlab_http_status(401) - - expect(json_response['email']).to be_nil - expect(json_response['private_token']).to be_nil - end - end - - context "when empty password" do - it "returns authentication error with email" do - post api("/session"), email: user.email - - expect(response).to have_gitlab_http_status(400) - end - - it "returns authentication error with username" do - post api("/session"), email: user.username - - expect(response).to have_gitlab_http_status(400) - end - end - - context "when empty name" do - it "returns authentication error" do - post api("/session"), password: user.password - - expect(response).to have_gitlab_http_status(400) - end - end - - context "when user is blocked" do - it "returns authentication error" do - user.block - post api("/session"), email: user.username, password: user.password - - expect(response).to have_gitlab_http_status(401) - end - end - - context "when user is ldap_blocked" do - it "returns authentication error" do - user.ldap_block - post api("/session"), email: user.username, password: user.password - - expect(response).to have_gitlab_http_status(401) - end - end - end -end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 4737f034f21..634c8dae0ba 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -127,8 +127,8 @@ describe API::Users do context "when admin" do context 'when sudo is defined' do it 'does not return 500' do - admin_personal_access_token = create(:personal_access_token, user: admin).token - get api("/users?private_token=#{admin_personal_access_token}&sudo=#{user.id}", admin) + admin_personal_access_token = create(:personal_access_token, user: admin, scopes: [:sudo]) + get api("/users?sudo=#{user.id}", admin, personal_access_token: admin_personal_access_token) expect(response).to have_gitlab_http_status(:success) end @@ -1097,14 +1097,6 @@ describe API::Users do end end - context 'with private token' do - it 'returns 403 without private token when sudo defined' do - get api("/user?private_token=#{user.private_token}&sudo=123") - - expect(response).to have_gitlab_http_status(403) - end - end - it 'returns current user without private token when sudo not defined' do get api("/user", user) @@ -1139,24 +1131,6 @@ describe API::Users do expect(json_response['id']).to eq(admin.id) end end - - context 'with private token' do - it 'returns sudoed user with private token when sudo defined' do - get api("/user?private_token=#{admin.private_token}&sudo=#{user.id}") - - expect(response).to have_gitlab_http_status(200) - expect(response).to match_response_schema('public_api/v4/user/login') - expect(json_response['id']).to eq(user.id) - end - - it 'returns initial current user without private token but with is_admin when sudo not defined' do - get api("/user?private_token=#{admin.private_token}") - - expect(response).to have_gitlab_http_status(200) - expect(response).to match_response_schema('public_api/v4/user/admin') - expect(json_response['id']).to eq(admin.id) - end - end end context 'with unauthenticated user' do diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index 39d44245c3f..fb1281a6b42 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -426,18 +426,23 @@ describe 'project routing' do end end - # project_milestones GET /:project_id/milestones(.:format) milestones#index - # POST /:project_id/milestones(.:format) milestones#create - # new_project_milestone GET /:project_id/milestones/new(.:format) milestones#new - # edit_project_milestone GET /:project_id/milestones/:id/edit(.:format) milestones#edit - # project_milestone GET /:project_id/milestones/:id(.:format) milestones#show - # PUT /:project_id/milestones/:id(.:format) milestones#update - # DELETE /:project_id/milestones/:id(.:format) milestones#destroy + # project_milestones GET /:project_id/milestones(.:format) milestones#index + # POST /:project_id/milestones(.:format) milestones#create + # new_project_milestone GET /:project_id/milestones/new(.:format) milestones#new + # edit_project_milestone GET /:project_id/milestones/:id/edit(.:format) milestones#edit + # project_milestone GET /:project_id/milestones/:id(.:format) milestones#show + # PUT /:project_id/milestones/:id(.:format) milestones#update + # DELETE /:project_id/milestones/:id(.:format) milestones#destroy + # promote_project_milestone POST /:project_id/milestones/:id/promote milestones#promote describe Projects::MilestonesController, 'routing' do it_behaves_like 'RESTful project resources' do let(:controller) { 'milestones' } let(:actions) { [:index, :create, :new, :edit, :show, :update] } 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") + end end # project_labels GET /:project_id/labels(.:format) labels#index diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb index 407d19c3b2a..609481603af 100644 --- a/spec/routing/routing_spec.rb +++ b/spec/routing/routing_spec.rb @@ -135,7 +135,6 @@ end # profile_history GET /profile/history(.:format) profile#history # profile_password PUT /profile/password(.:format) profile#password_update # profile_token GET /profile/token(.:format) profile#token -# profile_reset_private_token PUT /profile/reset_private_token(.:format) profile#reset_private_token # profile GET /profile(.:format) profile#show # profile_update PUT /profile/update(.:format) profile#update describe ProfilesController, "routing" do @@ -147,10 +146,6 @@ describe ProfilesController, "routing" do expect(get("/profile/audit_log")).to route_to('profiles#audit_log') end - it "to #reset_private_token" do - expect(put("/profile/reset_private_token")).to route_to('profiles#reset_private_token') - end - it "to #reset_rss_token" do expect(put("/profile/reset_rss_token")).to route_to('profiles#reset_rss_token') end diff --git a/spec/serializers/cluster_entity_spec.rb b/spec/serializers/cluster_entity_spec.rb index 2c7f49974f1..7b132a1b84d 100644 --- a/spec/serializers/cluster_entity_spec.rb +++ b/spec/serializers/cluster_entity_spec.rb @@ -1,22 +1,38 @@ require 'spec_helper' describe ClusterEntity do - set(:cluster) { create(:gcp_cluster, :errored) } - let(:request) { double('request') } + describe '#as_json' do + subject { described_class.new(cluster).as_json } - let(:entity) do - described_class.new(cluster) - end + context 'when provider type is gcp' do + let(:cluster) { create(:cluster, provider_type: :gcp, provider_gcp: provider) } - describe '#as_json' do - subject { entity.as_json } + context 'when status is creating' do + let(:provider) { create(:provider_gcp, :creating) } - it 'contains status' do - expect(subject[:status]).to eq(:errored) + it 'has corresponded data' do + expect(subject[:status]).to eq(:creating) + expect(subject[:status_reason]).to be_nil + end + end + + context 'when status is errored' do + let(:provider) { create(:provider_gcp, :errored) } + + it 'has corresponded data' do + expect(subject[:status]).to eq(:errored) + expect(subject[:status_reason]).to eq(provider.status_reason) + end + end end - it 'contains status reason' do - expect(subject[:status_reason]).to eq('general error') + context 'when provider type is user' do + let(:cluster) { create(:cluster, provider_type: :user) } + + it 'has nil' do + expect(subject[:status]).to be_nil + expect(subject[:status_reason]).to be_nil + end end end end diff --git a/spec/serializers/cluster_serializer_spec.rb b/spec/serializers/cluster_serializer_spec.rb index 1ac6784d28f..e5da92a451e 100644 --- a/spec/serializers/cluster_serializer_spec.rb +++ b/spec/serializers/cluster_serializer_spec.rb @@ -1,15 +1,20 @@ require 'spec_helper' describe ClusterSerializer do - let(:serializer) do - described_class.new - end - describe '#represent_status' do - subject { serializer.represent_status(resource) } + subject { described_class.new.represent_status(cluster) } + + context 'when provider type is gcp' do + let(:cluster) { create(:cluster, provider_type: :gcp, provider_gcp: provider) } + let(:provider) { create(:provider_gcp, :errored) } + + it 'serializes only status' do + expect(subject.keys).to contain_exactly(:status, :status_reason) + end + end - context 'when represents only status' do - let(:resource) { create(:gcp_cluster, :errored) } + context 'when provider type is user' do + let(:cluster) { create(:cluster, provider_type: :user) } it 'serializes only status' do expect(subject.keys).to contain_exactly(:status, :status_reason) diff --git a/spec/serializers/issue_entity_spec.rb b/spec/serializers/issue_entity_spec.rb new file mode 100644 index 00000000000..caa3e41402b --- /dev/null +++ b/spec/serializers/issue_entity_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe IssueEntity do + let(:project) { create(:project) } + let(:resource) { create(:issue, project: project) } + let(:user) { create(:user) } + + let(:request) { double('request', current_user: user) } + + subject { described_class.new(resource, request: request).as_json } + + it 'has Issuable attributes' do + expect(subject).to include(:id, :iid, :author_id, :description, :lock_version, :milestone_id, + :title, :updated_by_id, :created_at, :updated_at, :milestone, :labels) + end + + it 'has time estimation attributes' do + expect(subject).to include(:time_estimate, :total_time_spent, :human_time_estimate, :human_total_time_spent) + end +end diff --git a/spec/serializers/issue_serializer_spec.rb b/spec/serializers/issue_serializer_spec.rb new file mode 100644 index 00000000000..75578816e75 --- /dev/null +++ b/spec/serializers/issue_serializer_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe IssueSerializer do + let(:resource) { create(:issue) } + let(:user) { create(:user) } + let(:json_entity) do + described_class.new(current_user: user) + .represent(resource, serializer: serializer) + .with_indifferent_access + end + + context 'non-sidebar issue serialization' do + let(:serializer) { nil } + + it 'matches issue json schema' do + expect(json_entity).to match_schema('entities/issue') + end + end + + context 'sidebar issue serialization' do + let(:serializer) { 'sidebar' } + + it 'matches sidebar issue json schema' do + expect(json_entity).to match_schema('entities/issue_sidebar') + end + end +end diff --git a/spec/serializers/merge_request_basic_serializer_spec.rb b/spec/serializers/merge_request_basic_serializer_spec.rb index 4daf5a59d0c..1fad8e6bc5d 100644 --- a/spec/serializers/merge_request_basic_serializer_spec.rb +++ b/spec/serializers/merge_request_basic_serializer_spec.rb @@ -4,9 +4,13 @@ describe MergeRequestBasicSerializer do let(:resource) { create(:merge_request) } let(:user) { create(:user) } - subject { described_class.new.represent(resource) } + let(:json_entity) do + described_class.new(current_user: user) + .represent(resource, serializer: 'basic') + .with_indifferent_access + end - it 'has important MergeRequest attributes' do - expect(subject).to include(:merge_status) + it 'matches basic merge request json' do + expect(json_entity).to match_schema('entities/merge_request_basic') end end diff --git a/spec/serializers/merge_request_entity_spec.rb b/spec/serializers/merge_request_entity_spec.rb index 87832b3dca1..f9285049c0d 100644 --- a/spec/serializers/merge_request_entity_spec.rb +++ b/spec/serializers/merge_request_entity_spec.rb @@ -30,8 +30,17 @@ describe MergeRequestEntity do :assign_to_closing) end + it 'has Issuable attributes' do + expect(subject).to include(:id, :iid, :author_id, :description, :lock_version, :milestone_id, + :title, :updated_by_id, :created_at, :updated_at, :milestone, :labels) + end + + it 'has time estimation attributes' do + expect(subject).to include(:time_estimate, :total_time_spent, :human_time_estimate, :human_total_time_spent) + end + it 'has important MergeRequest attributes' do - expect(subject).to include(:diff_head_sha, :merge_commit_message, + expect(subject).to include(:state, :deleted_at, :diff_head_sha, :merge_commit_message, :has_conflicts, :has_ci, :merge_path, :conflict_resolution_path, :cancel_merge_when_pipeline_succeeds_path, diff --git a/spec/serializers/merge_request_serializer_spec.rb b/spec/serializers/merge_request_serializer_spec.rb index 73fbecc153d..e3abefa6d63 100644 --- a/spec/serializers/merge_request_serializer_spec.rb +++ b/spec/serializers/merge_request_serializer_spec.rb @@ -9,11 +9,11 @@ describe MergeRequestSerializer do end describe '#represent' do - let(:opts) { { basic: basic } } - subject { serializer.represent(merge_request, basic: basic) } + let(:opts) { { serializer: serializer_entity } } + subject { serializer.represent(merge_request, serializer: serializer_entity) } - context 'when basic param is truthy' do - let(:basic) { true } + context 'when passing basic serializer param' do + let(:serializer_entity) { 'basic' } it 'calls super class #represent with correct params' do expect_any_instance_of(BaseSerializer).to receive(:represent) @@ -23,8 +23,8 @@ describe MergeRequestSerializer do end end - context 'when basic param is falsy' do - let(:basic) { false } + context 'when serializer param is falsy' do + let(:serializer_entity) { nil } it 'calls super class #represent with correct params' do expect_any_instance_of(BaseSerializer).to receive(:represent) diff --git a/spec/services/applications/create_service_spec.rb b/spec/services/applications/create_service_spec.rb new file mode 100644 index 00000000000..47a2a9d6403 --- /dev/null +++ b/spec/services/applications/create_service_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe ::Applications::CreateService do + let(:user) { create(:user) } + let(:params) { attributes_for(:application) } + let(:request) { ActionController::TestRequest.new(remote_ip: '127.0.0.1') } + + subject { described_class.new(user, params) } + + it 'creates an application' do + expect { subject.execute(request) }.to change { Doorkeeper::Application.count }.by(1) + end +end diff --git a/spec/services/ci/create_cluster_service_spec.rb b/spec/services/ci/create_cluster_service_spec.rb deleted file mode 100644 index 6e7398fbffa..00000000000 --- a/spec/services/ci/create_cluster_service_spec.rb +++ /dev/null @@ -1,47 +0,0 @@ -require 'spec_helper' - -describe Ci::CreateClusterService do - describe '#execute' do - let(:access_token) { 'xxx' } - let(:project) { create(:project) } - let(:user) { create(:user) } - let(:result) { described_class.new(project, user, params).execute(access_token) } - - context 'when correct params' do - let(:params) do - { - gcp_project_id: 'gcp-project', - gcp_cluster_name: 'test-cluster', - gcp_cluster_zone: 'us-central1-a', - gcp_cluster_size: 1 - } - end - - it 'creates a cluster object' do - expect(ClusterProvisionWorker).to receive(:perform_async) - expect { result }.to change { Gcp::Cluster.count }.by(1) - expect(result.gcp_project_id).to eq('gcp-project') - expect(result.gcp_cluster_name).to eq('test-cluster') - expect(result.gcp_cluster_zone).to eq('us-central1-a') - expect(result.gcp_cluster_size).to eq(1) - expect(result.gcp_token).to eq(access_token) - end - end - - context 'when invalid params' do - let(:params) do - { - gcp_project_id: 'gcp-project', - gcp_cluster_name: 'test-cluster', - gcp_cluster_zone: 'us-central1-a', - gcp_cluster_size: 'ABC' - } - end - - it 'returns an error' do - expect(ClusterProvisionWorker).not_to receive(:perform_async) - expect { result }.to change { Gcp::Cluster.count }.by(0) - end - end - end -end diff --git a/spec/services/ci/fetch_gcp_operation_service_spec.rb b/spec/services/ci/fetch_gcp_operation_service_spec.rb deleted file mode 100644 index 7792979c5cb..00000000000 --- a/spec/services/ci/fetch_gcp_operation_service_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -require 'spec_helper' -require 'google/apis' - -describe Ci::FetchGcpOperationService do - describe '#execute' do - let(:cluster) { create(:gcp_cluster) } - let(:operation) { double } - - context 'when suceeded' do - before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:projects_zones_operations).and_return(operation) - end - - it 'fetch the gcp operaion' do - expect { |b| described_class.new.execute(cluster, &b) } - .to yield_with_args(operation) - end - end - - context 'when raises an error' do - let(:error) { Google::Apis::ServerError.new('a') } - - before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:projects_zones_operations).and_raise(error) - end - - it 'sets an error to cluster object' do - expect { |b| described_class.new.execute(cluster, &b) } - .not_to yield_with_args - expect(cluster.reload).to be_errored - end - end - end -end diff --git a/spec/services/ci/finalize_cluster_creation_service_spec.rb b/spec/services/ci/finalize_cluster_creation_service_spec.rb deleted file mode 100644 index def3709fdb4..00000000000 --- a/spec/services/ci/finalize_cluster_creation_service_spec.rb +++ /dev/null @@ -1,61 +0,0 @@ -require 'spec_helper' - -describe Ci::FinalizeClusterCreationService do - describe '#execute' do - let(:cluster) { create(:gcp_cluster) } - let(:result) { described_class.new.execute(cluster) } - - context 'when suceeded to get cluster from api' do - let(:gke_cluster) { double } - - before do - allow(gke_cluster).to receive(:endpoint).and_return('111.111.111.111') - allow(gke_cluster).to receive(:master_auth).and_return(spy) - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:projects_zones_clusters_get).and_return(gke_cluster) - end - - context 'when suceeded to get kubernetes token' do - let(:kubernetes_token) { 'abc' } - - before do - allow_any_instance_of(Ci::FetchKubernetesTokenService) - .to receive(:execute).and_return(kubernetes_token) - end - - it 'executes integration cluster' do - expect_any_instance_of(Ci::IntegrateClusterService).to receive(:execute) - described_class.new.execute(cluster) - end - end - - context 'when failed to get kubernetes token' do - before do - allow_any_instance_of(Ci::FetchKubernetesTokenService) - .to receive(:execute).and_return(nil) - end - - it 'sets an error to cluster object' do - described_class.new.execute(cluster) - - expect(cluster.reload).to be_errored - end - end - end - - context 'when failed to get cluster from api' do - let(:error) { Google::Apis::ServerError.new('a') } - - before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:projects_zones_clusters_get).and_raise(error) - end - - it 'sets an error to cluster object' do - described_class.new.execute(cluster) - - expect(cluster.reload).to be_errored - end - end - end -end diff --git a/spec/services/ci/integrate_cluster_service_spec.rb b/spec/services/ci/integrate_cluster_service_spec.rb deleted file mode 100644 index 3a79c205bd1..00000000000 --- a/spec/services/ci/integrate_cluster_service_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -require 'spec_helper' - -describe Ci::IntegrateClusterService do - describe '#execute' do - let(:cluster) { create(:gcp_cluster, :custom_project_namespace) } - let(:endpoint) { '123.123.123.123' } - let(:ca_cert) { 'ca_cert_xxx' } - let(:token) { 'token_xxx' } - let(:username) { 'username_xxx' } - let(:password) { 'password_xxx' } - - before do - described_class - .new.execute(cluster, endpoint, ca_cert, token, username, password) - - cluster.reload - end - - context 'when correct params' do - it 'creates a cluster object' do - expect(cluster.endpoint).to eq(endpoint) - expect(cluster.ca_cert).to eq(ca_cert) - expect(cluster.kubernetes_token).to eq(token) - expect(cluster.username).to eq(username) - expect(cluster.password).to eq(password) - expect(cluster.service.active).to be_truthy - expect(cluster.service.api_url).to eq(cluster.api_url) - expect(cluster.service.ca_pem).to eq(ca_cert) - expect(cluster.service.namespace).to eq(cluster.project_namespace) - expect(cluster.service.token).to eq(token) - end - end - - context 'when invalid params' do - let(:endpoint) { nil } - - it 'sets an error to cluster object' do - expect(cluster).to be_errored - end - end - end -end diff --git a/spec/services/ci/provision_cluster_service_spec.rb b/spec/services/ci/provision_cluster_service_spec.rb deleted file mode 100644 index 5ce5c788314..00000000000 --- a/spec/services/ci/provision_cluster_service_spec.rb +++ /dev/null @@ -1,85 +0,0 @@ -require 'spec_helper' - -describe Ci::ProvisionClusterService do - describe '#execute' do - let(:cluster) { create(:gcp_cluster) } - let(:operation) { spy } - - shared_examples 'error' do - it 'sets an error to cluster object' do - described_class.new.execute(cluster) - - expect(cluster.reload).to be_errored - end - end - - context 'when suceeded to request provision' do - before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:projects_zones_clusters_create).and_return(operation) - end - - context 'when operation status is RUNNING' do - before do - allow(operation).to receive(:status).and_return('RUNNING') - end - - context 'when suceeded to parse gcp operation id' do - before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:parse_operation_id).and_return('operation-123') - end - - context 'when cluster status is scheduled' do - before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:parse_operation_id).and_return('operation-123') - end - - it 'schedules a worker for status minitoring' do - expect(WaitForClusterCreationWorker).to receive(:perform_in) - - described_class.new.execute(cluster) - end - end - - context 'when cluster status is creating' do - before do - cluster.make_creating! - end - - it_behaves_like 'error' - end - end - - context 'when failed to parse gcp operation id' do - before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:parse_operation_id).and_return(nil) - end - - it_behaves_like 'error' - end - end - - context 'when operation status is others' do - before do - allow(operation).to receive(:status).and_return('others') - end - - it_behaves_like 'error' - end - end - - context 'when failed to request provision' do - let(:error) { Google::Apis::ServerError.new('a') } - - before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:projects_zones_clusters_create).and_raise(error) - end - - it_behaves_like 'error' - end - end -end diff --git a/spec/services/ci/update_cluster_service_spec.rb b/spec/services/ci/update_cluster_service_spec.rb deleted file mode 100644 index a289385b88f..00000000000 --- a/spec/services/ci/update_cluster_service_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -require 'spec_helper' - -describe Ci::UpdateClusterService do - describe '#execute' do - let(:cluster) { create(:gcp_cluster, :created_on_gke, :with_kubernetes_service) } - - before do - described_class.new(cluster.project, cluster.user, params).execute(cluster) - - cluster.reload - end - - context 'when correct params' do - context 'when enabled is true' do - let(:params) { { 'enabled' => 'true' } } - - it 'enables cluster and overwrite kubernetes service' do - expect(cluster.enabled).to be_truthy - expect(cluster.service.active).to be_truthy - expect(cluster.service.api_url).to eq(cluster.api_url) - expect(cluster.service.ca_pem).to eq(cluster.ca_cert) - expect(cluster.service.namespace).to eq(cluster.project_namespace) - expect(cluster.service.token).to eq(cluster.kubernetes_token) - end - end - - context 'when enabled is false' do - let(:params) { { 'enabled' => 'false' } } - - it 'disables cluster and kubernetes service' do - expect(cluster.enabled).to be_falsy - expect(cluster.service.active).to be_falsy - end - end - end - end -end diff --git a/spec/services/clusters/create_service_spec.rb b/spec/services/clusters/create_service_spec.rb new file mode 100644 index 00000000000..9c2c288a2fa --- /dev/null +++ b/spec/services/clusters/create_service_spec.rb @@ -0,0 +1,117 @@ +require 'spec_helper' + +describe Clusters::CreateService do + let(:access_token) { 'xxx' } + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:result) { described_class.new(project, user, params).execute(access_token) } + + context 'when provider is gcp' do + context 'when correct params' do + let(:params) do + { + name: 'test-cluster', + provider_type: :gcp, + provider_gcp_attributes: { + gcp_project_id: 'gcp-project', + zone: 'us-central1-a', + num_nodes: 1, + machine_type: 'machine_type-a' + } + } + end + + it 'creates a cluster object and performs a worker' do + expect(ClusterProvisionWorker).to receive(:perform_async) + + expect { result } + .to change { Clusters::Cluster.count }.by(1) + .and change { Clusters::Providers::Gcp.count }.by(1) + + expect(result.name).to eq('test-cluster') + expect(result.user).to eq(user) + expect(result.project).to eq(project) + expect(result.provider.gcp_project_id).to eq('gcp-project') + expect(result.provider.zone).to eq('us-central1-a') + expect(result.provider.num_nodes).to eq(1) + expect(result.provider.machine_type).to eq('machine_type-a') + expect(result.provider.access_token).to eq(access_token) + expect(result.platform).to be_nil + end + end + + context 'when invalid params' do + let(:params) do + { + name: 'test-cluster', + provider_type: :gcp, + provider_gcp_attributes: { + gcp_project_id: '!!!!!!!', + zone: 'us-central1-a', + num_nodes: 1, + machine_type: 'machine_type-a' + } + } + end + + it 'returns an error' do + expect(ClusterProvisionWorker).not_to receive(:perform_async) + expect { result }.to change { Clusters::Cluster.count }.by(0) + expect(result.errors[:"provider_gcp.gcp_project_id"]).to be_present + end + end + end + + # TODO: This will be active in 10.3 + # context 'when provider is user' do + # context 'when correct params' do + # let(:params) do + # { + # name: 'test-cluster', + # platform_type: :kubernetes, + # provider_type: :user, + # platform_kubernetes_attributes: { + # namespace: 'custom-namespace', + # api_url: 'https://111.111.111.111', + # token: 'token' + # } + # } + # end + + # it 'creates a cluster object and performs a worker' do + # expect(ClusterProvisionWorker).to receive(:perform_async) + + # expect { result } + # .to change { Clusters::Cluster.count }.by(1) + # .and change { Clusters::Platforms::Kubernetes.count }.by(1) + + # expect(result.name).to eq('test-cluster') + # expect(result.user).to eq(user) + # expect(result.project).to eq(project) + # expect(result.provider).to be_nil + # expect(result.platform.namespace).to eq('custom-namespace') + # end + # end + + # context 'when invalid params' do + # let(:params) do + # { + # name: 'test-cluster', + # platform_type: :kubernetes, + # provider_type: :user, + # platform_kubernetes_attributes: { + # namespace: 'custom-namespace', + # api_url: '!!!!!', + # token: 'token' + # } + # } + # end + + # it 'returns an error' do + # # expect(ClusterProvisionWorker).not_to receive(:perform_async) + # expect { result }.to change { Clusters::Cluster.count }.by(0) + # expect(result.errors[:"platform_kubernetes.api_url"]).to be_present + # end + # end + # end +end diff --git a/spec/services/clusters/gcp/fetch_operation_service_spec.rb b/spec/services/clusters/gcp/fetch_operation_service_spec.rb new file mode 100644 index 00000000000..20d46608033 --- /dev/null +++ b/spec/services/clusters/gcp/fetch_operation_service_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe Clusters::Gcp::FetchOperationService do + include GoogleApi::CloudPlatformHelpers + + describe '#execute' do + let(:provider) { create(:provider_gcp, :creating) } + let(:gcp_project_id) { provider.gcp_project_id } + let(:zone) { provider.zone } + let(:operation_id) { provider.operation_id } + + shared_examples 'success' do + it 'yields' do + expect { |b| described_class.new.execute(provider, &b) } + .to yield_with_args + end + end + + shared_examples 'error' do + it 'sets an error to provider object' do + expect { |b| described_class.new.execute(provider, &b) } + .not_to yield_with_args + expect(provider.reload).to be_errored + end + end + + context 'when suceeded to fetch operation' do + before do + stub_cloud_platform_get_zone_operation(gcp_project_id, zone, operation_id) + end + + it_behaves_like 'success' + end + + context 'when Internal Server Error happened' do + before do + stub_cloud_platform_get_zone_operation_error(gcp_project_id, zone, operation_id) + end + + it_behaves_like 'error' + end + end +end diff --git a/spec/services/clusters/gcp/finalize_creation_service_spec.rb b/spec/services/clusters/gcp/finalize_creation_service_spec.rb new file mode 100644 index 00000000000..0cf91307589 --- /dev/null +++ b/spec/services/clusters/gcp/finalize_creation_service_spec.rb @@ -0,0 +1,111 @@ +require 'spec_helper' + +describe Clusters::Gcp::FinalizeCreationService do + include GoogleApi::CloudPlatformHelpers + include KubernetesHelpers + + describe '#execute' do + let(:cluster) { create(:cluster, :project, :providing_by_gcp) } + let(:provider) { cluster.provider } + let(:platform) { cluster.platform } + let(:gcp_project_id) { provider.gcp_project_id } + let(:zone) { provider.zone } + let(:cluster_name) { cluster.name } + + shared_examples 'success' do + it 'configures provider and kubernetes' do + described_class.new.execute(provider) + + expect(provider).to be_created + end + end + + shared_examples 'error' do + it 'sets an error to provider object' do + described_class.new.execute(provider) + + expect(provider.reload).to be_errored + end + end + + context 'when suceeded to fetch gke cluster info' do + let(:endpoint) { '111.111.111.111' } + let(:api_url) { 'https://' + endpoint } + let(:username) { 'sample-username' } + let(:password) { 'sample-password' } + + before do + stub_cloud_platform_get_zone_cluster( + gcp_project_id, zone, cluster_name, + { + endpoint: endpoint, + username: username, + password: password + } + ) + + stub_kubeclient_discover(api_url) + end + + context 'when suceeded to fetch kuberenetes token' do + let(:token) { 'sample-token' } + + before do + stub_kubeclient_get_secrets( + api_url, + { + token: Base64.encode64(token) + } ) + end + + it_behaves_like 'success' + + it 'has corresponded data' do + described_class.new.execute(provider) + cluster.reload + provider.reload + platform.reload + + expect(provider.endpoint).to eq(endpoint) + expect(platform.api_url).to eq(api_url) + expect(platform.ca_cert).to eq(Base64.decode64(load_sample_cert)) + expect(platform.username).to eq(username) + expect(platform.password).to eq(password) + expect(platform.token).to eq(token) + end + end + + context 'when default-token is not found' do + before do + stub_kubeclient_get_secrets(api_url, metadata_name: 'aaaa') + end + + it_behaves_like 'error' + end + + context 'when token is empty' do + before do + stub_kubeclient_get_secrets(api_url, token: '') + end + + it_behaves_like 'error' + end + + context 'when failed to fetch kuberenetes token' do + before do + stub_kubeclient_get_secrets_error(api_url) + end + + it_behaves_like 'error' + end + end + + context 'when failed to fetch gke cluster info' do + before do + stub_cloud_platform_get_zone_cluster_error(gcp_project_id, zone, cluster_name) + end + + it_behaves_like 'error' + end + end +end diff --git a/spec/services/clusters/gcp/provision_service_spec.rb b/spec/services/clusters/gcp/provision_service_spec.rb new file mode 100644 index 00000000000..f5f9d4800fd --- /dev/null +++ b/spec/services/clusters/gcp/provision_service_spec.rb @@ -0,0 +1,69 @@ +require 'spec_helper' + +describe Clusters::Gcp::ProvisionService do + include GoogleApi::CloudPlatformHelpers + + describe '#execute' do + let(:provider) { create(:provider_gcp, :scheduled) } + let(:gcp_project_id) { provider.gcp_project_id } + let(:zone) { provider.zone } + + shared_examples 'success' do + it 'schedules a worker for status minitoring' do + expect(WaitForClusterCreationWorker).to receive(:perform_in) + + described_class.new.execute(provider) + + expect(provider.reload).to be_creating + end + end + + shared_examples 'error' do + it 'sets an error to provider object' do + described_class.new.execute(provider) + + expect(provider.reload).to be_errored + end + end + + context 'when suceeded to request provision' do + before do + stub_cloud_platform_create_cluster(gcp_project_id, zone) + end + + it_behaves_like 'success' + end + + context 'when operation status is unexpected' do + before do + stub_cloud_platform_create_cluster( + gcp_project_id, zone, + { + "status": 'unexpected' + } ) + end + + it_behaves_like 'error' + end + + context 'when selfLink is unexpected' do + before do + stub_cloud_platform_create_cluster( + gcp_project_id, zone, + { + "selfLink": 'unexpected' + }) + end + + it_behaves_like 'error' + end + + context 'when Internal Server Error happened' do + before do + stub_cloud_platform_create_cluster_error(gcp_project_id, zone) + end + + it_behaves_like 'error' + end + end +end diff --git a/spec/services/clusters/gcp/verify_provision_status_service_spec.rb b/spec/services/clusters/gcp/verify_provision_status_service_spec.rb new file mode 100644 index 00000000000..666fcf13cac --- /dev/null +++ b/spec/services/clusters/gcp/verify_provision_status_service_spec.rb @@ -0,0 +1,107 @@ +require 'spec_helper' + +describe Clusters::Gcp::VerifyProvisionStatusService do + include GoogleApi::CloudPlatformHelpers + + describe '#execute' do + let(:provider) { create(:provider_gcp, :creating) } + let(:gcp_project_id) { provider.gcp_project_id } + let(:zone) { provider.zone } + let(:operation_id) { provider.operation_id } + + shared_examples 'continue_creation' do + it 'schedules a worker for status minitoring' do + expect(WaitForClusterCreationWorker).to receive(:perform_in) + + described_class.new.execute(provider) + end + end + + shared_examples 'finalize_creation' do + it 'schedules a worker for status minitoring' do + expect_any_instance_of(Clusters::Gcp::FinalizeCreationService).to receive(:execute) + + described_class.new.execute(provider) + end + end + + shared_examples 'error' do + it 'sets an error to provider object' do + described_class.new.execute(provider) + + expect(provider.reload).to be_errored + end + end + + context 'when operation status is RUNNING' do + before do + stub_cloud_platform_get_zone_operation( + gcp_project_id, zone, operation_id, + { + "status": 'RUNNING', + "startTime": 1.minute.ago.strftime("%FT%TZ") + } ) + end + + it_behaves_like 'continue_creation' + + context 'when cluster creation time exceeds timeout' do + before do + stub_cloud_platform_get_zone_operation( + gcp_project_id, zone, operation_id, + { + "status": 'RUNNING', + "startTime": 30.minutes.ago.strftime("%FT%TZ") + } ) + end + + it_behaves_like 'error' + end + end + + context 'when operation status is PENDING' do + before do + stub_cloud_platform_get_zone_operation( + gcp_project_id, zone, operation_id, + { + "status": 'PENDING', + "startTime": 1.minute.ago.strftime("%FT%TZ") + } ) + end + + it_behaves_like 'continue_creation' + end + + context 'when operation status is DONE' do + before do + stub_cloud_platform_get_zone_operation( + gcp_project_id, zone, operation_id, + { + "status": 'DONE' + } ) + end + + it_behaves_like 'finalize_creation' + end + + context 'when operation status is unexpected' do + before do + stub_cloud_platform_get_zone_operation( + gcp_project_id, zone, operation_id, + { + "status": 'unexpected' + } ) + end + + it_behaves_like 'error' + end + + context 'when failed to get operation status' do + before do + stub_cloud_platform_get_zone_operation_error(gcp_project_id, zone, operation_id) + end + + it_behaves_like 'error' + end + end +end diff --git a/spec/services/clusters/update_service_spec.rb b/spec/services/clusters/update_service_spec.rb new file mode 100644 index 00000000000..2d91a21035d --- /dev/null +++ b/spec/services/clusters/update_service_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +describe Clusters::UpdateService do + describe '#execute' do + subject { described_class.new(cluster.project, cluster.user, params).execute(cluster) } + + let(:cluster) { create(:cluster, :project, :provided_by_user) } + + context 'when correct params' do + context 'when enabled is true' do + let(:params) { { enabled: true } } + + it 'enables cluster' do + is_expected.to eq(true) + expect(cluster.enabled).to be_truthy + end + end + + context 'when enabled is false' do + let(:params) { { enabled: false } } + + it 'disables cluster' do + is_expected.to eq(true) + expect(cluster.enabled).to be_falsy + end + end + + context 'when namespace is specified' do + let(:params) do + { + platform_kubernetes_attributes: { + namespace: 'custom-namespace' + } + } + end + + it 'updates namespace' do + is_expected.to eq(true) + expect(cluster.platform.namespace).to eq('custom-namespace') + end + end + end + + context 'when invalid params' do + let(:params) do + { + platform_kubernetes_attributes: { + namespace: '!!!' + } + } + end + + it 'returns false' do + is_expected.to eq(false) + expect(cluster.errors[:"platform_kubernetes.namespace"]).to be_present + end + end + end +end diff --git a/spec/services/issuable/common_system_notes_service_spec.rb b/spec/services/issuable/common_system_notes_service_spec.rb new file mode 100644 index 00000000000..9f92b662be1 --- /dev/null +++ b/spec/services/issuable/common_system_notes_service_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe Issuable::CommonSystemNotesService do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:issuable) { create(:issue) } + + shared_examples 'system note creation' do |update_params, note_text| + subject { described_class.new(project, user).execute(issuable, [])} + + before do + issuable.assign_attributes(update_params) + issuable.save + end + + it 'creates 1 system note with the correct content' do + expect { subject }.to change { Note.count }.from(0).to(1) + + note = Note.last + expect(note.note).to match(note_text) + expect(note.noteable_type).to eq('Issue') + end + end + + describe '#execute' do + it_behaves_like 'system note creation', { title: 'New title' }, 'changed title' + it_behaves_like 'system note creation', { description: 'New description' }, 'changed the description' + it_behaves_like 'system note creation', { discussion_locked: true }, 'locked this issue' + it_behaves_like 'system note creation', { time_estimate: 5 }, 'changed time estimate' + + context 'when new label is added' do + before do + label = create(:label, project: project) + issuable.labels << label + end + + it_behaves_like 'system note creation', {}, /added ~\w+ label/ + end + + context 'when new milestone is assigned' do + before do + milestone = create(:milestone, project: project) + issuable.milestone_id = milestone.id + end + + it_behaves_like 'system note creation', {}, 'changed milestone' + end + end +end diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb index d1043f99b5a..ac196e92601 100644 --- a/spec/services/merge_requests/merge_service_spec.rb +++ b/spec/services/merge_requests/merge_service_spec.rb @@ -12,55 +12,6 @@ describe MergeRequests::MergeService do end describe '#execute' do - context 'MergeRequest#merge_jid' do - let(:service) do - described_class.new(project, user, commit_message: 'Awesome message') - end - - before do - merge_request.update_column(:merge_jid, 'hash-123') - end - - it 'is cleaned when no error is raised' do - service.execute(merge_request) - - expect(merge_request.reload.merge_jid).to be_nil - end - - it 'is cleaned when expected error is raised' do - allow(service).to receive(:commit).and_raise(described_class::MergeError) - - service.execute(merge_request) - - expect(merge_request.reload.merge_jid).to be_nil - end - - it 'is cleaned when merge request is not mergeable' do - allow(merge_request).to receive(:mergeable?).and_return(false) - - service.execute(merge_request) - - expect(merge_request.reload.merge_jid).to be_nil - end - - it 'is cleaned when no source is found' do - allow(merge_request).to receive(:diff_head_sha).and_return(nil) - - service.execute(merge_request) - - expect(merge_request.reload.merge_jid).to be_nil - end - - it 'is not cleaned when unexpected error is raised' do - service = described_class.new(project, user, commit_message: 'Awesome message') - allow(service).to receive(:commit).and_raise(StandardError) - - expect { service.execute(merge_request) }.to raise_error(StandardError) - - expect(merge_request.reload.merge_jid).to be_present - end - end - context 'valid params' do let(:service) { described_class.new(project, user, commit_message: 'Awesome message') } diff --git a/spec/services/milestones/promote_service_spec.rb b/spec/services/milestones/promote_service_spec.rb new file mode 100644 index 00000000000..9f2df6d6d19 --- /dev/null +++ b/spec/services/milestones/promote_service_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +describe Milestones::PromoteService do + let(:group) { create(:group) } + let(:project) { create(:project, namespace: group) } + let(:user) { create(:user) } + let(:milestone_title) { 'project milestone' } + let(:milestone) { create(:milestone, project: project, title: milestone_title) } + let(:service) { described_class.new(project, user) } + + describe '#execute' do + before do + group.add_master(user) + end + + context 'validations' do + it 'raises error if milestone does not belong to a project' do + allow(milestone).to receive(:project_milestone?).and_return(false) + + expect { service.execute(milestone) }.to raise_error(described_class::PromoteMilestoneError) + end + + it 'raises error if project does not belong to a group' do + project.update(namespace: user.namespace) + + expect { service.execute(milestone) }.to raise_error(described_class::PromoteMilestoneError) + end + end + + context 'without duplicated milestone titles across projects' do + it 'promotes project milestone to group milestone' do + promoted_milestone = service.execute(milestone) + + expect(promoted_milestone).to be_group_milestone + end + + it 'sets issuables with new promoted milestone' do + issue = create(:issue, milestone: milestone, project: project) + merge_request = create(:merge_request, milestone: milestone, source_project: project) + + promoted_milestone = service.execute(milestone) + + expect(promoted_milestone).to be_group_milestone + expect(issue.reload.milestone).to eq(promoted_milestone) + expect(merge_request.reload.milestone).to eq(promoted_milestone) + end + end + + context 'with duplicated milestone titles across projects' do + let(:project_2) { create(:project, namespace: group) } + let!(:milestone_2) { create(:milestone, project: project_2, title: milestone_title) } + + it 'deletes project milestones with the same title' do + promoted_milestone = service.execute(milestone) + + expect(promoted_milestone).to be_group_milestone + expect(promoted_milestone).to be_valid + expect(Milestone.exists?(milestone.id)).to be_falsy + expect(Milestone.exists?(milestone_2.id)).to be_falsy + end + + it 'sets all issuables with new promoted milestone' do + issue = create(:issue, milestone: milestone, project: project) + issue_2 = create(:issue, milestone: milestone_2, project: project_2) + merge_request = create(:merge_request, milestone: milestone, source_project: project) + merge_request_2 = create(:merge_request, milestone: milestone_2, source_project: project_2) + + promoted_milestone = service.execute(milestone) + + expect(issue.reload.milestone).to eq(promoted_milestone) + expect(issue_2.reload.milestone).to eq(promoted_milestone) + expect(merge_request.reload.milestone).to eq(promoted_milestone) + expect(merge_request_2.reload.milestone).to eq(promoted_milestone) + end + end + end +end diff --git a/spec/services/projects/group_links/create_service_spec.rb b/spec/services/projects/group_links/create_service_spec.rb new file mode 100644 index 00000000000..ffb270d277e --- /dev/null +++ b/spec/services/projects/group_links/create_service_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe Projects::GroupLinks::CreateService, '#execute' do + let(:user) { create :user } + let(:group) { create :group } + let(:project) { create :project } + let(:opts) do + { + link_group_access: '30', + expires_at: nil + } + end + let(:subject) { described_class.new(project, user, opts) } + + it 'adds group to project' do + expect { subject.execute(group) }.to change { project.project_group_links.count }.from(0).to(1) + end + + it 'returns false if group is blank' do + expect { subject.execute(nil) }.not_to change { project.project_group_links.count } + end +end diff --git a/spec/services/projects/group_links/destroy_service_spec.rb b/spec/services/projects/group_links/destroy_service_spec.rb new file mode 100644 index 00000000000..336ee01ae50 --- /dev/null +++ b/spec/services/projects/group_links/destroy_service_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +describe Projects::GroupLinks::DestroyService, '#execute' do + let(:group_link) { create :project_group_link } + let(:project) { group_link.project } + let(:user) { create :user } + let(:subject) { described_class.new(project, user) } + + it 'removes group from project' do + expect { subject.execute(group_link) }.to change { project.project_group_links.count }.from(1).to(0) + end + + it 'returns false if group_link is blank' do + expect { subject.execute(nil) }.not_to change { project.project_group_links.count } + end +end diff --git a/spec/services/projects/hashed_storage_migration_service_spec.rb b/spec/services/projects/hashed_storage_migration_service_spec.rb index aa1988d29d6..b71b47c59b6 100644 --- a/spec/services/projects/hashed_storage_migration_service_spec.rb +++ b/spec/services/projects/hashed_storage_migration_service_spec.rb @@ -23,7 +23,7 @@ describe Projects::HashedStorageMigrationService do it 'updates project to be hashed and not read-only' do service.execute - expect(project.hashed_storage?).to be_truthy + expect(project.hashed_storage?(:repository)).to be_truthy expect(project.repository_read_only).to be_falsey end diff --git a/spec/services/projects/unlink_fork_service_spec.rb b/spec/services/projects/unlink_fork_service_spec.rb index 50d3a4ec982..2bba71fef4f 100644 --- a/spec/services/projects/unlink_fork_service_spec.rb +++ b/spec/services/projects/unlink_fork_service_spec.rb @@ -12,6 +12,9 @@ describe Projects::UnlinkForkService do context 'with opened merge request on the source project' do let(:merge_request) { create(:merge_request, source_project: forked_project, target_project: fork_link.forked_from_project) } + let(:merge_request2) { create(:merge_request, source_project: forked_project, target_project: fork_project(project)) } + let(:merge_request_in_fork) { create(:merge_request, source_project: forked_project, target_project: forked_project) } + let(:mr_close_service) { MergeRequests::CloseService.new(forked_project, user) } before do @@ -22,9 +25,14 @@ describe Projects::UnlinkForkService do it 'close all pending merge requests' do expect(mr_close_service).to receive(:execute).with(merge_request) + expect(mr_close_service).to receive(:execute).with(merge_request2) subject.execute end + + it 'does not close merge requests for the project being unlinked' do + expect(mr_close_service).not_to receive(:execute).with(merge_request_in_fork) + end end it 'remove fork relation' do @@ -53,4 +61,14 @@ describe Projects::UnlinkForkService do expect(source.forks_count).to be_zero end + + context 'when the original project was deleted' do + it 'does not fail when the original project is deleted' do + source = forked_project.forked_from_project + source.destroy + forked_project.reload + + expect { subject.execute }.not_to raise_error + end + end end diff --git a/spec/services/system_hooks_service_spec.rb b/spec/services/system_hooks_service_spec.rb index 8b5d9187785..46cd10cdc12 100644 --- a/spec/services/system_hooks_service_spec.rb +++ b/spec/services/system_hooks_service_spec.rb @@ -63,11 +63,54 @@ describe SystemHooksService do :group_id, :user_id, :user_username, :user_name, :user_email, :group_access ) end + + it 'includes the correct project visibility level' do + data = event_data(project, :create) + + expect(data[:project_visibility]).to eq('private') + end + + context 'group_rename' do + it 'contains old and new path' do + allow(group).to receive(:path_was).and_return('old-path') + + data = event_data(group, :rename) + + expect(data).to include(:event_name, :name, :created_at, :updated_at, :full_path, :path, :group_id, :old_path, :old_full_path) + expect(data[:path]).to eq(group.path) + expect(data[:full_path]).to eq(group.path) + expect(data[:old_path]).to eq(group.path_was) + expect(data[:old_full_path]).to eq(group.path_was) + end + + it 'contains old and new full_path for subgroup' do + subgroup = create(:group, parent: group) + allow(subgroup).to receive(:path_was).and_return('old-path') + + data = event_data(subgroup, :rename) + + expect(data[:full_path]).to eq(subgroup.full_path) + expect(data[:old_path]).to eq('old-path') + end + end + + context 'user_rename' do + it 'contains old and new username' do + allow(user).to receive(:username_was).and_return('old-username') + + data = event_data(user, :rename) + + expect(data).to include(:event_name, :name, :created_at, :updated_at, :email, :user_id, :username, :old_username) + expect(data[:username]).to eq(user.username) + expect(data[:old_username]).to eq(user.username_was) + end + end end context 'event names' do it { expect(event_name(user, :create)).to eq "user_create" } it { expect(event_name(user, :destroy)).to eq "user_destroy" } + it { expect(event_name(user, :rename)).to eq 'user_rename' } it { expect(event_name(project, :create)).to eq "project_create" } it { expect(event_name(project, :destroy)).to eq "project_destroy" } it { expect(event_name(project, :rename)).to eq "project_rename" } @@ -79,6 +122,7 @@ describe SystemHooksService do it { expect(event_name(key, :destroy)).to eq 'key_destroy' } it { expect(event_name(group, :create)).to eq 'group_create' } it { expect(event_name(group, :destroy)).to eq 'group_destroy' } + it { expect(event_name(group, :rename)).to eq 'group_rename' } it { expect(event_name(group_member, :create)).to eq 'user_add_to_group' } it { expect(event_name(group_member, :destroy)).to eq 'user_remove_from_group' } end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 48cacba6a8a..7c8331f6c60 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -48,7 +48,11 @@ RSpec.configure do |config| config.include Warden::Test::Helpers, type: :request config.include LoginHelpers, type: :feature config.include SearchHelpers, type: :feature + config.include CookieHelper, :js + config.include InputHelper, :js + config.include InspectRequests, :js config.include WaitForRequests, :js + config.include LiveDebugger, :js config.include StubConfiguration config.include EmailHelpers, :mailer, type: :mailer config.include TestEnv diff --git a/spec/support/api_helpers.rb b/spec/support/api_helpers.rb index 01aca74274c..ac0c7a9b493 100644 --- a/spec/support/api_helpers.rb +++ b/spec/support/api_helpers.rb @@ -18,21 +18,23 @@ module ApiHelpers # # Returns the relative path to the requested API resource def api(path, user = nil, version: API::API.version, personal_access_token: nil, oauth_access_token: nil) - "/api/#{version}#{path}" + + full_path = "/api/#{version}#{path}" - # Normalize query string - (path.index('?') ? '' : '?') + + if oauth_access_token + query_string = "access_token=#{oauth_access_token.token}" + elsif personal_access_token + query_string = "private_token=#{personal_access_token.token}" + elsif user + personal_access_token = create(:personal_access_token, user: user) + query_string = "private_token=#{personal_access_token.token}" + end - if personal_access_token.present? - "&private_token=#{personal_access_token.token}" - elsif oauth_access_token.present? - "&access_token=#{oauth_access_token.token}" - # Append private_token if given a User object - elsif user.respond_to?(:private_token) - "&private_token=#{user.private_token}" - else - '' - end + if query_string + full_path << (path.index('?') ? '&' : '?') + full_path << query_string + end + + full_path end # Temporary helper method for simplifying V3 exclusive API specs diff --git a/spec/support/bare_repo_operations.rb b/spec/support/bare_repo_operations.rb new file mode 100644 index 00000000000..38d11992dc2 --- /dev/null +++ b/spec/support/bare_repo_operations.rb @@ -0,0 +1,60 @@ +require 'zlib' + +class BareRepoOperations + # The ID of empty tree. + # See http://stackoverflow.com/a/40884093/1856239 and https://github.com/git/git/blob/3ad8b5bf26362ac67c9020bf8c30eee54a84f56d/cache.h#L1011-L1012 + EMPTY_TREE_ID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'.freeze + + include Gitlab::Popen + + def initialize(path_to_repo) + @path_to_repo = path_to_repo + end + + # Based on https://stackoverflow.com/a/25556917/1856239 + def commit_file(file, dst_path, branch = 'master') + head_id = execute(['show', '--format=format:%H', '--no-patch', branch], allow_failure: true)[0] || EMPTY_TREE_ID + + execute(['read-tree', '--empty']) + execute(['read-tree', head_id]) + + blob_id = execute(['hash-object', '--stdin', '-w']) do |stdin| + stdin.write(file.read) + end + + execute(['update-index', '--add', '--cacheinfo', '100644', blob_id[0], dst_path]) + + tree_id = execute(['write-tree']) + + commit_tree_args = ['commit-tree', tree_id[0], '-m', "Add #{dst_path}"] + commit_tree_args += ['-p', head_id] unless head_id == EMPTY_TREE_ID + commit_id = execute(commit_tree_args) + + execute(['update-ref', "refs/heads/#{branch}", commit_id[0]]) + end + + private + + def execute(args, allow_failure: false) + output, status = popen(base_args + args, nil) do |stdin| + yield stdin if block_given? + end + + unless status.zero? + if allow_failure + return [] + else + raise "Got a non-zero exit code while calling out `#{args.join(' ')}`: #{output}" + end + end + + output.split("\n") + end + + def base_args + [ + Gitlab.config.git.bin_path, + "--git-dir=#{@path_to_repo}" + ] + end +end diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index c45c4a4310d..9f672bc92fc 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -1,25 +1,25 @@ # rubocop:disable Style/GlobalVars require 'capybara/rails' require 'capybara/rspec' -require 'capybara/poltergeist' require 'capybara-screenshot/rspec' +require 'selenium-webdriver' # Give CI some extra time timeout = (ENV['CI'] || ENV['CI_SERVER']) ? 60 : 30 -Capybara.javascript_driver = :poltergeist -Capybara.register_driver :poltergeist do |app| - Capybara::Poltergeist::Driver.new( - app, - js_errors: true, - timeout: timeout, - window_size: [1366, 768], - url_whitelist: %w[localhost 127.0.0.1], - url_blacklist: %w[.mp4 .png .gif .avi .bmp .jpg .jpeg], - phantomjs_options: [ - '--load-images=yes' - ] +Capybara.javascript_driver = :chrome +Capybara.register_driver :chrome do |app| + extra_args = [] + extra_args << 'headless' unless ENV['CHROME_HEADLESS'] =~ /^(false|no|0)$/i + + capabilities = Selenium::WebDriver::Remote::Capabilities.chrome( + chromeOptions: { + 'args' => %w[no-sandbox disable-gpu --window-size=1240,1400] + extra_args + } ) + + Capybara::Selenium::Driver + .new(app, browser: :chrome, desired_capabilities: capabilities) end Capybara.default_max_wait_time = timeout @@ -27,6 +27,10 @@ Capybara.ignore_hidden_elements = true # Keep only the screenshots generated from the last failing test suite Capybara::Screenshot.prune_strategy = :keep_last_run +# From https://github.com/mattheworiordan/capybara-screenshot/issues/84#issuecomment-41219326 +Capybara::Screenshot.register_driver(:chrome) do |driver, path| + driver.browser.save_screenshot(path) +end RSpec.configure do |config| config.before(:context, :js) do @@ -37,13 +41,23 @@ RSpec.configure do |config| end config.before(:example, :js) do + session = Capybara.current_session + allow(Gitlab::Application.routes).to receive(:default_url_options).and_return( - host: Capybara.current_session.server.host, - port: Capybara.current_session.server.port, + host: session.server.host, + port: session.server.port, protocol: 'http') + + # reset window size between tests + unless session.current_window.size == [1240, 1400] + session.current_window.resize_to(1240, 1400) rescue nil + end end config.after(:example, :js) do |example| + # prevent localstorage from introducing side effects based on test order + execute_script("localStorage.clear();") + # capybara/rspec already calls Capybara.reset_sessions! in an `after` hook, # but `block_and_wait_for_requests_complete` is called before it so by # calling it explicitely here, we prevent any new requests from being fired diff --git a/spec/support/capybara_helpers.rb b/spec/support/capybara_helpers.rb index 3eb7bea3227..868233416bf 100644 --- a/spec/support/capybara_helpers.rb +++ b/spec/support/capybara_helpers.rb @@ -38,7 +38,7 @@ module CapybaraHelpers # Simulate a browser restart by clearing the session cookie. def clear_browser_session - page.driver.remove_cookie('_gitlab_session') + page.driver.browser.manage.delete_cookie('_gitlab_session') end end diff --git a/spec/support/cookie_helper.rb b/spec/support/cookie_helper.rb new file mode 100644 index 00000000000..224619c899c --- /dev/null +++ b/spec/support/cookie_helper.rb @@ -0,0 +1,17 @@ +# Helper for setting cookies in Selenium/WebDriver +# +module CookieHelper + def set_cookie(name, value, options = {}) + # Selenium driver will not set cookies for a given domain when the browser is at `about:blank`. + # It also doesn't appear to allow overriding the cookie path. loading `/` is the most inclusive. + visit options.fetch(:path, '/') unless on_a_page? + page.driver.browser.manage.add_cookie(name: name, value: value, **options) + end + + private + + def on_a_page? + current_url = Capybara.current_session.driver.browser.current_url + current_url && current_url != '' && current_url != 'about:blank' && current_url != 'data:,' + end +end diff --git a/spec/support/features/discussion_comments_shared_example.rb b/spec/support/features/discussion_comments_shared_example.rb index 7132b9cd221..aabc64d972b 100644 --- a/spec/support/features/discussion_comments_shared_example.rb +++ b/spec/support/features/discussion_comments_shared_example.rb @@ -71,26 +71,28 @@ shared_examples 'discussion comments' do |resource_name| expect(page).not_to have_selector menu_selector find(toggle_selector).click - find('body').trigger 'click' + find('body').click expect(page).not_to have_selector menu_selector end it 'clicking the ul padding or divider should not change the text' do - find(menu_selector).trigger 'click' + execute_script("document.querySelector('#{menu_selector}').click()") + # on issues page, the menu closes when clicking anywhere, on other pages it will + # remain open if clicking divider or menu padding, but should not change button action if resource_name == 'issue' expect(find(dropdown_selector)).to have_content 'Comment' find(toggle_selector).click - find("#{menu_selector} .divider").trigger 'click' + execute_script("document.querySelector('#{menu_selector} .divider').click()") else - find(menu_selector).trigger 'click' + execute_script("document.querySelector('#{menu_selector}').click()") expect(page).to have_selector menu_selector expect(find(dropdown_selector)).to have_content 'Comment' - find("#{menu_selector} .divider").trigger 'click' + execute_script("document.querySelector('#{menu_selector} .divider').click()") expect(page).to have_selector menu_selector end @@ -105,7 +107,12 @@ shared_examples 'discussion comments' do |resource_name| end it 'updates the submit button text and closes the dropdown' do - expect(find(dropdown_selector)).to have_content 'Start discussion' + # on issues page, the submit input is a <button>, on other pages it is <input> + if resource_name == 'issue' + expect(find(submit_selector)).to have_content 'Start discussion' + else + expect(find(submit_selector).value).to eq 'Start discussion' + end expect(page).not_to have_selector menu_selector end @@ -187,7 +194,12 @@ shared_examples 'discussion comments' do |resource_name| end it 'updates the submit button text and closes the dropdown' do - expect(find(dropdown_selector)).to have_content 'Comment' + # on issues page, the submit input is a <button>, on other pages it is <input> + if resource_name == 'issue' + expect(find(submit_selector)).to have_content 'Comment' + else + expect(find(submit_selector).value).to eq 'Comment' + end expect(page).not_to have_selector menu_selector end @@ -226,6 +238,7 @@ shared_examples 'discussion comments' do |resource_name| describe "on a closed #{resource_name}" do before do find("#{form_selector} .js-note-target-close").click + wait_for_requests find("#{form_selector} .note-textarea").send_keys('a') end diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb index 061e0d35590..08e21ee2537 100644 --- a/spec/support/features/issuable_slash_commands_shared_examples.rb +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -61,7 +61,7 @@ shared_examples 'issuable record that supports quick actions in its description context 'with a note containing commands' do it 'creates a note without the commands and interpret the commands accordingly' do assignee = create(:user, username: 'bob') - write_note("Awesome!\n/assign @bob\n/label ~bug\n/milestone %\"ASAP\"") + write_note("Awesome!\n\n/assign @bob\n\n/label ~bug\n\n/milestone %\"ASAP\"") expect(page).to have_content 'Awesome!' expect(page).not_to have_content '/assign @bob' @@ -82,7 +82,7 @@ shared_examples 'issuable record that supports quick actions in its description context 'with a note containing only commands' do it 'does not create a note but interpret the commands accordingly' do assignee = create(:user, username: 'bob') - write_note("/assign @bob\n/label ~bug\n/milestone %\"ASAP\"") + write_note("/assign @bob\n\n/label ~bug\n\n/milestone %\"ASAP\"") expect(page).not_to have_content '/assign @bob' expect(page).not_to have_content '/label ~bug' diff --git a/spec/support/features/reportable_note_shared_examples.rb b/spec/support/features/reportable_note_shared_examples.rb index 192a2fed0a8..836e5e7be23 100644 --- a/spec/support/features/reportable_note_shared_examples.rb +++ b/spec/support/features/reportable_note_shared_examples.rb @@ -39,7 +39,7 @@ shared_examples 'reportable note' do |type| end def open_dropdown(dropdown) - dropdown.find('.more-actions-toggle').trigger('click') + dropdown.find('.more-actions-toggle').click dropdown.find('.dropdown-menu li', match: :first) end end diff --git a/spec/support/gitlab-git-test.git/objects/88/3e379fcaa5f818fca81cdbabd7a497794d6535 b/spec/support/gitlab-git-test.git/objects/88/3e379fcaa5f818fca81cdbabd7a497794d6535 Binary files differnew file mode 100644 index 00000000000..1c47f34b9a5 --- /dev/null +++ b/spec/support/gitlab-git-test.git/objects/88/3e379fcaa5f818fca81cdbabd7a497794d6535 diff --git a/spec/support/gitlab-git-test.git/objects/c8/b1ab16c858c67b680eea4644cf652485f555cf b/spec/support/gitlab-git-test.git/objects/c8/b1ab16c858c67b680eea4644cf652485f555cf Binary files differnew file mode 100644 index 00000000000..ca13c8df66a --- /dev/null +++ b/spec/support/gitlab-git-test.git/objects/c8/b1ab16c858c67b680eea4644cf652485f555cf diff --git a/spec/support/gitlab-git-test.git/objects/e3/7697aea12699f0b44544332a7c0f41ace5fb16 b/spec/support/gitlab-git-test.git/objects/e3/7697aea12699f0b44544332a7c0f41ace5fb16 new file mode 100644 index 00000000000..3be244dbda4 --- /dev/null +++ b/spec/support/gitlab-git-test.git/objects/e3/7697aea12699f0b44544332a7c0f41ace5fb16 @@ -0,0 +1,2 @@ +x¥ŽK +Â0EgNIÒ|ADtè*^’
mZ qGîÄY×àð8—×ZK©ý®7"ÈFc’Ò%oH¢D²Ü9rZÛLÎs“MJ2Œ™=±ÑÒAå…CmeFg²·V¨xI9øH2†¯þXÜJ…ár»pÅ6‡Ï;NÔà8•zˆ??>ß+–ù×z¡¹WÆBÞÎÙf·Ç}«þßb¡N@K\SYîì•iSC
\ No newline at end of file diff --git a/spec/support/gitlab-git-test.git/objects/eb/a0c153ed20d927bab00507f356043b6b4be31e b/spec/support/gitlab-git-test.git/objects/eb/a0c153ed20d927bab00507f356043b6b4be31e Binary files differnew file mode 100644 index 00000000000..2bf27fe5048 --- /dev/null +++ b/spec/support/gitlab-git-test.git/objects/eb/a0c153ed20d927bab00507f356043b6b4be31e diff --git a/spec/support/gitlab-git-test.git/objects/f6/5ad228d96e2a2ae7088e8557fe8906f6dd2b3f b/spec/support/gitlab-git-test.git/objects/f6/5ad228d96e2a2ae7088e8557fe8906f6dd2b3f Binary files differnew file mode 100644 index 00000000000..8ab8606c6be --- /dev/null +++ b/spec/support/gitlab-git-test.git/objects/f6/5ad228d96e2a2ae7088e8557fe8906f6dd2b3f diff --git a/spec/support/gitlab_stubs/session.json b/spec/support/gitlab_stubs/session.json index 688175369ae..658ff5871b0 100644 --- a/spec/support/gitlab_stubs/session.json +++ b/spec/support/gitlab_stubs/session.json @@ -14,7 +14,5 @@ "provider":null, "is_admin":false, "can_create_group":false, - "can_create_project":false, - "private_token":"Wvjy2Krpb7y8xi93owUz", - "access_token":"Wvjy2Krpb7y8xi93owUz" + "can_create_project":false } diff --git a/spec/support/gitlab_stubs/user.json b/spec/support/gitlab_stubs/user.json index ce8dfe5ae75..658ff5871b0 100644 --- a/spec/support/gitlab_stubs/user.json +++ b/spec/support/gitlab_stubs/user.json @@ -14,7 +14,5 @@ "provider":null, "is_admin":false, "can_create_group":false, - "can_create_project":false, - "private_token":"Wvjy2Krpb7y8xi93owUz", - "access_token":"Wvjy2Krpb7y8xi93owUz" -}
\ No newline at end of file + "can_create_project":false +} diff --git a/spec/support/google_api/cloud_platform_helpers.rb b/spec/support/google_api/cloud_platform_helpers.rb new file mode 100644 index 00000000000..4b785611ab5 --- /dev/null +++ b/spec/support/google_api/cloud_platform_helpers.rb @@ -0,0 +1,155 @@ +module GoogleApi + module CloudPlatformHelpers + def stub_google_api_validate_token + request.session[GoogleApi::CloudPlatform::Client.session_key_for_token] = 'token' + request.session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = 1.hour.since.to_i.to_s + end + + def stub_google_api_expired_token + request.session[GoogleApi::CloudPlatform::Client.session_key_for_token] = 'token' + request.session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = 1.hour.ago.to_i.to_s + end + + def stub_cloud_platform_get_zone_cluster(project_id, zone, cluster_id, **options) + WebMock.stub_request(:get, cloud_platform_get_zone_cluster_url(project_id, zone, cluster_id)) + .to_return(cloud_platform_response(cloud_platform_cluster_body(options))) + end + + def stub_cloud_platform_get_zone_cluster_error(project_id, zone, cluster_id) + WebMock.stub_request(:get, cloud_platform_get_zone_cluster_url(project_id, zone, cluster_id)) + .to_return(status: [500, "Internal Server Error"]) + end + + def stub_cloud_platform_create_cluster(project_id, zone, **options) + WebMock.stub_request(:post, cloud_platform_create_cluster_url(project_id, zone)) + .to_return(cloud_platform_response(cloud_platform_operation_body(options))) + end + + def stub_cloud_platform_create_cluster_error(project_id, zone) + WebMock.stub_request(:post, cloud_platform_create_cluster_url(project_id, zone)) + .to_return(status: [500, "Internal Server Error"]) + end + + def stub_cloud_platform_get_zone_operation(project_id, zone, operation_id, **options) + WebMock.stub_request(:get, cloud_platform_get_zone_operation_url(project_id, zone, operation_id)) + .to_return(cloud_platform_response(cloud_platform_operation_body(options))) + end + + def stub_cloud_platform_get_zone_operation_error(project_id, zone, operation_id) + WebMock.stub_request(:get, cloud_platform_get_zone_operation_url(project_id, zone, operation_id)) + .to_return(status: [500, "Internal Server Error"]) + end + + def cloud_platform_get_zone_cluster_url(project_id, zone, cluster_id) + "https://container.googleapis.com/v1/projects/#{project_id}/zones/#{zone}/clusters/#{cluster_id}" + end + + def cloud_platform_create_cluster_url(project_id, zone) + "https://container.googleapis.com/v1/projects/#{project_id}/zones/#{zone}/clusters" + end + + def cloud_platform_get_zone_operation_url(project_id, zone, operation_id) + "https://container.googleapis.com/v1/projects/#{project_id}/zones/#{zone}/operations/#{operation_id}" + end + + def cloud_platform_response(body) + { status: 200, headers: { 'Content-Type' => 'application/json' }, body: body.to_json } + end + + def load_sample_cert + pem_file = File.expand_path(Rails.root.join('spec/fixtures/clusters/sample_cert.pem')) + Base64.encode64(File.read(pem_file)) + end + + ## + # gcloud container clusters create + # https://cloud.google.com/container-engine/reference/rest/v1/projects.zones.clusters/create + # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/PerceivedComplexity + def cloud_platform_cluster_body(**options) + { + "name": options[:name] || 'string', + "description": options[:description] || 'string', + "initialNodeCount": options[:initialNodeCount] || 'number', + # "nodeConfig": {, + # object(NodeConfig), + # },, + "masterAuth": { + "username": options[:username] || 'string', + "password": options[:password] || 'string', + # "clientCertificateConfig": { + # object(ClientCertificateConfig) + # }, + "clusterCaCertificate": options[:clusterCaCertificate] || load_sample_cert, + "clientCertificate": options[:clientCertificate] || 'string', + "clientKey": options[:clientKey] || 'string' + }, + "loggingService": options[:loggingService] || 'string', + "monitoringService": options[:monitoringService] || 'string', + "network": options[:network] || 'string', + "clusterIpv4Cidr": options[:clusterIpv4Cidr] || 'string', + # "addonsConfig": {, + # object(AddonsConfig), + # },, + "subnetwork": options[:subnetwork] || 'string', + # "nodePools": [, + # {, + # object(NodePool), + # }, + # ],, + # "locations": [, + # string, + # ],, + "enableKubernetesAlpha": options[:enableKubernetesAlpha] || 'boolean', + # "resourceLabels": {, + # string: string,, + # ..., + # },, + "labelFingerprint": options[:labelFingerprint] || 'string', + # "legacyAbac": {, + # object(LegacyAbac), + # }, + # "networkPolicy": {, + # object(NetworkPolicy), + # }, + # "ipAllocationPolicy": {, + # object(IPAllocationPolicy), + # }, + # "masterAuthorizedNetworksConfig": {, + # object(MasterAuthorizedNetworksConfig), + # }, + "selfLink": options[:selfLink] || 'string', + "zone": options[:zone] || 'string', + "endpoint": options[:endpoint] || 'string', + "initialClusterVersion": options[:initialClusterVersion] || 'string', + "currentMasterVersion": options[:currentMasterVersion] || 'string', + "currentNodeVersion": options[:currentNodeVersion] || 'string', + "createTime": options[:createTime] || 'string', + "status": options[:status] || 'RUNNING', + "statusMessage": options[:statusMessage] || 'string', + "nodeIpv4CidrSize": options[:nodeIpv4CidrSize] || 'number', + "servicesIpv4Cidr": options[:servicesIpv4Cidr] || 'string', + # "instanceGroupUrls": [, + # string, + # ],, + "currentNodeCount": options[:currentNodeCount] || 'number', + "expireTime": options[:expireTime] || 'string' + } + end + + def cloud_platform_operation_body(**options) + { + "name": options[:name] || 'operation-1234567891234-1234567', + "zone": options[:zone] || 'us-central1-a', + "operationType": options[:operationType] || 'CREATE_CLUSTER', + "status": options[:status] || 'PENDING', + "detail": options[:detail] || 'detail', + "statusMessage": options[:statusMessage] || '', + "selfLink": options[:selfLink] || 'https://container.googleapis.com/v1/projects/123456789101/zones/us-central1-a/operations/operation-1234567891234-1234567', + "targetLink": options[:targetLink] || 'https://container.googleapis.com/v1/projects/123456789101/zones/us-central1-a/clusters/test-cluster', + "startTime": options[:startTime] || '2017-09-13T16:49:13.055601589Z', + "endTime": options[:endTime] || '' + } + end + end +end diff --git a/spec/support/helpers/merge_request_diff_helpers.rb b/spec/support/helpers/merge_request_diff_helpers.rb index fd22e384b1b..c98aa503ed1 100644 --- a/spec/support/helpers/merge_request_diff_helpers.rb +++ b/spec/support/helpers/merge_request_diff_helpers.rb @@ -2,7 +2,7 @@ module MergeRequestDiffHelpers def click_diff_line(line_holder, diff_side = nil) line = get_line_components(line_holder, diff_side) line[:content].hover - line[:num].find('.add-diff-note').trigger('click') + line[:num].find('.add-diff-note', visible: false).send_keys(:return) end def get_line_components(line_holder, diff_side = nil) diff --git a/spec/support/helpers/note_interaction_helpers.rb b/spec/support/helpers/note_interaction_helpers.rb index 86008698692..79a0aa174b1 100644 --- a/spec/support/helpers/note_interaction_helpers.rb +++ b/spec/support/helpers/note_interaction_helpers.rb @@ -2,7 +2,7 @@ module NoteInteractionHelpers def open_more_actions_dropdown(note) note_element = find("#note_#{note.id}") - note_element.find('.more-actions-toggle').trigger('click') + note_element.find('.more-actions-toggle').click note_element.find('.more-actions .dropdown-menu li', match: :first) end end diff --git a/spec/support/input_helper.rb b/spec/support/input_helper.rb new file mode 100644 index 00000000000..acbb42274ec --- /dev/null +++ b/spec/support/input_helper.rb @@ -0,0 +1,7 @@ +# see app/assets/javascripts/test_utils/simulate_input.js + +module InputHelper + def simulate_input(selector, input = '') + evaluate_script("window.simulateInput(#{selector.to_json}, #{input.to_json});") + end +end diff --git a/spec/support/inspect_requests.rb b/spec/support/inspect_requests.rb new file mode 100644 index 00000000000..88ddc5c7f6c --- /dev/null +++ b/spec/support/inspect_requests.rb @@ -0,0 +1,17 @@ +require_relative './wait_for_requests' + +module InspectRequests + extend self + include WaitForRequests + + def inspect_requests(inject_headers: {}) + Gitlab::Testing::RequestInspectorMiddleware.log_requests!(inject_headers) + + yield + + wait_for_all_requests + Gitlab::Testing::RequestInspectorMiddleware.requests + ensure + Gitlab::Testing::RequestInspectorMiddleware.stop_logging! + end +end diff --git a/spec/support/kubernetes_helpers.rb b/spec/support/kubernetes_helpers.rb index c92f78b324c..e46b61b6461 100644 --- a/spec/support/kubernetes_helpers.rb +++ b/spec/support/kubernetes_helpers.rb @@ -9,22 +9,51 @@ module KubernetesHelpers kube_response(kube_pods_body) end - def stub_kubeclient_discover - WebMock.stub_request(:get, service.api_url + '/api/v1').to_return(kube_response(kube_v1_discovery_body)) + def stub_kubeclient_discover(api_url) + WebMock.stub_request(:get, api_url + '/api/v1').to_return(kube_response(kube_v1_discovery_body)) end def stub_kubeclient_pods(response = nil) - stub_kubeclient_discover + stub_kubeclient_discover(service.api_url) pods_url = service.api_url + "/api/v1/namespaces/#{service.actual_namespace}/pods" WebMock.stub_request(:get, pods_url).to_return(response || kube_pods_response) end + def stub_kubeclient_get_secrets(api_url, **options) + WebMock.stub_request(:get, api_url + '/api/v1/secrets') + .to_return(kube_response(kube_v1_secrets_body(options))) + end + + def stub_kubeclient_get_secrets_error(api_url) + WebMock.stub_request(:get, api_url + '/api/v1/secrets') + .to_return(status: [404, "Internal Server Error"]) + end + + def kube_v1_secrets_body(**options) + { + "kind" => "SecretList", + "apiVersion": "v1", + "items" => [ + { + "metadata": { + "name": options[:metadata_name] || "default-token-1", + "namespace": "kube-system" + }, + "data": { + "token": options[:token] || Base64.encode64('token-sample-123') + } + } + ] + } + end + def kube_v1_discovery_body { "kind" => "APIResourceList", "resources" => [ - { "name" => "pods", "namespaced" => true, "kind" => "Pod" } + { "name" => "pods", "namespaced" => true, "kind" => "Pod" }, + { "name" => "secrets", "namespaced" => true, "kind" => "Secret" } ] } end diff --git a/spec/support/live_debugger.rb b/spec/support/live_debugger.rb new file mode 100644 index 00000000000..911eb48a8ca --- /dev/null +++ b/spec/support/live_debugger.rb @@ -0,0 +1,17 @@ +require 'io/console' + +module LiveDebugger + def live_debug + puts + puts "Current example is paused for live debugging." + puts "Opening #{current_url} in your default browser..." + puts "The current user credentials are: #{@current_user.username} / #{@current_user.password}" if @current_user + puts "Press any key to resume the execution of the example!!" + + `open #{current_url}` + + loop until $stdin.getch + + puts "Back to the example!" + end +end diff --git a/spec/support/login_helpers.rb b/spec/support/login_helpers.rb index 4aed40bf22d..50702a0ac88 100644 --- a/spec/support/login_helpers.rb +++ b/spec/support/login_helpers.rb @@ -3,6 +3,21 @@ require_relative 'devise_helpers' module LoginHelpers include DeviseHelpers + # Overriding Devise::Test::IntegrationHelpers#sign_in to store @current_user + # since we may need it in LiveDebugger#live_debug. + def sign_in(resource, scope: nil) + super + + @current_user = resource + end + + # Overriding Devise::Test::IntegrationHelpers#sign_out to clear @current_user. + def sign_out(resource_or_scope) + super + + @current_user = nil + end + # Internal: Log in as a specific user or a new user of a specific role # # user_or_role - User object, or a role to create (e.g., :admin, :user) @@ -28,7 +43,7 @@ module LoginHelpers gitlab_sign_in_with(user, **kwargs) - user + @current_user = user end def gitlab_sign_in_via(provider, user, uid) @@ -41,6 +56,7 @@ module LoginHelpers def gitlab_sign_out find(".header-user-dropdown-toggle").click click_link "Sign out" + @current_user = nil expect(page).to have_button('Sign in') end diff --git a/spec/support/mobile_helpers.rb b/spec/support/mobile_helpers.rb index 431f20a2a5c..3b9eb84e824 100644 --- a/spec/support/mobile_helpers.rb +++ b/spec/support/mobile_helpers.rb @@ -12,6 +12,6 @@ module MobileHelpers end def resize_window(width, height) - page.driver.resize_window width, height + Capybara.current_session.current_window.resize_to(width, height) end end diff --git a/spec/support/protected_tags/access_control_ce_shared_examples.rb b/spec/support/protected_tags/access_control_ce_shared_examples.rb index 421a51fc336..2770cdcbefc 100644 --- a/spec/support/protected_tags/access_control_ce_shared_examples.rb +++ b/spec/support/protected_tags/access_control_ce_shared_examples.rb @@ -9,7 +9,7 @@ RSpec.shared_examples "protected tags > access control > CE" do allowed_to_create_button = find(".js-allowed-to-create") unless allowed_to_create_button.text == access_type_name - allowed_to_create_button.trigger('click') + allowed_to_create_button.click find('.create_access_levels-container .dropdown-menu li', match: :first) within('.create_access_levels-container .dropdown-menu') { click_on access_type_name } end diff --git a/spec/support/quick_actions_helpers.rb b/spec/support/quick_actions_helpers.rb index d2aaae7518f..361190aa352 100644 --- a/spec/support/quick_actions_helpers.rb +++ b/spec/support/quick_actions_helpers.rb @@ -3,7 +3,7 @@ module QuickActionsHelpers Sidekiq::Testing.fake! do page.within('.js-main-target-form') do fill_in 'note[note]', with: text - find('.js-comment-submit-button').trigger('click') + find('.js-comment-submit-button').click end end end diff --git a/spec/support/shared_examples/features/protected_branches_access_control_ce.rb b/spec/support/shared_examples/features/protected_branches_access_control_ce.rb index d5bc12f3bc5..5fde91512da 100644 --- a/spec/support/shared_examples/features/protected_branches_access_control_ce.rb +++ b/spec/support/shared_examples/features/protected_branches_access_control_ce.rb @@ -9,7 +9,7 @@ shared_examples "protected branches > access control > CE" do allowed_to_push_button = find(".js-allowed-to-push") unless allowed_to_push_button.text == access_type_name - allowed_to_push_button.trigger('click') + allowed_to_push_button.click within(".dropdown.open .dropdown-menu") { click_on access_type_name } end end @@ -34,7 +34,7 @@ shared_examples "protected branches > access control > CE" do within('.js-allowed-to-push-container') do expect(first("li")).to have_content("Roles") - click_on access_type_name + find(:link, access_type_name).click end end @@ -79,7 +79,7 @@ shared_examples "protected branches > access control > CE" do within('.js-allowed-to-merge-container') do expect(first("li")).to have_content("Roles") - click_on access_type_name + find(:link, access_type_name).click end end diff --git a/spec/support/stub_configuration.rb b/spec/support/stub_configuration.rb index 4d448a55978..4ead78529c3 100644 --- a/spec/support/stub_configuration.rb +++ b/spec/support/stub_configuration.rb @@ -38,6 +38,10 @@ module StubConfiguration allow(Gitlab.config.backup).to receive_messages(to_settings(messages)) end + def stub_lfs_setting(messages) + allow(Gitlab.config.lfs).to receive_messages(to_settings(messages)) + end + def stub_storage_settings(messages) # Default storage is always required messages['default'] ||= Gitlab.config.repositories.storages.default diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index a27bfdee3d2..fff120fcb88 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -182,6 +182,8 @@ module TestEnv return unless @gitaly_pid Process.kill('KILL', @gitaly_pid) + rescue Errno::ESRCH + # The process can already be gone if the test run was INTerrupted. end def setup_factory_repo diff --git a/spec/support/time_tracking_shared_examples.rb b/spec/support/time_tracking_shared_examples.rb index 0fa74f911f6..909d4e2ee8d 100644 --- a/spec/support/time_tracking_shared_examples.rb +++ b/spec/support/time_tracking_shared_examples.rb @@ -80,6 +80,6 @@ end def submit_time(quick_action) fill_in 'note[note]', with: quick_action - find('.js-comment-submit-button').trigger('click') + find('.js-comment-submit-button').click wait_for_requests end diff --git a/spec/support/update_invalid_issuable.rb b/spec/support/update_invalid_issuable.rb index 50a1d4a56e2..1490287681b 100644 --- a/spec/support/update_invalid_issuable.rb +++ b/spec/support/update_invalid_issuable.rb @@ -25,13 +25,11 @@ shared_examples 'update invalid issuable' do |klass| .and_raise(ActiveRecord::StaleObjectError.new(issuable, :save)) end - if klass == MergeRequest - it 'renders edit when format is html' do - put :update, params + it 'renders edit when format is html' do + put :update, params - expect(response).to render_template(:edit) - expect(assigns[:conflict]).to be_truthy - end + expect(response).to render_template(:edit) + expect(assigns[:conflict]).to be_truthy end it 'renders json error message when format is json' do @@ -44,17 +42,16 @@ shared_examples 'update invalid issuable' do |klass| end end - if klass == MergeRequest - context 'when updating an invalid issuable' do - before do - params[:merge_request][:title] = "" - end + context 'when updating an invalid issuable' do + before do + key = klass == Issue ? :issue : :merge_request + params[key][:title] = "" + end - it 'renders edit when merge request is invalid' do - put :update, params + it 'renders edit when merge request is invalid' do + put :update, params - expect(response).to render_template(:edit) - end + expect(response).to render_template(:edit) end end end diff --git a/spec/support/wait_for_requests.rb b/spec/support/wait_for_requests.rb index b5c3c0f55b8..f4130d68271 100644 --- a/spec/support/wait_for_requests.rb +++ b/spec/support/wait_for_requests.rb @@ -1,25 +1,47 @@ -require_relative './wait_for_requests' - module WaitForRequests extend self # This is inspired by http://www.salsify.com/blog/engineering/tearing-capybara-ajax-tests def block_and_wait_for_requests_complete + block_requests { wait_for_all_requests } + end + + # Block all requests inside block with 503 response + def block_requests Gitlab::Testing::RequestBlockerMiddleware.block_requests! - wait_for('pending requests complete') do - Gitlab::Testing::RequestBlockerMiddleware.num_active_requests.zero? && finished_all_requests? - end + yield ensure Gitlab::Testing::RequestBlockerMiddleware.allow_requests! end + # Slow down requests inside block by injecting `sleep 0.2` before each response + def slow_requests + Gitlab::Testing::RequestBlockerMiddleware.slow_requests! + yield + ensure + Gitlab::Testing::RequestBlockerMiddleware.allow_requests! + end + + # Wait for client-side AJAX requests def wait_for_requests - wait_for('JS requests') { finished_all_requests? } + wait_for('JS requests complete') { finished_all_js_requests? } + end + + # Wait for active Rack requests and client-side AJAX requests + def wait_for_all_requests + wait_for('pending requests complete') do + finished_all_rack_reqiests? && + finished_all_js_requests? + end end private - def finished_all_requests? + def finished_all_rack_reqiests? + Gitlab::Testing::RequestBlockerMiddleware.num_active_requests.zero? + end + + def finished_all_js_requests? return true unless javascript_test? finished_all_ajax_requests? && diff --git a/spec/tasks/gitlab/users_rake_spec.rb b/spec/tasks/gitlab/users_rake_spec.rb deleted file mode 100644 index 972670e7f91..00000000000 --- a/spec/tasks/gitlab/users_rake_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -require 'spec_helper' -require 'rake' - -describe 'gitlab:users namespace rake task' do - let(:enable_registry) { true } - - before :all do - Rake.application.rake_require 'tasks/gitlab/helpers' - Rake.application.rake_require 'tasks/gitlab/users' - - # empty task as env is already loaded - Rake::Task.define_task :environment - end - - def run_rake_task(task_name) - Rake::Task[task_name].reenable - Rake.application.invoke_task task_name - end - - describe 'clear_all_authentication_tokens' do - before do - # avoid writing task output to spec progress - allow($stdout).to receive :write - end - - context 'gitlab version' do - it 'clears the authentication token for all users' do - create_list(:user, 2) - - expect(User.pluck(:authentication_token)).to all(be_present) - - run_rake_task('gitlab:users:clear_all_authentication_tokens') - - expect(User.pluck(:authentication_token)).to all(be_nil) - end - end - end -end diff --git a/spec/tasks/tokens_spec.rb b/spec/tasks/tokens_spec.rb index b84137eb365..51f7a536cbb 100644 --- a/spec/tasks/tokens_spec.rb +++ b/spec/tasks/tokens_spec.rb @@ -7,12 +7,6 @@ describe 'tokens rake tasks' do Rake.application.rake_require 'tasks/tokens' end - describe 'reset_all task' do - it 'invokes create_hooks task' do - expect { run_rake_task('tokens:reset_all_auth') }.to change { user.reload.authentication_token } - end - end - describe 'reset_all_email task' do it 'invokes create_hooks task' do expect { run_rake_task('tokens:reset_all_email') }.to change { user.reload.incoming_email_token } diff --git a/spec/uploaders/file_uploader_spec.rb b/spec/uploaders/file_uploader_spec.rb index 2492d56a5cf..f52b2bab05b 100644 --- a/spec/uploaders/file_uploader_spec.rb +++ b/spec/uploaders/file_uploader_spec.rb @@ -3,25 +3,51 @@ require 'spec_helper' describe FileUploader do let(:uploader) { described_class.new(build_stubbed(:project)) } - describe '.absolute_path' do - it 'returns the correct absolute path by building it dynamically' do - project = build_stubbed(:project) - upload = double(model: project, path: 'secret/foo.jpg') + context 'legacy storage' do + let(:project) { build_stubbed(:project) } - dynamic_segment = project.path_with_namespace + describe '.absolute_path' do + it 'returns the correct absolute path by building it dynamically' do + upload = double(model: project, path: 'secret/foo.jpg') - expect(described_class.absolute_path(upload)) - .to end_with("#{dynamic_segment}/secret/foo.jpg") + dynamic_segment = project.full_path + + expect(described_class.absolute_path(upload)) + .to end_with("#{dynamic_segment}/secret/foo.jpg") + end + end + + describe "#store_dir" do + it "stores in the namespace path" do + uploader = described_class.new(project) + + expect(uploader.store_dir).to include(project.full_path) + expect(uploader.store_dir).not_to include("system") + end end end - describe "#store_dir" do - it "stores in the namespace path" do - project = build_stubbed(:project) - uploader = described_class.new(project) + context 'hashed storage' do + let(:project) { build_stubbed(:project, :hashed) } + + describe '.absolute_path' do + it 'returns the correct absolute path by building it dynamically' do + upload = double(model: project, path: 'secret/foo.jpg') + + dynamic_segment = project.disk_path + + expect(described_class.absolute_path(upload)) + .to end_with("#{dynamic_segment}/secret/foo.jpg") + end + end + + describe "#store_dir" do + it "stores in the namespace path" do + uploader = described_class.new(project) - expect(uploader.store_dir).to include(project.path_with_namespace) - expect(uploader.store_dir).not_to include("system") + expect(uploader.store_dir).to include(project.disk_path) + expect(uploader.store_dir).not_to include("system") + end end end diff --git a/spec/views/shared/issuable/_participants.html.haml.rb b/spec/views/shared/issuable/_participants.html.haml.rb deleted file mode 100644 index 51059d4c0d7..00000000000 --- a/spec/views/shared/issuable/_participants.html.haml.rb +++ /dev/null @@ -1,26 +0,0 @@ -require 'spec_helper' -require 'nokogiri' - -describe 'shared/issuable/_participants.html.haml' do - let(:project) { create(:project) } - let(:participants) { create_list(:user, 100) } - - before do - allow(view).to receive_messages(project: project, - participants: participants) - end - - it 'renders lazy loaded avatars' do - render 'shared/issuable/participants' - - html = Nokogiri::HTML(rendered) - - avatars = html.css('.participants-author img') - - avatars.each do |avatar| - expect(avatar[:class]).to include('lazy') - expect(avatar[:src]).to eql(LazyImageTagHelper.placeholder_image) - expect(avatar[:"data-src"]).to match('http://www.gravatar.com/avatar/') - end - end -end diff --git a/spec/workers/cluster_provision_worker_spec.rb b/spec/workers/cluster_provision_worker_spec.rb index 11f208289db..85c7dc20ede 100644 --- a/spec/workers/cluster_provision_worker_spec.rb +++ b/spec/workers/cluster_provision_worker_spec.rb @@ -2,11 +2,22 @@ require 'spec_helper' describe ClusterProvisionWorker do describe '#perform' do - context 'when cluster exists' do - let(:cluster) { create(:gcp_cluster) } + context 'when provider type is gcp' do + let(:cluster) { create(:cluster, provider_type: :gcp, provider_gcp: provider) } + let(:provider) { create(:provider_gcp, :scheduled) } it 'provision a cluster' do - expect_any_instance_of(Ci::ProvisionClusterService).to receive(:execute) + expect_any_instance_of(Clusters::Gcp::ProvisionService).to receive(:execute) + + described_class.new.perform(cluster.id) + end + end + + context 'when provider type is user' do + let(:cluster) { create(:cluster, provider_type: :user) } + + it 'does not provision a cluster' do + expect_any_instance_of(Clusters::Gcp::ProvisionService).not_to receive(:execute) described_class.new.perform(cluster.id) end @@ -14,7 +25,7 @@ describe ClusterProvisionWorker do context 'when cluster does not exist' do it 'does not provision a cluster' do - expect_any_instance_of(Ci::ProvisionClusterService).not_to receive(:execute) + expect_any_instance_of(Clusters::Gcp::ProvisionService).not_to receive(:execute) described_class.new.perform(123) end diff --git a/spec/workers/stuck_merge_jobs_worker_spec.rb b/spec/workers/stuck_merge_jobs_worker_spec.rb index a5ad78393c9..f8b55e873df 100644 --- a/spec/workers/stuck_merge_jobs_worker_spec.rb +++ b/spec/workers/stuck_merge_jobs_worker_spec.rb @@ -12,8 +12,13 @@ describe StuckMergeJobsWorker do worker.perform - expect(mr_with_sha.reload).to be_merged - expect(mr_without_sha.reload).to be_opened + mr_with_sha.reload + mr_without_sha.reload + + expect(mr_with_sha).to be_merged + expect(mr_without_sha).to be_opened + expect(mr_with_sha.merge_jid).to be_present + expect(mr_without_sha.merge_jid).to be_nil end it 'updates merge request to opened when locked but has not been merged' do diff --git a/spec/workers/wait_for_cluster_creation_worker_spec.rb b/spec/workers/wait_for_cluster_creation_worker_spec.rb index dcd4a3b9aec..29812408396 100644 --- a/spec/workers/wait_for_cluster_creation_worker_spec.rb +++ b/spec/workers/wait_for_cluster_creation_worker_spec.rb @@ -2,65 +2,32 @@ require 'spec_helper' describe WaitForClusterCreationWorker do describe '#perform' do - context 'when cluster exists' do - let(:cluster) { create(:gcp_cluster) } - let(:operation) { double } + context 'when provider type is gcp' do + let(:cluster) { create(:cluster, provider_type: :gcp, provider_gcp: provider) } + let(:provider) { create(:provider_gcp, :creating) } - before do - allow(operation).to receive(:status).and_return(status) - allow(operation).to receive(:start_time).and_return(1.minute.ago) - allow(operation).to receive(:status_message).and_return('error') - allow_any_instance_of(Ci::FetchGcpOperationService).to receive(:execute).and_yield(operation) - end - - context 'when operation status is RUNNING' do - let(:status) { 'RUNNING' } - - it 'reschedules worker' do - expect(described_class).to receive(:perform_in) - - described_class.new.perform(cluster.id) - end - - context 'when operation timeout' do - before do - allow(operation).to receive(:start_time).and_return(30.minutes.ago.utc) - end - - it 'sets an error message on cluster' do - described_class.new.perform(cluster.id) + it 'provision a cluster' do + expect_any_instance_of(Clusters::Gcp::VerifyProvisionStatusService).to receive(:execute) - expect(cluster.reload).to be_errored - end - end - end - - context 'when operation status is DONE' do - let(:status) { 'DONE' } - - it 'finalizes cluster creation' do - expect_any_instance_of(Ci::FinalizeClusterCreationService).to receive(:execute) - - described_class.new.perform(cluster.id) - end + described_class.new.perform(cluster.id) end + end - context 'when operation status is others' do - let(:status) { 'others' } + context 'when provider type is user' do + let(:cluster) { create(:cluster, provider_type: :user) } - it 'sets an error message on cluster' do - described_class.new.perform(cluster.id) + it 'does not provision a cluster' do + expect_any_instance_of(Clusters::Gcp::VerifyProvisionStatusService).not_to receive(:execute) - expect(cluster.reload).to be_errored - end + described_class.new.perform(cluster.id) end end context 'when cluster does not exist' do it 'does not provision a cluster' do - expect_any_instance_of(Ci::FetchGcpOperationService).not_to receive(:execute) + expect_any_instance_of(Clusters::Gcp::VerifyProvisionStatusService).not_to receive(:execute) - described_class.new.perform(1234) + described_class.new.perform(123) end end end diff --git a/vendor/assets/javascripts/autosize.js b/vendor/assets/javascripts/autosize.js deleted file mode 100644 index cfa49e72c50..00000000000 --- a/vendor/assets/javascripts/autosize.js +++ /dev/null @@ -1,243 +0,0 @@ -/*! - Autosize 3.0.14 - license: MIT - http://www.jacklmoore.com/autosize -*/ -(function (global, factory) { - if (typeof define === 'function' && define.amd) { - define(['exports', 'module'], factory); - } else if (typeof exports !== 'undefined' && typeof module !== 'undefined') { - factory(exports, module); - } else { - var mod = { - exports: {} - }; - factory(mod.exports, mod); - global.autosize = mod.exports; - } -})(this, function (exports, module) { - 'use strict'; - - var set = typeof Set === 'function' ? new Set() : (function () { - var list = []; - - return { - has: function has(key) { - return Boolean(list.indexOf(key) > -1); - }, - add: function add(key) { - list.push(key); - }, - 'delete': function _delete(key) { - list.splice(list.indexOf(key), 1); - } }; - })(); - - function assign(ta) { - var _ref = arguments[1] === undefined ? {} : arguments[1]; - - var _ref$setOverflowX = _ref.setOverflowX; - var setOverflowX = _ref$setOverflowX === undefined ? true : _ref$setOverflowX; - var _ref$setOverflowY = _ref.setOverflowY; - var setOverflowY = _ref$setOverflowY === undefined ? true : _ref$setOverflowY; - - if (!ta || !ta.nodeName || ta.nodeName !== 'TEXTAREA' || set.has(ta)) return; - - var heightOffset = null; - var overflowY = null; - var clientWidth = ta.clientWidth; - - function init() { - var style = window.getComputedStyle(ta, null); - - overflowY = style.overflowY; - - if (style.resize === 'vertical') { - ta.style.resize = 'none'; - } else if (style.resize === 'both') { - ta.style.resize = 'horizontal'; - } - - if (style.boxSizing === 'content-box') { - heightOffset = -(parseFloat(style.paddingTop) + parseFloat(style.paddingBottom)); - } else { - heightOffset = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth); - } - // Fix when a textarea is not on document body and heightOffset is Not a Number - if (isNaN(heightOffset)) { - heightOffset = 0; - } - - update(); - } - - function changeOverflow(value) { - { - // Chrome/Safari-specific fix: - // When the textarea y-overflow is hidden, Chrome/Safari do not reflow the text to account for the space - // made available by removing the scrollbar. The following forces the necessary text reflow. - var width = ta.style.width; - ta.style.width = '0px'; - // Force reflow: - /* jshint ignore:start */ - ta.offsetWidth; - /* jshint ignore:end */ - ta.style.width = width; - } - - overflowY = value; - - if (setOverflowY) { - ta.style.overflowY = value; - } - - resize(); - } - - function resize() { - var htmlTop = window.pageYOffset; - var bodyTop = document.body.scrollTop; - var originalHeight = ta.style.height; - - ta.style.height = 'auto'; - - var endHeight = ta.scrollHeight + heightOffset; - - if (ta.scrollHeight === 0) { - // If the scrollHeight is 0, then the element probably has display:none or is detached from the DOM. - ta.style.height = originalHeight; - return; - } - - ta.style.height = endHeight + 'px'; - - // used to check if an update is actually necessary on window.resize - clientWidth = ta.clientWidth; - - // prevents scroll-position jumping - document.documentElement.scrollTop = htmlTop; - document.body.scrollTop = bodyTop; - } - - function update() { - var startHeight = ta.style.height; - - resize(); - - var style = window.getComputedStyle(ta, null); - - if (style.height !== ta.style.height) { - if (overflowY !== 'visible') { - changeOverflow('visible'); - } - } else { - if (overflowY !== 'hidden') { - changeOverflow('hidden'); - } - } - - if (startHeight !== ta.style.height) { - var evt = document.createEvent('Event'); - evt.initEvent('autosize:resized', true, false); - ta.dispatchEvent(evt); - } - } - - var pageResize = function pageResize() { - if (ta.clientWidth !== clientWidth) { - update(); - } - }; - - var destroy = (function (style) { - window.removeEventListener('resize', pageResize, false); - ta.removeEventListener('input', update, false); - ta.removeEventListener('keyup', update, false); - ta.removeEventListener('autosize:destroy', destroy, false); - ta.removeEventListener('autosize:update', update, false); - set['delete'](ta); - - Object.keys(style).forEach(function (key) { - ta.style[key] = style[key]; - }); - }).bind(ta, { - height: ta.style.height, - resize: ta.style.resize, - overflowY: ta.style.overflowY, - overflowX: ta.style.overflowX, - wordWrap: ta.style.wordWrap }); - - ta.addEventListener('autosize:destroy', destroy, false); - - // IE9 does not fire onpropertychange or oninput for deletions, - // so binding to onkeyup to catch most of those events. - // There is no way that I know of to detect something like 'cut' in IE9. - if ('onpropertychange' in ta && 'oninput' in ta) { - ta.addEventListener('keyup', update, false); - } - - window.addEventListener('resize', pageResize, false); - ta.addEventListener('input', update, false); - ta.addEventListener('autosize:update', update, false); - set.add(ta); - - if (setOverflowX) { - ta.style.overflowX = 'hidden'; - ta.style.wordWrap = 'break-word'; - } - - init(); - } - - function destroy(ta) { - if (!(ta && ta.nodeName && ta.nodeName === 'TEXTAREA')) return; - var evt = document.createEvent('Event'); - evt.initEvent('autosize:destroy', true, false); - ta.dispatchEvent(evt); - } - - function update(ta) { - if (!(ta && ta.nodeName && ta.nodeName === 'TEXTAREA')) return; - var evt = document.createEvent('Event'); - evt.initEvent('autosize:update', true, false); - ta.dispatchEvent(evt); - } - - var autosize = null; - - // Do nothing in Node.js environment and IE8 (or lower) - if (typeof window === 'undefined' || typeof window.getComputedStyle !== 'function') { - autosize = function (el) { - return el; - }; - autosize.destroy = function (el) { - return el; - }; - autosize.update = function (el) { - return el; - }; - } else { - autosize = function (el, options) { - if (el) { - Array.prototype.forEach.call(el.length ? el : [el], function (x) { - return assign(x, options); - }); - } - return el; - }; - autosize.destroy = function (el) { - if (el) { - Array.prototype.forEach.call(el.length ? el : [el], destroy); - } - return el; - }; - autosize.update = function (el) { - if (el) { - Array.prototype.forEach.call(el.length ? el : [el], update); - } - return el; - }; - } - - module.exports = autosize; -});
\ No newline at end of file diff --git a/vendor/assets/javascripts/fuzzaldrin-plus.js b/vendor/assets/javascripts/fuzzaldrin-plus.js deleted file mode 100644 index 1985e3f8f6c..00000000000 --- a/vendor/assets/javascripts/fuzzaldrin-plus.js +++ /dev/null @@ -1,1161 +0,0 @@ -/*! - * fuzzaldrin-plus.js - 0.3.1 - * https://github.com/jeancroy/fuzzaldrin-plus - * - * Copyright 2016 - Jean Christophe Roy - * Released under the MIT license - * https://github.com/jeancroy/fuzzaldrin-plus/raw/master/LICENSE.md - */ -(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ -fuzzaldrinPlus = require('fuzzaldrin-plus'); - -},{"fuzzaldrin-plus":3}],2:[function(require,module,exports){ -(function() { - var PathSeparator, legacy_scorer, pluckCandidates, scorer, sortCandidates; - - scorer = require('./scorer'); - - legacy_scorer = require('./legacy'); - - pluckCandidates = function(a) { - return a.candidate; - }; - - sortCandidates = function(a, b) { - return b.score - a.score; - }; - - PathSeparator = require('path').sep; - - module.exports = function(candidates, query, _arg) { - var allowErrors, bAllowErrors, bKey, candidate, coreQuery, key, legacy, maxInners, maxResults, prepQuery, queryHasSlashes, score, scoredCandidates, spotLeft, string, _i, _j, _len, _len1, _ref; - _ref = _arg != null ? _arg : {}, key = _ref.key, maxResults = _ref.maxResults, maxInners = _ref.maxInners, allowErrors = _ref.allowErrors, legacy = _ref.legacy; - scoredCandidates = []; - spotLeft = (maxInners != null) && maxInners > 0 ? maxInners : candidates.length; - bAllowErrors = !!allowErrors; - bKey = key != null; - prepQuery = scorer.prepQuery(query); - if (!legacy) { - for (_i = 0, _len = candidates.length; _i < _len; _i++) { - candidate = candidates[_i]; - string = bKey ? candidate[key] : candidate; - if (!string) { - continue; - } - score = scorer.score(string, query, prepQuery, bAllowErrors); - if (score > 0) { - scoredCandidates.push({ - candidate: candidate, - score: score - }); - if (!--spotLeft) { - break; - } - } - } - } else { - queryHasSlashes = prepQuery.depth > 0; - coreQuery = prepQuery.core; - for (_j = 0, _len1 = candidates.length; _j < _len1; _j++) { - candidate = candidates[_j]; - string = key != null ? candidate[key] : candidate; - if (!string) { - continue; - } - score = legacy_scorer.score(string, coreQuery, queryHasSlashes); - if (!queryHasSlashes) { - score = legacy_scorer.basenameScore(string, coreQuery, score); - } - if (score > 0) { - scoredCandidates.push({ - candidate: candidate, - score: score - }); - } - } - } - scoredCandidates.sort(sortCandidates); - candidates = scoredCandidates.map(pluckCandidates); - if (maxResults != null) { - candidates = candidates.slice(0, maxResults); - } - return candidates; - }; - -}).call(this); - -},{"./legacy":4,"./scorer":6,"path":7}],3:[function(require,module,exports){ -(function() { - var PathSeparator, filter, legacy_scorer, matcher, prepQueryCache, scorer; - - scorer = require('./scorer'); - - legacy_scorer = require('./legacy'); - - filter = require('./filter'); - - matcher = require('./matcher'); - - PathSeparator = require('path').sep; - - prepQueryCache = null; - - module.exports = { - filter: function(candidates, query, options) { - if (!((query != null ? query.length : void 0) && (candidates != null ? candidates.length : void 0))) { - return []; - } - return filter(candidates, query, options); - }, - prepQuery: function(query) { - return scorer.prepQuery(query); - }, - score: function(string, query, prepQuery, _arg) { - var allowErrors, coreQuery, legacy, queryHasSlashes, score, _ref; - _ref = _arg != null ? _arg : {}, allowErrors = _ref.allowErrors, legacy = _ref.legacy; - if (!((string != null ? string.length : void 0) && (query != null ? query.length : void 0))) { - return 0; - } - if (prepQuery == null) { - prepQuery = prepQueryCache && prepQueryCache.query === query ? prepQueryCache : (prepQueryCache = scorer.prepQuery(query)); - } - if (!legacy) { - score = scorer.score(string, query, prepQuery, !!allowErrors); - } else { - queryHasSlashes = prepQuery.depth > 0; - coreQuery = prepQuery.core; - score = legacy_scorer.score(string, coreQuery, queryHasSlashes); - if (!queryHasSlashes) { - score = legacy_scorer.basenameScore(string, coreQuery, score); - } - } - return score; - }, - match: function(string, query, prepQuery, _arg) { - var allowErrors, baseMatches, matches, query_lw, string_lw, _i, _ref, _results; - allowErrors = (_arg != null ? _arg : {}).allowErrors; - if (!string) { - return []; - } - if (!query) { - return []; - } - if (string === query) { - return (function() { - _results = []; - for (var _i = 0, _ref = string.length; 0 <= _ref ? _i < _ref : _i > _ref; 0 <= _ref ? _i++ : _i--){ _results.push(_i); } - return _results; - }).apply(this); - } - if (prepQuery == null) { - prepQuery = prepQueryCache && prepQueryCache.query === query ? prepQueryCache : (prepQueryCache = scorer.prepQuery(query)); - } - if (!(allowErrors || scorer.isMatch(string, prepQuery.core_lw, prepQuery.core_up))) { - return []; - } - string_lw = string.toLowerCase(); - query_lw = prepQuery.query_lw; - matches = matcher.match(string, string_lw, prepQuery); - if (matches.length === 0) { - return matches; - } - if (string.indexOf(PathSeparator) > -1) { - baseMatches = matcher.basenameMatch(string, string_lw, prepQuery); - matches = matcher.mergeMatches(matches, baseMatches); - } - return matches; - } - }; - -}).call(this); - -},{"./filter":2,"./legacy":4,"./matcher":5,"./scorer":6,"path":7}],4:[function(require,module,exports){ -(function() { - var PathSeparator, queryIsLastPathSegment; - - PathSeparator = require('path').sep; - - exports.basenameScore = function(string, query, score) { - var base, depth, index, lastCharacter, segmentCount, slashCount; - index = string.length - 1; - while (string[index] === PathSeparator) { - index--; - } - slashCount = 0; - lastCharacter = index; - base = null; - while (index >= 0) { - if (string[index] === PathSeparator) { - slashCount++; - if (base == null) { - base = string.substring(index + 1, lastCharacter + 1); - } - } else if (index === 0) { - if (lastCharacter < string.length - 1) { - if (base == null) { - base = string.substring(0, lastCharacter + 1); - } - } else { - if (base == null) { - base = string; - } - } - } - index--; - } - if (base === string) { - score *= 2; - } else if (base) { - score += exports.score(base, query); - } - segmentCount = slashCount + 1; - depth = Math.max(1, 10 - segmentCount); - score *= depth * 0.01; - return score; - }; - - exports.score = function(string, query) { - var character, characterScore, indexInQuery, indexInString, lowerCaseIndex, minIndex, queryLength, queryScore, stringLength, totalCharacterScore, upperCaseIndex, _ref; - if (string === query) { - return 1; - } - if (queryIsLastPathSegment(string, query)) { - return 1; - } - totalCharacterScore = 0; - queryLength = query.length; - stringLength = string.length; - indexInQuery = 0; - indexInString = 0; - while (indexInQuery < queryLength) { - character = query[indexInQuery++]; - lowerCaseIndex = string.indexOf(character.toLowerCase()); - upperCaseIndex = string.indexOf(character.toUpperCase()); - minIndex = Math.min(lowerCaseIndex, upperCaseIndex); - if (minIndex === -1) { - minIndex = Math.max(lowerCaseIndex, upperCaseIndex); - } - indexInString = minIndex; - if (indexInString === -1) { - return 0; - } - characterScore = 0.1; - if (string[indexInString] === character) { - characterScore += 0.1; - } - if (indexInString === 0 || string[indexInString - 1] === PathSeparator) { - characterScore += 0.8; - } else if ((_ref = string[indexInString - 1]) === '-' || _ref === '_' || _ref === ' ') { - characterScore += 0.7; - } - string = string.substring(indexInString + 1, stringLength); - totalCharacterScore += characterScore; - } - queryScore = totalCharacterScore / queryLength; - return ((queryScore * (queryLength / stringLength)) + queryScore) / 2; - }; - - queryIsLastPathSegment = function(string, query) { - if (string[string.length - query.length - 1] === PathSeparator) { - return string.lastIndexOf(query) === string.length - query.length; - } - }; - - exports.match = function(string, query, stringOffset) { - var character, indexInQuery, indexInString, lowerCaseIndex, matches, minIndex, queryLength, stringLength, upperCaseIndex, _i, _ref, _results; - if (stringOffset == null) { - stringOffset = 0; - } - if (string === query) { - return (function() { - _results = []; - for (var _i = stringOffset, _ref = stringOffset + string.length; stringOffset <= _ref ? _i < _ref : _i > _ref; stringOffset <= _ref ? _i++ : _i--){ _results.push(_i); } - return _results; - }).apply(this); - } - queryLength = query.length; - stringLength = string.length; - indexInQuery = 0; - indexInString = 0; - matches = []; - while (indexInQuery < queryLength) { - character = query[indexInQuery++]; - lowerCaseIndex = string.indexOf(character.toLowerCase()); - upperCaseIndex = string.indexOf(character.toUpperCase()); - minIndex = Math.min(lowerCaseIndex, upperCaseIndex); - if (minIndex === -1) { - minIndex = Math.max(lowerCaseIndex, upperCaseIndex); - } - indexInString = minIndex; - if (indexInString === -1) { - return []; - } - matches.push(stringOffset + indexInString); - stringOffset += indexInString + 1; - string = string.substring(indexInString + 1, stringLength); - } - return matches; - }; - -}).call(this); - -},{"path":7}],5:[function(require,module,exports){ -(function() { - var PathSeparator, scorer; - - PathSeparator = require('path').sep; - - scorer = require('./scorer'); - - exports.basenameMatch = function(subject, subject_lw, prepQuery) { - var basePos, depth, end; - end = subject.length - 1; - while (subject[end] === PathSeparator) { - end--; - } - basePos = subject.lastIndexOf(PathSeparator, end); - if (basePos === -1) { - return []; - } - depth = prepQuery.depth; - while (depth-- > 0) { - basePos = subject.lastIndexOf(PathSeparator, basePos - 1); - if (basePos === -1) { - return []; - } - } - basePos++; - end++; - return exports.match(subject.slice(basePos, end), subject_lw.slice(basePos, end), prepQuery, basePos); - }; - - exports.mergeMatches = function(a, b) { - var ai, bj, i, j, m, n, out; - m = a.length; - n = b.length; - if (n === 0) { - return a.slice(); - } - if (m === 0) { - return b.slice(); - } - i = -1; - j = 0; - bj = b[j]; - out = []; - while (++i < m) { - ai = a[i]; - while (bj <= ai && ++j < n) { - if (bj < ai) { - out.push(bj); - } - bj = b[j]; - } - out.push(ai); - } - while (j < n) { - out.push(b[j++]); - } - return out; - }; - - exports.match = function(subject, subject_lw, prepQuery, offset) { - var DIAGONAL, LEFT, STOP, UP, acro_score, align, backtrack, csc_diag, csc_row, csc_score, i, j, m, matches, move, n, pos, query, query_lw, score, score_diag, score_row, score_up, si_lw, start, trace; - if (offset == null) { - offset = 0; - } - query = prepQuery.query; - query_lw = prepQuery.query_lw; - m = subject.length; - n = query.length; - acro_score = scorer.scoreAcronyms(subject, subject_lw, query, query_lw).score; - score_row = new Array(n); - csc_row = new Array(n); - STOP = 0; - UP = 1; - LEFT = 2; - DIAGONAL = 3; - trace = new Array(m * n); - pos = -1; - j = -1; - while (++j < n) { - score_row[j] = 0; - csc_row[j] = 0; - } - i = -1; - while (++i < m) { - score = 0; - score_up = 0; - csc_diag = 0; - si_lw = subject_lw[i]; - j = -1; - while (++j < n) { - csc_score = 0; - align = 0; - score_diag = score_up; - if (query_lw[j] === si_lw) { - start = scorer.isWordStart(i, subject, subject_lw); - csc_score = csc_diag > 0 ? csc_diag : scorer.scoreConsecutives(subject, subject_lw, query, query_lw, i, j, start); - align = score_diag + scorer.scoreCharacter(i, j, start, acro_score, csc_score); - } - score_up = score_row[j]; - csc_diag = csc_row[j]; - if (score > score_up) { - move = LEFT; - } else { - score = score_up; - move = UP; - } - if (align > score) { - score = align; - move = DIAGONAL; - } else { - csc_score = 0; - } - score_row[j] = score; - csc_row[j] = csc_score; - trace[++pos] = score > 0 ? move : STOP; - } - } - i = m - 1; - j = n - 1; - pos = i * n + j; - backtrack = true; - matches = []; - while (backtrack && i >= 0 && j >= 0) { - switch (trace[pos]) { - case UP: - i--; - pos -= n; - break; - case LEFT: - j--; - pos--; - break; - case DIAGONAL: - matches.push(i + offset); - j--; - i--; - pos -= n + 1; - break; - default: - backtrack = false; - } - } - matches.reverse(); - return matches; - }; - -}).call(this); - -},{"./scorer":6,"path":7}],6:[function(require,module,exports){ -(function() { - var AcronymResult, PathSeparator, Query, basenameScore, coreChars, countDir, doScore, emptyAcronymResult, file_coeff, isMatch, isSeparator, isWordEnd, isWordStart, miss_coeff, opt_char_re, pos_bonus, scoreAcronyms, scoreCharacter, scoreConsecutives, scoreExact, scoreExactMatch, scorePattern, scorePosition, scoreSize, tau_depth, tau_size, truncatedUpperCase, wm; - - PathSeparator = require('path').sep; - - wm = 150; - - pos_bonus = 20; - - tau_depth = 13; - - tau_size = 85; - - file_coeff = 1.2; - - miss_coeff = 0.75; - - opt_char_re = /[ _\-:\/\\]/g; - - exports.coreChars = coreChars = function(query) { - return query.replace(opt_char_re, ''); - }; - - exports.score = function(string, query, prepQuery, allowErrors) { - var score, string_lw; - if (prepQuery == null) { - prepQuery = new Query(query); - } - if (allowErrors == null) { - allowErrors = false; - } - if (!(allowErrors || isMatch(string, prepQuery.core_lw, prepQuery.core_up))) { - return 0; - } - string_lw = string.toLowerCase(); - score = doScore(string, string_lw, prepQuery); - return Math.ceil(basenameScore(string, string_lw, prepQuery, score)); - }; - - Query = (function() { - function Query(query) { - if (!(query != null ? query.length : void 0)) { - return null; - } - this.query = query; - this.query_lw = query.toLowerCase(); - this.core = coreChars(query); - this.core_lw = this.core.toLowerCase(); - this.core_up = truncatedUpperCase(this.core); - this.depth = countDir(query, query.length); - } - - return Query; - - })(); - - exports.prepQuery = function(query) { - return new Query(query); - }; - - exports.isMatch = isMatch = function(subject, query_lw, query_up) { - var i, j, m, n, qj_lw, qj_up, si; - m = subject.length; - n = query_lw.length; - if (!m || n > m) { - return false; - } - i = -1; - j = -1; - while (++j < n) { - qj_lw = query_lw[j]; - qj_up = query_up[j]; - while (++i < m) { - si = subject[i]; - if (si === qj_lw || si === qj_up) { - break; - } - } - if (i === m) { - return false; - } - } - return true; - }; - - doScore = function(subject, subject_lw, prepQuery) { - var acro, acro_score, align, csc_diag, csc_row, csc_score, i, j, m, miss_budget, miss_left, mm, n, pos, query, query_lw, record_miss, score, score_diag, score_row, score_up, si_lw, start, sz; - query = prepQuery.query; - query_lw = prepQuery.query_lw; - m = subject.length; - n = query.length; - acro = scoreAcronyms(subject, subject_lw, query, query_lw); - acro_score = acro.score; - if (acro.count === n) { - return scoreExact(n, m, acro_score, acro.pos); - } - pos = subject_lw.indexOf(query_lw); - if (pos > -1) { - return scoreExactMatch(subject, subject_lw, query, query_lw, pos, n, m); - } - score_row = new Array(n); - csc_row = new Array(n); - sz = scoreSize(n, m); - miss_budget = Math.ceil(miss_coeff * n) + 5; - miss_left = miss_budget; - j = -1; - while (++j < n) { - score_row[j] = 0; - csc_row[j] = 0; - } - i = subject_lw.indexOf(query_lw[0]); - if (i > -1) { - i--; - } - mm = subject_lw.lastIndexOf(query_lw[n - 1], m); - if (mm > i) { - m = mm + 1; - } - while (++i < m) { - score = 0; - score_diag = 0; - csc_diag = 0; - si_lw = subject_lw[i]; - record_miss = true; - j = -1; - while (++j < n) { - score_up = score_row[j]; - if (score_up > score) { - score = score_up; - } - csc_score = 0; - if (query_lw[j] === si_lw) { - start = isWordStart(i, subject, subject_lw); - csc_score = csc_diag > 0 ? csc_diag : scoreConsecutives(subject, subject_lw, query, query_lw, i, j, start); - align = score_diag + scoreCharacter(i, j, start, acro_score, csc_score); - if (align > score) { - score = align; - miss_left = miss_budget; - } else { - if (record_miss && --miss_left <= 0) { - return score_row[n - 1] * sz; - } - record_miss = false; - } - } - score_diag = score_up; - csc_diag = csc_row[j]; - csc_row[j] = csc_score; - score_row[j] = score; - } - } - return score * sz; - }; - - exports.isWordStart = isWordStart = function(pos, subject, subject_lw) { - var curr_s, prev_s; - if (pos === 0) { - return true; - } - curr_s = subject[pos]; - prev_s = subject[pos - 1]; - return isSeparator(curr_s) || isSeparator(prev_s) || (curr_s !== subject_lw[pos] && prev_s === subject_lw[pos - 1]); - }; - - exports.isWordEnd = isWordEnd = function(pos, subject, subject_lw, len) { - var curr_s, next_s; - if (pos === len - 1) { - return true; - } - curr_s = subject[pos]; - next_s = subject[pos + 1]; - return isSeparator(curr_s) || isSeparator(next_s) || (curr_s === subject_lw[pos] && next_s !== subject_lw[pos + 1]); - }; - - isSeparator = function(c) { - return c === ' ' || c === '.' || c === '-' || c === '_' || c === '/' || c === '\\'; - }; - - scorePosition = function(pos) { - var sc; - if (pos < pos_bonus) { - sc = pos_bonus - pos; - return 100 + sc * sc; - } else { - return Math.max(100 + pos_bonus - pos, 0); - } - }; - - scoreSize = function(n, m) { - return tau_size / (tau_size + Math.abs(m - n)); - }; - - scoreExact = function(n, m, quality, pos) { - return 2 * n * (wm * quality + scorePosition(pos)) * scoreSize(n, m); - }; - - exports.scorePattern = scorePattern = function(count, len, sameCase, start, end) { - var bonus, sz; - sz = count; - bonus = 6; - if (sameCase === count) { - bonus += 2; - } - if (start) { - bonus += 3; - } - if (end) { - bonus += 1; - } - if (count === len) { - if (start) { - if (sameCase === len) { - sz += 2; - } else { - sz += 1; - } - } - if (end) { - bonus += 1; - } - } - return sameCase + sz * (sz + bonus); - }; - - exports.scoreCharacter = scoreCharacter = function(i, j, start, acro_score, csc_score) { - var posBonus; - posBonus = scorePosition(i); - if (start) { - return posBonus + wm * ((acro_score > csc_score ? acro_score : csc_score) + 10); - } - return posBonus + wm * csc_score; - }; - - exports.scoreConsecutives = scoreConsecutives = function(subject, subject_lw, query, query_lw, i, j, start) { - var k, m, mi, n, nj, sameCase, startPos, sz; - m = subject.length; - n = query.length; - mi = m - i; - nj = n - j; - k = mi < nj ? mi : nj; - startPos = i; - sameCase = 0; - sz = 0; - if (query[j] === subject[i]) { - sameCase++; - } - while (++sz < k && query_lw[++j] === subject_lw[++i]) { - if (query[j] === subject[i]) { - sameCase++; - } - } - if (sz === 1) { - return 1 + 2 * sameCase; - } - return scorePattern(sz, n, sameCase, start, isWordEnd(i, subject, subject_lw, m)); - }; - - exports.scoreExactMatch = scoreExactMatch = function(subject, subject_lw, query, query_lw, pos, n, m) { - var end, i, pos2, sameCase, start; - start = isWordStart(pos, subject, subject_lw); - if (!start) { - pos2 = subject_lw.indexOf(query_lw, pos + 1); - if (pos2 > -1) { - start = isWordStart(pos2, subject, subject_lw); - if (start) { - pos = pos2; - } - } - } - i = -1; - sameCase = 0; - while (++i < n) { - if (query[pos + i] === subject[i]) { - sameCase++; - } - } - end = isWordEnd(pos + n - 1, subject, subject_lw, m); - return scoreExact(n, m, scorePattern(n, n, sameCase, start, end), pos); - }; - - AcronymResult = (function() { - function AcronymResult(score, pos, count) { - this.score = score; - this.pos = pos; - this.count = count; - } - - return AcronymResult; - - })(); - - emptyAcronymResult = new AcronymResult(0, 0.1, 0); - - exports.scoreAcronyms = scoreAcronyms = function(subject, subject_lw, query, query_lw) { - var count, i, j, m, n, pos, qj_lw, sameCase, score; - m = subject.length; - n = query.length; - if (!(m > 1 && n > 1)) { - return emptyAcronymResult; - } - count = 0; - pos = 0; - sameCase = 0; - i = -1; - j = -1; - while (++j < n) { - qj_lw = query_lw[j]; - while (++i < m) { - if (qj_lw === subject_lw[i] && isWordStart(i, subject, subject_lw)) { - if (query[j] === subject[i]) { - sameCase++; - } - pos += i; - count++; - break; - } - } - if (i === m) { - break; - } - } - if (count < 2) { - return emptyAcronymResult; - } - score = scorePattern(count, n, sameCase, true, false); - return new AcronymResult(score, pos / count, count); - }; - - basenameScore = function(subject, subject_lw, prepQuery, fullPathScore) { - var alpha, basePathScore, basePos, depth, end; - if (fullPathScore === 0) { - return 0; - } - end = subject.length - 1; - while (subject[end] === PathSeparator) { - end--; - } - basePos = subject.lastIndexOf(PathSeparator, end); - if (basePos === -1) { - return fullPathScore; - } - depth = prepQuery.depth; - while (depth-- > 0) { - basePos = subject.lastIndexOf(PathSeparator, basePos - 1); - if (basePos === -1) { - return fullPathScore; - } - } - basePos++; - end++; - basePathScore = doScore(subject.slice(basePos, end), subject_lw.slice(basePos, end), prepQuery); - alpha = 0.5 * tau_depth / (tau_depth + countDir(subject, end + 1)); - return alpha * basePathScore + (1 - alpha) * fullPathScore * scoreSize(0, file_coeff * (end - basePos)); - }; - - exports.countDir = countDir = function(path, end) { - var count, i; - if (end < 1) { - return 0; - } - count = 0; - i = -1; - while (++i < end && path[i] === PathSeparator) { - continue; - } - while (++i < end) { - if (path[i] === PathSeparator) { - count++; - while (++i < end && path[i] === PathSeparator) { - continue; - } - } - } - return count; - }; - - truncatedUpperCase = function(str) { - var char, upper, _i, _len; - upper = ""; - for (_i = 0, _len = str.length; _i < _len; _i++) { - char = str[_i]; - upper += char.toUpperCase()[0]; - } - return upper; - }; - -}).call(this); - -},{"path":7}],7:[function(require,module,exports){ -(function (process){ -// Copyright Joyent, Inc. and other Node contributors. -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the -// "Software"), to deal in the Software without restriction, including -// without limitation the rights to use, copy, modify, merge, publish, -// distribute, sublicense, and/or sell copies of the Software, and to permit -// persons to whom the Software is furnished to do so, subject to the -// following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN -// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -// USE OR OTHER DEALINGS IN THE SOFTWARE. - -// resolves . and .. elements in a path array with directory names there -// must be no slashes, empty elements, or device names (c:\) in the array -// (so also no leading and trailing slashes - it does not distinguish -// relative and absolute paths) -function normalizeArray(parts, allowAboveRoot) { - // if the path tries to go above the root, `up` ends up > 0 - var up = 0; - for (var i = parts.length - 1; i >= 0; i--) { - var last = parts[i]; - if (last === '.') { - parts.splice(i, 1); - } else if (last === '..') { - parts.splice(i, 1); - up++; - } else if (up) { - parts.splice(i, 1); - up--; - } - } - - // if the path is allowed to go above the root, restore leading ..s - if (allowAboveRoot) { - for (; up--; up) { - parts.unshift('..'); - } - } - - return parts; -} - -// Split a filename into [root, dir, basename, ext], unix version -// 'root' is just a slash, or nothing. -var splitPathRe = - /^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/; -var splitPath = function(filename) { - return splitPathRe.exec(filename).slice(1); -}; - -// path.resolve([from ...], to) -// posix version -exports.resolve = function() { - var resolvedPath = '', - resolvedAbsolute = false; - - for (var i = arguments.length - 1; i >= -1 && !resolvedAbsolute; i--) { - var path = (i >= 0) ? arguments[i] : process.cwd(); - - // Skip empty and invalid entries - if (typeof path !== 'string') { - throw new TypeError('Arguments to path.resolve must be strings'); - } else if (!path) { - continue; - } - - resolvedPath = path + '/' + resolvedPath; - resolvedAbsolute = path.charAt(0) === '/'; - } - - // At this point the path should be resolved to a full absolute path, but - // handle relative paths to be safe (might happen when process.cwd() fails) - - // Normalize the path - resolvedPath = normalizeArray(filter(resolvedPath.split('/'), function(p) { - return !!p; - }), !resolvedAbsolute).join('/'); - - return ((resolvedAbsolute ? '/' : '') + resolvedPath) || '.'; -}; - -// path.normalize(path) -// posix version -exports.normalize = function(path) { - var isAbsolute = exports.isAbsolute(path), - trailingSlash = substr(path, -1) === '/'; - - // Normalize the path - path = normalizeArray(filter(path.split('/'), function(p) { - return !!p; - }), !isAbsolute).join('/'); - - if (!path && !isAbsolute) { - path = '.'; - } - if (path && trailingSlash) { - path += '/'; - } - - return (isAbsolute ? '/' : '') + path; -}; - -// posix version -exports.isAbsolute = function(path) { - return path.charAt(0) === '/'; -}; - -// posix version -exports.join = function() { - var paths = Array.prototype.slice.call(arguments, 0); - return exports.normalize(filter(paths, function(p, index) { - if (typeof p !== 'string') { - throw new TypeError('Arguments to path.join must be strings'); - } - return p; - }).join('/')); -}; - - -// path.relative(from, to) -// posix version -exports.relative = function(from, to) { - from = exports.resolve(from).substr(1); - to = exports.resolve(to).substr(1); - - function trim(arr) { - var start = 0; - for (; start < arr.length; start++) { - if (arr[start] !== '') break; - } - - var end = arr.length - 1; - for (; end >= 0; end--) { - if (arr[end] !== '') break; - } - - if (start > end) return []; - return arr.slice(start, end - start + 1); - } - - var fromParts = trim(from.split('/')); - var toParts = trim(to.split('/')); - - var length = Math.min(fromParts.length, toParts.length); - var samePartsLength = length; - for (var i = 0; i < length; i++) { - if (fromParts[i] !== toParts[i]) { - samePartsLength = i; - break; - } - } - - var outputParts = []; - for (var i = samePartsLength; i < fromParts.length; i++) { - outputParts.push('..'); - } - - outputParts = outputParts.concat(toParts.slice(samePartsLength)); - - return outputParts.join('/'); -}; - -exports.sep = '/'; -exports.delimiter = ':'; - -exports.dirname = function(path) { - var result = splitPath(path), - root = result[0], - dir = result[1]; - - if (!root && !dir) { - // No dirname whatsoever - return '.'; - } - - if (dir) { - // It has a dirname, strip trailing slash - dir = dir.substr(0, dir.length - 1); - } - - return root + dir; -}; - - -exports.basename = function(path, ext) { - var f = splitPath(path)[2]; - // TODO: make this comparison case-insensitive on windows? - if (ext && f.substr(-1 * ext.length) === ext) { - f = f.substr(0, f.length - ext.length); - } - return f; -}; - - -exports.extname = function(path) { - return splitPath(path)[3]; -}; - -function filter (xs, f) { - if (xs.filter) return xs.filter(f); - var res = []; - for (var i = 0; i < xs.length; i++) { - if (f(xs[i], i, xs)) res.push(xs[i]); - } - return res; -} - -// String.prototype.substr - negative index don't work in IE8 -var substr = 'ab'.substr(-1) === 'b' - ? function (str, start, len) { return str.substr(start, len) } - : function (str, start, len) { - if (start < 0) start = str.length + start; - return str.substr(start, len); - } -; - -}).call(this,require('_process')) -},{"_process":8}],8:[function(require,module,exports){ -// shim for using process in browser - -var process = module.exports = {}; -var queue = []; -var draining = false; -var currentQueue; -var queueIndex = -1; - -function cleanUpNextTick() { - draining = false; - if (currentQueue.length) { - queue = currentQueue.concat(queue); - } else { - queueIndex = -1; - } - if (queue.length) { - drainQueue(); - } -} - -function drainQueue() { - if (draining) { - return; - } - var timeout = setTimeout(cleanUpNextTick); - draining = true; - - var len = queue.length; - while(len) { - currentQueue = queue; - queue = []; - while (++queueIndex < len) { - if (currentQueue) { - currentQueue[queueIndex].run(); - } - } - queueIndex = -1; - len = queue.length; - } - currentQueue = null; - draining = false; - clearTimeout(timeout); -} - -process.nextTick = function (fun) { - var args = new Array(arguments.length - 1); - if (arguments.length > 1) { - for (var i = 1; i < arguments.length; i++) { - args[i - 1] = arguments[i]; - } - } - queue.push(new Item(fun, args)); - if (queue.length === 1 && !draining) { - setTimeout(drainQueue, 0); - } -}; - -// v8 likes predictible objects -function Item(fun, array) { - this.fun = fun; - this.array = array; -} -Item.prototype.run = function () { - this.fun.apply(null, this.array); -}; -process.title = 'browser'; -process.browser = true; -process.env = {}; -process.argv = []; -process.version = ''; // empty string to avoid regexp issues -process.versions = {}; - -function noop() {} - -process.on = noop; -process.addListener = noop; -process.once = noop; -process.off = noop; -process.removeListener = noop; -process.removeAllListeners = noop; -process.emit = noop; - -process.binding = function (name) { - throw new Error('process.binding is not supported'); -}; - -process.cwd = function () { return '/' }; -process.chdir = function (dir) { - throw new Error('process.chdir is not supported'); -}; -process.umask = function() { return 0; }; - -},{}]},{},[1]); diff --git a/vendor/assets/javascripts/peek.js b/vendor/assets/javascripts/peek.js index f7e77de34ff..6a341a3f0fe 100644 --- a/vendor/assets/javascripts/peek.js +++ b/vendor/assets/javascripts/peek.js @@ -1,5 +1,14 @@ +/* + * This is a modified version of https://github.com/peek/peek/blob/master/app/assets/javascripts/peek.js + * + * - Removed the dependency on jquery.tipsy + * - Removed the initializeTipsy and toggleBar functions + * - Customized updatePerformanceBar to handle SQL queries report specificities + * - Changed /peek/results to /-/peek/results + * - Removed the keypress, pjax:end, page:change, and turbolinks:load handlers + */ (function($) { - var fetchRequestResults, getRequestId, peekEnabled, toggleBar, updatePerformanceBar; + var fetchRequestResults, getRequestId, peekEnabled, updatePerformanceBar; getRequestId = function() { return $('#peek').data('request-id'); }; @@ -41,22 +50,6 @@ }); return $(document).trigger('peek:render', [getRequestId(), results]); }; - toggleBar = function(event) { - var wrapper; - if ($(event.target).is(':input')) { - return; - } - if (event.which === 96 && !event.metaKey) { - wrapper = $('#peek'); - if (wrapper.hasClass('disabled')) { - wrapper.removeClass('disabled'); - return document.cookie = "peek=true; path=/"; - } else { - wrapper.addClass('disabled'); - return document.cookie = "peek=false; path=/"; - } - } - }; fetchRequestResults = function() { return $.ajax('/-/peek/results', { data: { @@ -68,7 +61,6 @@ error: function(xhr, textStatus, error) {} }); }; - $(document).on('keypress', toggleBar); $(document).on('peek:update', fetchRequestResults); return $(function() { if (peekEnabled()) { diff --git a/yarn.lock b/yarn.lock index 91ffbe5d4b0..ee00c1f4f3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -248,6 +248,10 @@ autoprefixer@^6.3.1: postcss "^5.2.16" postcss-value-parser "^3.2.3" +autosize@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/autosize/-/autosize-4.0.0.tgz#7a0599b1ba84d73bd7589b0d9da3870152c69237" + aws-sign2@~0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f" @@ -2671,6 +2675,10 @@ function-bind@^1.1.1, function-bind@~1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" +fuzzaldrin-plus@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/fuzzaldrin-plus/-/fuzzaldrin-plus-0.5.0.tgz#ef5f26f0c2fc7e9e9a16ea149a802d6cb4804b1e" + gauge@~2.7.3: version "2.7.4" resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" @@ -6390,9 +6398,9 @@ vue-style-loader@^2.0.0: hash-sum "^1.0.2" loader-utils "^1.0.2" -vue-template-compiler@^2.2.6: - version "2.2.6" - resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.2.6.tgz#2e2928daf0cd0feca9dfc35a9729adeae173ec68" +vue-template-compiler@^2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.5.2.tgz#6f198ebc677b8f804315cd33b91e849315ae7177" dependencies: de-indent "^1.0.2" he "^1.1.0" @@ -6401,9 +6409,9 @@ vue-template-es2015-compiler@^1.2.2: version "1.5.1" resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.5.1.tgz#0c36cc57aa3a9ec13e846342cb14a72fcac8bd93" -vue@^2.2.6: - version "2.2.6" - resolved "https://registry.yarnpkg.com/vue/-/vue-2.2.6.tgz#451714b394dd6d4eae7b773c40c2034a59621aed" +vue@^2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/vue/-/vue-2.5.2.tgz#fd367a87bae7535e47f9dc5c9ec3b496e5feb5a4" vuex@^3.0.0: version "3.0.0" |