diff options
1061 files changed, 18337 insertions, 6653 deletions
diff --git a/.gitignore b/.gitignore index 0fb97ffb98e..89da29fd790 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ eslint-report.html .sass-cache/ /.secret /.vagrant +/.yarn-cache /.byebug_history /Vagrantfile /backups/* @@ -48,6 +49,7 @@ eslint-report.html /public/uploads/ /shared/artifacts/ /spec/javascripts/fixtures/blob/pdf/ +/spec/javascripts/fixtures/blob/balsamiq/ /rails_best_practices_output.html /tags /tmp/* diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 588f255eff8..5463a020de7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,9 +1,10 @@ image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.3-golang-1.8-git-2.7-phantomjs-2.1-node-7.1-postgresql-9.6" cache: - key: "ruby-233" + key: "ruby-233-with-yarn" paths: - vendor/ruby + - .yarn-cache/ variables: MYSQL_ALLOW_EMPTY_PASSWORD: "1" @@ -141,6 +142,7 @@ stages: # Trigger a package build on omnibus-gitlab repository build-package: + before_script: [] services: [] variables: SETUP_DB: "false" @@ -148,17 +150,7 @@ build-package: stage: build when: manual script: - # If no branch in omnibus is specified, trigger pipeline against master - - if [ -z "$OMNIBUS_BRANCH" ] ; then export OMNIBUS_BRANCH=master ;fi - - echo "token=${BUILD_TRIGGER_TOKEN}" > version_details - - echo "ref=${OMNIBUS_BRANCH}" >> version_details - - echo "variables[ALTERNATIVE_SOURCES]=true" >> version_details - - echo "variables[GITLAB_VERSION]=${CI_COMMIT_SHA}" >> version_details - # Collect version details of all components - - for f in *_VERSION; do echo "variables[$f]=$(cat $f)" >> version_details; done - # Trigger the API and pass values collected above as parameters to it - - cat version_details | tr '\n' '&' | curl -X POST https://gitlab.com/api/v4/projects/20699/trigger/pipeline --data-binary @- - - rm version_details + - scripts/trigger-build # Prepare and merge knapsack tests knapsack: @@ -195,7 +187,7 @@ setup-test-env: stage: prepare script: - node --version - - yarn install --pure-lockfile + - yarn install --pure-lockfile --cache-folder .yarn-cache - bundle exec rake gitlab:assets:compile - bundle exec ruby -Ispec -e 'require "spec_helper" ; TestEnv.init' artifacts: @@ -409,7 +401,8 @@ rake gitlab:assets:compile: SKIP_STORAGE_VALIDATION: "true" WEBPACK_REPORT: "true" script: - - bundle exec rake yarn:install gitlab:assets:compile + - yarn install --pure-lockfile --production --cache-folder .yarn-cache + - bundle exec rake gitlab:assets:compile artifacts: name: webpack-report expire_in: 31d @@ -417,9 +410,6 @@ rake gitlab:assets:compile: - webpack-report/ rake karma: - cache: - paths: - - vendor/ruby stage: test <<: *use-pg <<: *dedicated-runner diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md index 66e1e0e20b3..58af062e75e 100644 --- a/.gitlab/issue_templates/Bug.md +++ b/.gitlab/issue_templates/Bug.md @@ -40,6 +40,7 @@ logs, and code as it's very hard to read otherwise.) #### Results of GitLab environment info <details> +<summary>Expand for output related to GitLab environment info</summary> <pre> (For installations with omnibus-gitlab package run and paste the output of: @@ -54,6 +55,7 @@ logs, and code as it's very hard to read otherwise.) #### Results of GitLab application Check <details> +<summary>Expand for output related to the GitLab application check</summary> <pre> (For installations with omnibus-gitlab package run and paste the output of: diff --git a/.rubocop.yml b/.rubocop.yml index e53af97a92c..4e1d456d8d1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -494,7 +494,13 @@ Style/TrailingBlankLines: # This cop checks for trailing comma in array and hash literals. Style/TrailingCommaInLiteral: - Enabled: false + Enabled: true + EnforcedStyleForMultiline: no_comma + +# This cop checks for trailing comma in argument lists. +Style/TrailingCommaInArguments: + Enabled: true + EnforcedStyleForMultiline: no_comma # Checks for %W when interpolation is not needed. Style/UnneededCapitalW: diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 38b22afdf82..7582f761bcb 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -369,13 +369,6 @@ Style/SymbolProc: Style/TernaryParentheses: Enabled: false -# Offense count: 53 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyleForMultiline, SupportedStylesForMultiline. -# SupportedStylesForMultiline: comma, consistent_comma, no_comma -Style/TrailingCommaInArguments: - Enabled: false - # Offense count: 18 # Cop supports --auto-correct. # Configuration parameters: AllowNamedUnderscoreVariables. diff --git a/CHANGELOG.md b/CHANGELOG.md index e625278a796..38de411ebb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 9.1.4 (2017-05-12) + +- No changes. +- No changes. +- No changes. +- Fix error on CI/CD Settings page related to invalid pipeline trigger. !10948 (dosuken123) +- Sort the network graph both by commit date and topographically. !11057 +- Fix cross referencing for private and internal projects. !11243 +- Handle incoming emails from aliases correctly. +- Gracefully handle failures for incoming emails which do not match on the To header, and have no References header. +- Add missing project attributes to Import/Export. +- Fixed search terms not correctly highlighting. +- Fixed bug where merge request JSON would be displayed. + ## 9.1.3 (2017-05-05) - Do not show private groups on subgroups page if user doesn't have access to. @@ -145,12 +145,12 @@ gem 'acts-as-taggable-on', '~> 4.0' # Background jobs gem 'sidekiq', '~> 5.0' -gem 'sidekiq-cron', '~> 0.4.4' +gem 'sidekiq-cron', '~> 0.6.0' gem 'redis-namespace', '~> 1.5.2' gem 'sidekiq-limit_fetch', '~> 3.4' # Cron Parser -gem 'rufus-scheduler', '~> 3.1.10' +gem 'rufus-scheduler', '~> 3.4' # HTTP requests gem 'httparty', '~> 0.13.3' @@ -367,6 +367,6 @@ gem 'vmstat', '~> 2.3.0' gem 'sys-filesystem', '~> 1.1.6' # Gitaly GRPC client -gem 'gitaly', '~> 0.6.0' +gem 'gitaly', '~> 0.7.0' gem 'toml-rb', '~> 0.3.15', require: false diff --git a/Gemfile.lock b/Gemfile.lock index c0c56aa9602..873cd8781ef 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -181,6 +181,8 @@ GEM equalizer (0.0.11) erubis (2.7.0) escape_utils (1.1.1) + et-orbi (1.0.3) + tzinfo eventmachine (1.0.8) excon (0.55.0) execjs (2.6.0) @@ -263,7 +265,7 @@ GEM po_to_json (>= 1.0.0) rails (>= 3.2.0) gherkin-ruby (0.3.2) - gitaly (0.6.0) + gitaly (0.7.0) google-protobuf (~> 3.1) grpc (~> 1.0) github-linguist (4.7.6) @@ -697,7 +699,8 @@ GEM rubyntlm (0.5.2) rubypants (0.2.0) rubyzip (1.2.1) - rufus-scheduler (3.1.10) + rufus-scheduler (3.4.0) + et-orbi (~> 1.0) rugged (0.25.1.1) safe_yaml (1.0.4) sanitize (2.1.0) @@ -734,9 +737,8 @@ GEM connection_pool (~> 2.2, >= 2.2.0) rack-protection (>= 1.5.0) redis (~> 3.3, >= 3.3.3) - sidekiq-cron (0.4.4) - redis-namespace (>= 1.5.2) - rufus-scheduler (>= 2.0.24) + sidekiq-cron (0.6.0) + rufus-scheduler (>= 3.3.0) sidekiq (>= 4.2.1) sidekiq-limit_fetch (3.4.0) sidekiq (>= 4) @@ -922,7 +924,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.2.0) - gitaly (~> 0.6.0) + gitaly (~> 0.7.0) github-linguist (~> 4.7.0) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-markup (~> 1.5.1) @@ -1013,7 +1015,7 @@ DEPENDENCIES ruby-fogbugz (~> 0.2.1) ruby-prof (~> 0.16.2) ruby_parser (~> 3.8.4) - rufus-scheduler (~> 3.1.10) + rufus-scheduler (~> 3.4) rugged (~> 0.25.1.1) sanitize (~> 2.0) sass-rails (~> 5.0.6) @@ -1025,7 +1027,7 @@ DEPENDENCIES sham_rack (~> 1.3.6) shoulda-matchers (~> 2.8.0) sidekiq (~> 5.0) - sidekiq-cron (~> 0.4.4) + sidekiq-cron (~> 0.6.0) sidekiq-limit_fetch (~> 3.4) simplecov (~> 0.14.0) slack-notifier (~> 1.5.1) diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js index cdbfe36ca1c..c17877a276d 100644 --- a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js +++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js @@ -1,5 +1,3 @@ -/* global Flash */ - import sqljs from 'sql.js'; import { template as _template } from 'underscore'; @@ -15,19 +13,27 @@ const PREVIEW_TEMPLATE = _template(` class BalsamiqViewer { constructor(viewer) { this.viewer = viewer; - this.endpoint = this.viewer.dataset.endpoint; } - loadFile() { - const xhr = new XMLHttpRequest(); + loadFile(endpoint) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.open('GET', endpoint, true); + xhr.responseType = 'arraybuffer'; + xhr.onload = loadEvent => this.fileLoaded(loadEvent, resolve, reject); + xhr.onerror = reject; + + xhr.send(); + }); + } - xhr.open('GET', this.endpoint, true); - xhr.responseType = 'arraybuffer'; + fileLoaded(loadEvent, resolve, reject) { + if (loadEvent.target.status !== 200) return reject(); - xhr.onload = this.renderFile.bind(this); - xhr.onerror = BalsamiqViewer.onError; + this.renderFile(loadEvent); - xhr.send(); + return resolve(); } renderFile(loadEvent) { @@ -103,12 +109,6 @@ class BalsamiqViewer { static parseTitle(resource) { return JSON.parse(resource.values[0][2]).name; } - - static onError() { - const flash = new Flash('Balsamiq file could not be loaded.'); - - return flash; - } } export default BalsamiqViewer; diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js index 1dacf84470f..8641a6fdae6 100644 --- a/app/assets/javascripts/blob/balsamiq_viewer.js +++ b/app/assets/javascripts/blob/balsamiq_viewer.js @@ -1,6 +1,22 @@ +/* global Flash */ + import BalsamiqViewer from './balsamiq/balsamiq_viewer'; -document.addEventListener('DOMContentLoaded', () => { - const balsamiqViewer = new BalsamiqViewer(document.getElementById('js-balsamiq-viewer')); - balsamiqViewer.loadFile(); -}); +function onError() { + const flash = new window.Flash('Balsamiq file could not be loaded.'); + + return flash; +} + +function loadBalsamiqFile() { + const viewer = document.getElementById('js-balsamiq-viewer'); + + if (!(viewer instanceof Element)) return; + + const endpoint = viewer.dataset.endpoint; + + const balsamiqViewer = new BalsamiqViewer(viewer); + balsamiqViewer.loadFile(endpoint).catch(onError); +} + +$(loadBalsamiqFile); diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index 07d67d49aa5..849da633c89 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -1,17 +1,38 @@ /* global Flash */ export default class BlobViewer { constructor() { + BlobViewer.initAuxiliaryViewer(); + + this.initMainViewers(); + } + + static initAuxiliaryViewer() { + const auxiliaryViewer = document.querySelector('.blob-viewer[data-type="auxiliary"]'); + if (!auxiliaryViewer) return; + + BlobViewer.loadViewer(auxiliaryViewer); + } + + initMainViewers() { + this.$fileHolder = $('.file-holder'); + if (!this.$fileHolder.length) return; + this.switcher = document.querySelector('.js-blob-viewer-switcher'); this.switcherBtns = document.querySelectorAll('.js-blob-viewer-switch-btn'); this.copySourceBtn = document.querySelector('.js-copy-blob-source-btn'); - this.simpleViewer = document.querySelector('.blob-viewer[data-type="simple"]'); - this.richViewer = document.querySelector('.blob-viewer[data-type="rich"]'); - this.$fileHolder = $('.file-holder'); - let initialViewerName = document.querySelector('.blob-viewer:not(.hidden)').getAttribute('data-type'); + this.simpleViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="simple"]'); + this.richViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="rich"]'); this.initBindings(); + this.switchToInitialViewer(); + } + + switchToInitialViewer() { + const initialViewer = this.$fileHolder[0].querySelector('.blob-viewer:not(.hidden)'); + let initialViewerName = initialViewer.getAttribute('data-type'); + if (this.switcher && location.hash.indexOf('#L') === 0) { initialViewerName = 'simple'; } @@ -61,40 +82,13 @@ export default class BlobViewer { $(this.copySourceBtn).tooltip('fixTitle'); } - loadViewer(viewerParam) { - const viewer = viewerParam; - const url = viewer.getAttribute('data-url'); - - if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) { - return; - } - - viewer.setAttribute('data-loading', 'true'); - - $.ajax({ - url, - dataType: 'JSON', - }) - .fail(() => new Flash('Error loading source view')) - .done((data) => { - viewer.innerHTML = data.html; - $(viewer).syntaxHighlight(); - - viewer.setAttribute('data-loaded', 'true'); - - this.$fileHolder.trigger('highlight:line'); - - this.toggleCopyButtonState(); - }); - } - switchToViewer(name) { - const newViewer = document.querySelector(`.blob-viewer[data-type='${name}']`); + const newViewer = this.$fileHolder[0].querySelector(`.blob-viewer[data-type='${name}']`); if (this.activeViewer === newViewer) return; const oldButton = document.querySelector('.js-blob-viewer-switch-btn.active'); const newButton = document.querySelector(`.js-blob-viewer-switch-btn[data-viewer='${name}']`); - const oldViewer = document.querySelector(`.blob-viewer:not([data-type='${name}'])`); + const oldViewer = this.$fileHolder[0].querySelector(`.blob-viewer:not([data-type='${name}'])`); if (oldButton) { oldButton.classList.remove('active'); @@ -115,6 +109,40 @@ export default class BlobViewer { this.toggleCopyButtonState(); - this.loadViewer(newViewer); + BlobViewer.loadViewer(newViewer) + .then((viewer) => { + $(viewer).syntaxHighlight(); + + this.$fileHolder.trigger('highlight:line'); + + this.toggleCopyButtonState(); + }) + .catch(() => new Flash('Error loading viewer')); + } + + static loadViewer(viewerParam) { + const viewer = viewerParam; + const url = viewer.getAttribute('data-url'); + + return new Promise((resolve, reject) => { + if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) { + resolve(viewer); + return; + } + + viewer.setAttribute('data-loading', 'true'); + + $.ajax({ + url, + dataType: 'JSON', + }) + .fail(reject) + .done((data) => { + viewer.innerHTML = data.html; + viewer.setAttribute('data-loaded', 'true'); + + resolve(viewer); + }); + }); } } diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index 239eeacf2d7..0d23bdeeb99 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -35,7 +35,10 @@ gl.issueBoards.Board = Vue.extend({ filter: { handler() { this.list.page = 1; - this.list.getIssues(true); + this.list.getIssues(true) + .catch(() => { + // TODO: handle request error + }); }, deep: true, }, diff --git a/app/assets/javascripts/boards/components/board_blank_state.js b/app/assets/javascripts/boards/components/board_blank_state.js index 3fc68457961..870e115bd1a 100644 --- a/app/assets/javascripts/boards/components/board_blank_state.js +++ b/app/assets/javascripts/boards/components/board_blank_state.js @@ -70,7 +70,10 @@ export default { list.id = listObj.id; list.label.id = listObj.label.id; - list.getIssues(); + list.getIssues() + .catch(() => { + // TODO: handle request error + }); }); }) .catch(() => { diff --git a/app/assets/javascripts/boards/components/board_list.js b/app/assets/javascripts/boards/components/board_list.js index b13386536bf..7ee2696e720 100644 --- a/app/assets/javascripts/boards/components/board_list.js +++ b/app/assets/javascripts/boards/components/board_list.js @@ -2,6 +2,7 @@ import boardNewIssue from './board_new_issue'; import boardCard from './board_card'; import eventHub from '../eventhub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; const Store = gl.issueBoards.BoardsStore; @@ -44,6 +45,7 @@ export default { components: { boardCard, boardNewIssue, + loadingIcon, }, methods: { listHeight() { @@ -90,7 +92,10 @@ export default { if (this.scrollHeight() <= this.listHeight() && this.list.issuesSize > this.list.issues.length) { this.list.page += 1; - this.list.getIssues(false); + this.list.getIssues(false) + .catch(() => { + // TODO: handle request error + }); } if (this.scrollHeight() > Math.ceil(this.listHeight())) { @@ -153,10 +158,7 @@ export default { class="board-list-loading text-center" aria-label="Loading issues" v-if="loading"> - <i - class="fa fa-spinner fa-spin" - aria-hidden="true"> - </i> + <loading-icon /> </div> <board-new-issue :list="list" @@ -181,12 +183,12 @@ export default { class="board-list-count text-center" v-if="showCount" data-id="-1"> - <i - class="fa fa-spinner fa-spin" - aria-label="Loading more issues" - aria-hidden="true" - v-show="list.loadingMore"> - </i> + + <loading-icon + v-show="list.loadingMore" + label="Loading more issues" + /> + <span v-if="list.issues.length === list.issuesSize"> Showing all issues </span> diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 317cef9f227..9bcea302da2 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -36,6 +36,9 @@ gl.issueBoards.BoardSidebar = Vue.extend({ }, assigneeId() { return this.issue.assignee ? this.issue.assignee.id : 0; + }, + milestoneTitle() { + return this.issue.milestone ? this.issue.milestone.title : 'No Milestone'; } }, watch: { diff --git a/app/assets/javascripts/boards/components/modal/index.js b/app/assets/javascripts/boards/components/modal/index.js index fdab317dc23..507f16f3f06 100644 --- a/app/assets/javascripts/boards/components/modal/index.js +++ b/app/assets/javascripts/boards/components/modal/index.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import queryData from '../../utils/query_data'; +import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; require('./header'); require('./list'); @@ -108,6 +109,8 @@ gl.issueBoards.IssuesModal = Vue.extend({ if (!this.issuesCount) { this.issuesCount = data.size; } + }).catch(() => { + // TODO: handle request error }); }, }, @@ -135,6 +138,7 @@ gl.issueBoards.IssuesModal = Vue.extend({ 'modal-list': gl.issueBoards.ModalList, 'modal-footer': gl.issueBoards.ModalFooter, 'empty-state': gl.issueBoards.ModalEmptyState, + loadingIcon, }, template: ` <div @@ -159,7 +163,7 @@ gl.issueBoards.IssuesModal = Vue.extend({ class="add-issues-list text-center" v-if="loading || filterLoading"> <div class="add-issues-list-loading"> - <i class="fa fa-spinner fa-spin"></i> + <loading-icon /> </div> </section> <modal-footer></modal-footer> diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index bd2f62bcc1a..90561d0f7a8 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -25,7 +25,9 @@ class List { } if (this.type !== 'blank' && this.id) { - this.getIssues(); + this.getIssues().catch(() => { + // TODO: handle request error + }); } } @@ -52,11 +54,17 @@ class List { gl.issueBoards.BoardsStore.state.lists.splice(index, 1); gl.issueBoards.BoardsStore.updateNewListDropdown(this.id); - gl.boardService.destroyList(this.id); + gl.boardService.destroyList(this.id) + .catch(() => { + // TODO: handle request error + }); } update () { - gl.boardService.updateList(this.id, this.position); + gl.boardService.updateList(this.id, this.position) + .catch(() => { + // TODO: handle request error + }); } nextPage () { @@ -146,11 +154,17 @@ class List { this.issues.splice(oldIndex, 1); this.issues.splice(newIndex, 0, issue); - gl.boardService.moveIssue(issue.id, null, null, moveBeforeIid, moveAfterIid); + gl.boardService.moveIssue(issue.id, null, null, moveBeforeIid, moveAfterIid) + .catch(() => { + // TODO: handle request error + }); } updateIssueLabel(issue, listFrom, moveBeforeIid, moveAfterIid) { - gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeIid, moveAfterIid); + gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeIid, moveAfterIid) + .catch(() => { + // TODO: handle request error + }); } findIssue (id) { diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js b/app/assets/javascripts/commit/pipelines/pipelines_table.js index ad9c600b499..b8be0d8a301 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js @@ -6,6 +6,7 @@ import PipelineStore from '../../pipelines/stores/pipelines_store'; import eventHub from '../../pipelines/event_hub'; import EmptyState from '../../pipelines/components/empty_state.vue'; import ErrorState from '../../pipelines/components/error_state.vue'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import '../../lib/utils/common_utils'; import '../../vue_shared/vue_resource_interceptor'; import Poll from '../../lib/utils/poll'; @@ -17,8 +18,6 @@ import Poll from '../../lib/utils/poll'; * We need a store to store the received environemnts. * We need a service to communicate with the server. * - * Necessary SVG in the table are provided as props. This should be refactored - * as soon as we have Webpack and can load them directly into JS files. */ export default Vue.component('pipelines-table', { @@ -27,6 +26,7 @@ export default Vue.component('pipelines-table', { 'pipelines-table-component': PipelinesTableComponent, 'error-state': ErrorState, 'empty-state': EmptyState, + loadingIcon, }, /** @@ -151,13 +151,12 @@ export default Vue.component('pipelines-table', { template: ` <div class="content-list pipelines"> - <div - class="realtime-loading" - v-if="isLoading"> - <i - class="fa fa-spinner fa-spin" - aria-hidden="true" /> - </div> + + <loading-icon + label="Loading pipelines" + size="3" + v-if="isLoading" + /> <empty-state v-if="shouldRenderEmptyState" diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js index 222084deee9..dec1704395e 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js @@ -33,7 +33,7 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({ <span> {{ __('FirstPushedBy|First') }} <span class="commit-icon">${iconCommit}</span> - <a :href="commit.commitUrl" class="commit-hash-link monospace">{{ commit.shortSha }}</a> + <a :href="commit.commitUrl" class="commit-hash-link commit-sha">{{ commit.shortSha }}</a> {{ __('FirstPushedBy|pushed by') }} <a :href="commit.author.webUrl" class="commit-author-link"> {{ commit.author.name }} diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js index b1e9362434f..1f7c673b1d4 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js @@ -26,9 +26,9 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({ <h5 class="item-title"> <a :href="build.url" class="pipeline-id">#{{ build.id }}</a> <i class="fa fa-code-fork"></i> - <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a> + <a :href="build.branch.url" class="ref-name">{{ build.branch.name }}</a> <span class="icon-branch">${iconBranch}</span> - <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a> + <a :href="build.commitUrl" class="commit-sha">{{ build.shortSha }}</a> </h5> <span> <a :href="build.url" class="build-date">{{ build.date }}</a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js index e306026429e..78cc97eea0b 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js @@ -29,9 +29,9 @@ global.cycleAnalytics.StageTestComponent = Vue.extend({ · <a :href="build.url" class="pipeline-id">#{{ build.id }}</a> <i class="fa fa-code-fork"></i> - <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a> + <a :href="build.branch.url" class="ref-name">{{ build.branch.name }}</a> <span class="icon-branch">${iconBranch}</span> - <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a> + <a :href="build.commitUrl" class="commit-sha">{{ build.shortSha }}</a> </h5> <span> <a :href="build.url" class="issue-date"> diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue index 3ff3a9d977e..3f993213dd0 100644 --- a/app/assets/javascripts/deploy_keys/components/action_btn.vue +++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue @@ -1,5 +1,6 @@ <script> import eventHub from '../eventhub'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { data() { @@ -22,6 +23,11 @@ default: 'btn-default', }, }, + + components: { + loadingIcon, + }, + methods: { doAction() { this.isLoading = true; @@ -44,11 +50,6 @@ :disabled="isLoading" @click="doAction"> {{ text }} - <i - v-if="isLoading" - class="fa fa-spinner fa-spin" - aria-hidden="true" - aria-label="Loading"> - </i> + <loading-icon v-if="isLoading" /> </button> </template> diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index 7315a9e11cb..5f6eed0c67c 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -4,6 +4,7 @@ import DeployKeysService from '../service'; import DeployKeysStore from '../store'; import keysPanel from './keys_panel.vue'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { data() { @@ -28,6 +29,7 @@ }, components: { keysPanel, + loadingIcon, }, methods: { fetchKeys() { @@ -74,15 +76,11 @@ <template> <div class="col-lg-9 col-lg-offset-3 append-bottom-default deploy-keys"> - <div - class="text-center" - v-if="isLoading && !hasKeys"> - <i - class="fa fa-spinner fa-spin fa-2x" - aria-hidden="true" - aria-label="Loading deploy keys"> - </i> - </div> + <loading-icon + v-if="isLoading && !hasKeys" + size="2" + label="Loading deploy keys" + /> <div v-else-if="hasKeys"> <keys-panel title="Enabled deploy keys for this project" diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js index f3a688fbf2f..5f533b5761c 100644 --- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js +++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js @@ -120,7 +120,7 @@ const DiffNoteAvatars = Vue.extend({ }, methods: { clickedAvatar(e) { - notes.addDiffNote(e); + notes.onAddDiffNote(e); // Toggle the active state of the toggle all button this.toggleDiscussionsToggleState(); diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js index 92f6fd654b3..9d51fb53eb2 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js @@ -88,6 +88,7 @@ const ResolveBtn = Vue.extend({ CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); this.discussion.updateHeadline(data); + gl.mrWidget.checkStatus(); } else { new Flash(errorFlashMsg); } diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js index 4ea6ba8a73d..ba4f6d36fcb 100644 --- a/app/assets/javascripts/diff_notes/services/resolve.js +++ b/app/assets/javascripts/diff_notes/services/resolve.js @@ -49,6 +49,7 @@ class ResolveServiceClass { discussion.resolveAllNotes(resolved_by); } + gl.mrWidget.checkStatus(); discussion.updateHeadline(data); } else { throw new Error('An error occurred when trying to resolve discussion.'); diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index bf802056d36..22d01c484d3 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -10,12 +10,10 @@ /* global IssuableForm */ /* global LabelsSelect */ /* global MilestoneSelect */ -/* global MergedButtons */ /* global Commit */ /* global NotificationsForm */ /* global TreeView */ /* global NotificationsDropdown */ -/* global UsersSelect */ /* global GroupAvatar */ /* global LineHighlighter */ /* global ProjectFork */ @@ -53,6 +51,8 @@ import ShortcutsWiki from './shortcuts_wiki'; import Pipelines from './pipelines'; import BlobViewer from './blob/viewer/index'; import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; +import UsersSelect from './users_select'; +import RefSelectDropdown from './ref_select_dropdown'; const ShortcutsBlob = require('./shortcuts_blob'); @@ -114,6 +114,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'projects:boards:show': case 'projects:boards:index': shortcut_handler = new ShortcutsNavigation(); + new UsersSelect(); break; case 'projects:builds:show': new Build(); @@ -128,6 +129,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_', }); shortcut_handler = new ShortcutsNavigation(); + new UsersSelect(); break; case 'projects:issues:show': new Issue(); @@ -140,6 +142,10 @@ const ShortcutsBlob = require('./shortcuts_blob'); new Milestone(); new Sidebar(); break; + case 'groups:issues': + case 'groups:merge_requests': + new UsersSelect(); + break; case 'dashboard:todos:index': new gl.Todos(); break; @@ -207,6 +213,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'projects:tags:new': new ZenMode(); new gl.GLForm($('.tag-form')); + new RefSelectDropdown($('.js-branch-select'), window.gl.availableRefs); break; case 'projects:releases:edit': new ZenMode(); @@ -216,19 +223,18 @@ const ShortcutsBlob = require('./shortcuts_blob'); new gl.Diff(); shortcut_handler = new ShortcutsIssuable(true); new ZenMode(); - new MergedButtons(); - break; - case 'projects:merge_requests:commits': - new MergedButtons(); break; case "projects:merge_requests:diffs": new gl.Diff(); new ZenMode(); - new MergedButtons(); break; case 'dashboard:activity': new gl.Activities(); break; + case 'dashboard:issues': + case 'dashboard:merge_requests': + new UsersSelect(); + break; case 'projects:commit:show': new Commit(); new gl.Diff(); @@ -252,6 +258,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); new NotificationsForm(); if ($('#tree-slider').length) { new TreeView(); + new BlobViewer(); } break; case 'projects:pipelines:builds': @@ -306,6 +313,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'projects:tree:show': shortcut_handler = new ShortcutsNavigation(); new TreeView(); + new BlobViewer(); gl.TargetBranchDropDown.bootstrap(); break; case 'projects:find_file:show': @@ -381,6 +389,9 @@ const ShortcutsBlob = require('./shortcuts_blob'); new LineHighlighter(); new BlobViewer(); break; + case 'import:fogbugz:new_user_map': + new UsersSelect(); + break; } switch (path.first()) { case 'sessions': diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js index de3927d683c..70cd337fb8a 100644 --- a/app/assets/javascripts/droplab/drop_down.js +++ b/app/assets/javascripts/droplab/drop_down.js @@ -1,44 +1,42 @@ -/* eslint-disable */ - import utils from './utils'; import { SELECTED_CLASS, IGNORE_CLASS } from './constants'; -var DropDown = function(list) { - this.currentIndex = 0; - this.hidden = true; - this.list = typeof list === 'string' ? document.querySelector(list) : list; - this.items = []; +class DropDown { + constructor(list) { + this.currentIndex = 0; + this.hidden = true; + this.list = typeof list === 'string' ? document.querySelector(list) : list; + this.items = []; - this.eventWrapper = {}; + this.eventWrapper = {}; - this.getItems(); - this.initTemplateString(); - this.addEvents(); + this.getItems(); + this.initTemplateString(); + this.addEvents(); - this.initialState = list.innerHTML; -}; + this.initialState = list.innerHTML; + } -Object.assign(DropDown.prototype, { - getItems: function() { + getItems() { this.items = [].slice.call(this.list.querySelectorAll('li')); return this.items; - }, + } - initTemplateString: function() { - var items = this.items || this.getItems(); + initTemplateString() { + const items = this.items || this.getItems(); - var templateString = ''; + let templateString = ''; if (items.length > 0) templateString = items[items.length - 1].outerHTML; this.templateString = templateString; return this.templateString; - }, + } - clickEvent: function(e) { + clickEvent(e) { if (e.target.tagName === 'UL') return; if (e.target.classList.contains(IGNORE_CLASS)) return; - var selected = utils.closest(e.target, 'LI'); + const selected = utils.closest(e.target, 'LI'); if (!selected) return; this.addSelectedClass(selected); @@ -46,95 +44,95 @@ Object.assign(DropDown.prototype, { e.preventDefault(); this.hide(); - var listEvent = new CustomEvent('click.dl', { + const listEvent = new CustomEvent('click.dl', { detail: { list: this, - selected: selected, + selected, data: e.target.dataset, }, }); this.list.dispatchEvent(listEvent); - }, + } - addSelectedClass: function (selected) { + addSelectedClass(selected) { this.removeSelectedClasses(); selected.classList.add(SELECTED_CLASS); - }, + } - removeSelectedClasses: function () { + removeSelectedClasses() { const items = this.items || this.getItems(); items.forEach(item => item.classList.remove(SELECTED_CLASS)); - }, + } - addEvents: function() { - this.eventWrapper.clickEvent = this.clickEvent.bind(this) + addEvents() { + this.eventWrapper.clickEvent = this.clickEvent.bind(this); this.list.addEventListener('click', this.eventWrapper.clickEvent); - }, - - toggle: function() { - this.hidden ? this.show() : this.hide(); - }, + } - setData: function(data) { + setData(data) { this.data = data; this.render(data); - }, + } - addData: function(data) { + addData(data) { this.data = (this.data || []).concat(data); this.render(this.data); - }, + } - render: function(data) { + render(data) { const children = data ? data.map(this.renderChildren.bind(this)) : []; const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list; renderableList.innerHTML = children.join(''); - }, + } - renderChildren: function(data) { - var html = utils.template(this.templateString, data); - var template = document.createElement('div'); + renderChildren(data) { + const html = utils.template(this.templateString, data); + const template = document.createElement('div'); template.innerHTML = html; - this.setImagesSrc(template); + DropDown.setImagesSrc(template); template.firstChild.style.display = data.droplab_hidden ? 'none' : 'block'; return template.firstChild.outerHTML; - }, - - setImagesSrc: function(template) { - const images = [].slice.call(template.querySelectorAll('img[data-src]')); - - images.forEach((image) => { - image.src = image.getAttribute('data-src'); - image.removeAttribute('data-src'); - }); - }, + } - show: function() { + show() { if (!this.hidden) return; this.list.style.display = 'block'; this.currentIndex = 0; this.hidden = false; - }, + } - hide: function() { + hide() { if (this.hidden) return; this.list.style.display = 'none'; this.currentIndex = 0; this.hidden = true; - }, + } - toggle: function () { - this.hidden ? this.show() : this.hide(); - }, + toggle() { + if (this.hidden) return this.show(); - destroy: function() { + return this.hide(); + } + + destroy() { this.hide(); this.list.removeEventListener('click', this.eventWrapper.clickEvent); } -}); + + static setImagesSrc(template) { + const images = [...template.querySelectorAll('img[data-src]')]; + + images.forEach((image) => { + const img = image; + + img.src = img.getAttribute('data-src'); + img.removeAttribute('data-src'); + }); + } +} export default DropDown; diff --git a/app/assets/javascripts/droplab/drop_lab.js b/app/assets/javascripts/droplab/drop_lab.js index 6eb9f314af7..2a02ede72bf 100644 --- a/app/assets/javascripts/droplab/drop_lab.js +++ b/app/assets/javascripts/droplab/drop_lab.js @@ -1,99 +1,99 @@ -/* eslint-disable */ - import HookButton from './hook_button'; import HookInput from './hook_input'; import utils from './utils'; import Keyboard from './keyboard'; import { DATA_TRIGGER } from './constants'; -var DropLab = function() { - this.ready = false; - this.hooks = []; - this.queuedData = []; - this.config = {}; +class DropLab { + constructor() { + this.ready = false; + this.hooks = []; + this.queuedData = []; + this.config = {}; - this.eventWrapper = {}; -}; + this.eventWrapper = {}; + } -Object.assign(DropLab.prototype, { - loadStatic: function(){ - var dropdownTriggers = [].slice.apply(document.querySelectorAll(`[${DATA_TRIGGER}]`)); + loadStatic() { + const dropdownTriggers = [].slice.apply(document.querySelectorAll(`[${DATA_TRIGGER}]`)); this.addHooks(dropdownTriggers); - }, + } - addData: function () { - var args = [].slice.apply(arguments); - this.applyArgs(args, '_addData'); - }, + addData(...args) { + this.applyArgs(args, 'processAddData'); + } - setData: function() { - var args = [].slice.apply(arguments); - this.applyArgs(args, '_setData'); - }, + setData(...args) { + this.applyArgs(args, 'processSetData'); + } - destroy: function() { + destroy() { this.hooks.forEach(hook => hook.destroy()); this.hooks = []; this.removeEvents(); - }, + } - applyArgs: function(args, methodName) { - if (this.ready) return this[methodName].apply(this, args); + applyArgs(args, methodName) { + if (this.ready) return this[methodName](...args); this.queuedData = this.queuedData || []; this.queuedData.push(args); - }, - _addData: function(trigger, data) { - this._processData(trigger, data, 'addData'); - }, + return this.ready; + } + + processAddData(trigger, data) { + this.processData(trigger, data, 'addData'); + } - _setData: function(trigger, data) { - this._processData(trigger, data, 'setData'); - }, + processSetData(trigger, data) { + this.processData(trigger, data, 'setData'); + } - _processData: function(trigger, data, methodName) { + processData(trigger, data, methodName) { this.hooks.forEach((hook) => { if (Array.isArray(trigger)) hook.list[methodName](trigger); if (hook.trigger.id === trigger) hook.list[methodName](data); }); - }, + } - addEvents: function() { - this.eventWrapper.documentClicked = this.documentClicked.bind(this) + addEvents() { + this.eventWrapper.documentClicked = this.documentClicked.bind(this); document.addEventListener('click', this.eventWrapper.documentClicked); - }, + } - documentClicked: function(e) { + documentClicked(e) { let thisTag = e.target; if (thisTag.tagName !== 'UL') thisTag = utils.closest(thisTag, 'UL'); - if (utils.isDropDownParts(thisTag, this.hooks) || utils.isDropDownParts(e.target, this.hooks)) return; + if (utils.isDropDownParts(thisTag, this.hooks)) return; + if (utils.isDropDownParts(e.target, this.hooks)) return; this.hooks.forEach(hook => hook.list.hide()); - }, + } - removeEvents: function(){ + removeEvents() { document.removeEventListener('click', this.eventWrapper.documentClicked); - }, - - changeHookList: function(trigger, list, plugins, config) { - const availableTrigger = typeof trigger === 'string' ? document.getElementById(trigger) : trigger; + } + changeHookList(trigger, list, plugins, config) { + const availableTrigger = typeof trigger === 'string' ? document.getElementById(trigger) : trigger; this.hooks.forEach((hook, i) => { - hook.list.list.dataset.dropdownActive = false; + const aHook = hook; + + aHook.list.list.dataset.dropdownActive = false; - if (hook.trigger !== availableTrigger) return; + if (aHook.trigger !== availableTrigger) return; - hook.destroy(); + aHook.destroy(); this.hooks.splice(i, 1); this.addHook(availableTrigger, list, plugins, config); }); - }, + } - addHook: function(hook, list, plugins, config) { + addHook(hook, list, plugins, config) { const availableHook = typeof hook === 'string' ? document.querySelector(hook) : hook; let availableList; @@ -111,18 +111,18 @@ Object.assign(DropLab.prototype, { this.hooks.push(new HookObject(availableHook, availableList, plugins, config)); return this; - }, + } - addHooks: function(hooks, plugins, config) { + addHooks(hooks, plugins, config) { hooks.forEach(hook => this.addHook(hook, null, plugins, config)); return this; - }, + } - setConfig: function(obj){ + setConfig(obj) { this.config = obj; - }, + } - fireReady: function() { + fireReady() { const readyEvent = new CustomEvent('ready.dl', { detail: { dropdown: this, @@ -131,10 +131,14 @@ Object.assign(DropLab.prototype, { document.dispatchEvent(readyEvent); this.ready = true; - }, + } - init: function (hook, list, plugins, config) { - hook ? this.addHook(hook, list, plugins, config) : this.loadStatic(); + init(hook, list, plugins, config) { + if (hook) { + this.addHook(hook, list, plugins, config); + } else { + this.loadStatic(); + } this.addEvents(); @@ -146,7 +150,7 @@ Object.assign(DropLab.prototype, { this.queuedData = []; return this; - }, -}); + } +} export default DropLab; diff --git a/app/assets/javascripts/droplab/hook.js b/app/assets/javascripts/droplab/hook.js index 2f840083571..cf78165b0d8 100644 --- a/app/assets/javascripts/droplab/hook.js +++ b/app/assets/javascripts/droplab/hook.js @@ -1,22 +1,15 @@ -/* eslint-disable */ - import DropDown from './drop_down'; -var Hook = function(trigger, list, plugins, config){ - this.trigger = trigger; - this.list = new DropDown(list); - this.type = 'Hook'; - this.event = 'click'; - this.plugins = plugins || []; - this.config = config || {}; - this.id = trigger.id; -}; - -Object.assign(Hook.prototype, { - - addEvents: function(){}, - - constructor: Hook, -}); +class Hook { + constructor(trigger, list, plugins, config) { + this.trigger = trigger; + this.list = new DropDown(list); + this.type = 'Hook'; + this.event = 'click'; + this.plugins = plugins || []; + this.config = config || {}; + this.id = trigger.id; + } +} export default Hook; diff --git a/app/assets/javascripts/droplab/hook_button.js b/app/assets/javascripts/droplab/hook_button.js index be8aead1303..af45eba74e7 100644 --- a/app/assets/javascripts/droplab/hook_button.js +++ b/app/assets/javascripts/droplab/hook_button.js @@ -1,65 +1,58 @@ -/* eslint-disable */ - import Hook from './hook'; -var HookButton = function(trigger, list, plugins, config) { - Hook.call(this, trigger, list, plugins, config); - - this.type = 'button'; - this.event = 'click'; +class HookButton extends Hook { + constructor(trigger, list, plugins, config) { + super(trigger, list, plugins, config); - this.eventWrapper = {}; + this.type = 'button'; + this.event = 'click'; - this.addEvents(); - this.addPlugins(); -}; + this.eventWrapper = {}; -HookButton.prototype = Object.create(Hook.prototype); + this.addEvents(); + this.addPlugins(); + } -Object.assign(HookButton.prototype, { - addPlugins: function() { + addPlugins() { this.plugins.forEach(plugin => plugin.init(this)); - }, + } - clicked: function(e){ - var buttonEvent = new CustomEvent('click.dl', { + clicked(e) { + const buttonEvent = new CustomEvent('click.dl', { detail: { hook: this, }, bubbles: true, - cancelable: true + cancelable: true, }); e.target.dispatchEvent(buttonEvent); this.list.toggle(); - }, + } - addEvents: function(){ + addEvents() { this.eventWrapper.clicked = this.clicked.bind(this); this.trigger.addEventListener('click', this.eventWrapper.clicked); - }, + } - removeEvents: function(){ + removeEvents() { this.trigger.removeEventListener('click', this.eventWrapper.clicked); - }, + } - restoreInitialState: function() { + restoreInitialState() { this.list.list.innerHTML = this.list.initialState; - }, + } - removePlugins: function() { + removePlugins() { this.plugins.forEach(plugin => plugin.destroy()); - }, + } - destroy: function() { + destroy() { this.restoreInitialState(); this.removeEvents(); this.removePlugins(); - }, - - constructor: HookButton, -}); - + } +} export default HookButton; diff --git a/app/assets/javascripts/droplab/hook_input.js b/app/assets/javascripts/droplab/hook_input.js index 05082334045..19131a64f2c 100644 --- a/app/assets/javascripts/droplab/hook_input.js +++ b/app/assets/javascripts/droplab/hook_input.js @@ -1,25 +1,23 @@ -/* eslint-disable */ - import Hook from './hook'; -var HookInput = function(trigger, list, plugins, config) { - Hook.call(this, trigger, list, plugins, config); +class HookInput extends Hook { + constructor(trigger, list, plugins, config) { + super(trigger, list, plugins, config); - this.type = 'input'; - this.event = 'input'; + this.type = 'input'; + this.event = 'input'; - this.eventWrapper = {}; + this.eventWrapper = {}; - this.addEvents(); - this.addPlugins(); -}; + this.addEvents(); + this.addPlugins(); + } -Object.assign(HookInput.prototype, { - addPlugins: function() { + addPlugins() { this.plugins.forEach(plugin => plugin.init(this)); - }, + } - addEvents: function(){ + addEvents() { this.eventWrapper.mousedown = this.mousedown.bind(this); this.eventWrapper.input = this.input.bind(this); this.eventWrapper.keyup = this.keyup.bind(this); @@ -29,19 +27,19 @@ Object.assign(HookInput.prototype, { this.trigger.addEventListener('input', this.eventWrapper.input); this.trigger.addEventListener('keyup', this.eventWrapper.keyup); this.trigger.addEventListener('keydown', this.eventWrapper.keydown); - }, + } - removeEvents: function() { + removeEvents() { this.hasRemovedEvents = true; this.trigger.removeEventListener('mousedown', this.eventWrapper.mousedown); this.trigger.removeEventListener('input', this.eventWrapper.input); this.trigger.removeEventListener('keyup', this.eventWrapper.keyup); this.trigger.removeEventListener('keydown', this.eventWrapper.keydown); - }, + } - input: function(e) { - if(this.hasRemovedEvents) return; + input(e) { + if (this.hasRemovedEvents) return; this.list.show(); @@ -51,12 +49,12 @@ Object.assign(HookInput.prototype, { text: e.target.value, }, bubbles: true, - cancelable: true + cancelable: true, }); e.target.dispatchEvent(inputEvent); - }, + } - mousedown: function(e) { + mousedown(e) { if (this.hasRemovedEvents) return; const mouseEvent = new CustomEvent('mousedown.dl', { @@ -68,21 +66,21 @@ Object.assign(HookInput.prototype, { cancelable: true, }); e.target.dispatchEvent(mouseEvent); - }, + } - keyup: function(e) { + keyup(e) { if (this.hasRemovedEvents) return; this.keyEvent(e, 'keyup.dl'); - }, + } - keydown: function(e) { + keydown(e) { if (this.hasRemovedEvents) return; this.keyEvent(e, 'keydown.dl'); - }, + } - keyEvent: function(e, eventName) { + keyEvent(e, eventName) { this.list.show(); const keyEvent = new CustomEvent(eventName, { @@ -96,17 +94,17 @@ Object.assign(HookInput.prototype, { cancelable: true, }); e.target.dispatchEvent(keyEvent); - }, + } - restoreInitialState: function() { + restoreInitialState() { this.list.list.innerHTML = this.list.initialState; - }, + } - removePlugins: function() { + removePlugins() { this.plugins.forEach(plugin => plugin.destroy()); - }, + } - destroy: function() { + destroy() { this.restoreInitialState(); this.removeEvents(); @@ -114,6 +112,6 @@ Object.assign(HookInput.prototype, { this.list.destroy(); } -}); +} export default HookInput; diff --git a/app/assets/javascripts/environments/components/environment.vue b/app/assets/javascripts/environments/components/environment.vue index e0088d496eb..d4e13f3c84a 100644 --- a/app/assets/javascripts/environments/components/environment.vue +++ b/app/assets/javascripts/environments/components/environment.vue @@ -1,17 +1,19 @@ <script> /* global Flash */ import EnvironmentsService from '../services/environments_service'; -import EnvironmentTable from './environments_table.vue'; +import environmentTable from './environments_table.vue'; import EnvironmentsStore from '../stores/environments_store'; -import TablePaginationComponent from '../../vue_shared/components/table_pagination'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import tablePagination from '../../vue_shared/components/table_pagination.vue'; import '../../lib/utils/common_utils'; import eventHub from '../event_hub'; export default { components: { - 'environment-table': EnvironmentTable, - 'table-pagination': TablePaginationComponent, + environmentTable, + tablePagination, + loadingIcon, }, data() { @@ -186,14 +188,11 @@ export default { </div> <div class="content-list environments-container"> - <div - class="environments-list-loading text-center" - v-if="isLoading"> - - <i - class="fa fa-spinner fa-spin" - aria-hidden="true" /> - </div> + <loading-icon + label="Loading environments" + size="3" + v-if="isLoading" + /> <div class="blank-state blank-state-no-icon" diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index 63bffe8a998..a2448520a5f 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -1,6 +1,7 @@ <script> import playIconSvg from 'icons/_icon_play.svg'; import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { props: { @@ -11,6 +12,10 @@ export default { }, }, + components: { + loadingIcon, + }, + data() { return { playIconSvg, @@ -61,10 +66,7 @@ export default { <i class="fa fa-caret-down" aria-hidden="true"/> - <i - v-if="isLoading" - class="fa fa-spinner fa-spin" - aria-hidden="true"/> + <loading-icon v-if="isLoading" /> </span> </button> diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 0ffe9ea17fa..1f01629aa1b 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -1,5 +1,6 @@ <script> import Timeago from 'timeago.js'; +import _ from 'underscore'; import '../../lib/utils/text_utility'; import ActionsComponent from './environment_actions.vue'; import ExternalUrlComponent from './environment_external_url.vue'; @@ -59,7 +60,7 @@ export default { hasLastDeploymentKey() { if (this.model && this.model.last_deployment && - !this.$options.isObjectEmpty(this.model.last_deployment)) { + !_.isEmpty(this.model.last_deployment)) { return true; } return false; @@ -310,8 +311,8 @@ export default { */ deploymentHasUser() { return this.model && - !this.$options.isObjectEmpty(this.model.last_deployment) && - !this.$options.isObjectEmpty(this.model.last_deployment.user); + !_.isEmpty(this.model.last_deployment) && + !_.isEmpty(this.model.last_deployment.user); }, /** @@ -322,8 +323,8 @@ export default { */ deploymentUser() { if (this.model && - !this.$options.isObjectEmpty(this.model.last_deployment) && - !this.$options.isObjectEmpty(this.model.last_deployment.user)) { + !_.isEmpty(this.model.last_deployment) && + !_.isEmpty(this.model.last_deployment.user)) { return this.model.last_deployment.user; } return {}; @@ -338,8 +339,8 @@ export default { */ shouldRenderBuildName() { return !this.model.isFolder && - !this.$options.isObjectEmpty(this.model.last_deployment) && - !this.$options.isObjectEmpty(this.model.last_deployment.deployable); + !_.isEmpty(this.model.last_deployment) && + !_.isEmpty(this.model.last_deployment.deployable); }, /** @@ -380,7 +381,7 @@ export default { */ shouldRenderDeploymentID() { return !this.model.isFolder && - !this.$options.isObjectEmpty(this.model.last_deployment) && + !_.isEmpty(this.model.last_deployment) && this.model.last_deployment.iid !== undefined; }, @@ -410,21 +411,6 @@ export default { }, }, - /** - * Helper to verify if certain given object are empty. - * Should be replaced by lodash _.isEmpty - https://lodash.com/docs/4.17.2#isEmpty - * @param {Object} object - * @returns {Bollean} - */ - isObjectEmpty(object) { - for (const key in object) { // eslint-disable-line - if (hasOwnProperty.call(object, key)) { - return false; - } - } - return true; - }, - methods: { onClickFolder() { eventHub.$emit('toggleFolder', this.model, this.folderUrl); diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index 44b8730fd09..2ba985bfe3e 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -6,6 +6,7 @@ * Makes a post request when the button is clicked. */ import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { props: { @@ -20,6 +21,10 @@ export default { }, }, + components: { + loadingIcon, + }, + data() { return { isLoading: false, @@ -49,9 +54,6 @@ export default { Rollback </span> - <i - v-if="isLoading" - class="fa fa-spinner fa-spin" - aria-hidden="true" /> + <loading-icon v-if="isLoading" /> </button> </template> diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue index f483ea7e937..a904453ffa9 100644 --- a/app/assets/javascripts/environments/components/environment_stop.vue +++ b/app/assets/javascripts/environments/components/environment_stop.vue @@ -4,6 +4,7 @@ * Used in environments table. */ import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { props: { @@ -19,6 +20,10 @@ export default { }; }, + components: { + loadingIcon, + }, + computed: { title() { return 'Stop'; @@ -51,9 +56,6 @@ export default { <i class="fa fa-stop stop-env-icon" aria-hidden="true" /> - <i - v-if="isLoading" - class="fa fa-spinner fa-spin" - aria-hidden="true" /> + <loading-icon v-if="isLoading" /> </button> </template> diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 15eedaf76e1..5148a2ae79b 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -3,10 +3,12 @@ * Render environments table. */ import EnvironmentTableRowComponent from './environment_item.vue'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { components: { 'environment-item': EnvironmentTableRowComponent, + loadingIcon, }, props: { @@ -77,10 +79,8 @@ export default { <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0"> <tr v-if="isLoadingFolderContent"> - <td colspan="6" class="text-center"> - <i - class="fa fa-spin fa-spinner fa-2x" - aria-hidden="true" /> + <td colspan="6"> + <loading-icon size="2" /> </td> </tr> diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index f4a0c390c91..bd161c8a379 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -1,16 +1,18 @@ <script> /* global Flash */ import EnvironmentsService from '../services/environments_service'; -import EnvironmentTable from '../components/environments_table.vue'; +import environmentTable from '../components/environments_table.vue'; import EnvironmentsStore from '../stores/environments_store'; -import TablePaginationComponent from '../../vue_shared/components/table_pagination'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import tablePagination from '../../vue_shared/components/table_pagination.vue'; import '../../lib/utils/common_utils'; import '../../vue_shared/vue_resource_interceptor'; export default { components: { - 'environment-table': EnvironmentTable, - 'table-pagination': TablePaginationComponent, + environmentTable, + tablePagination, + loadingIcon, }, data() { @@ -153,13 +155,12 @@ export default { </div> <div class="environments-container"> - <div - class="environments-list-loading text-center" - v-if="isLoading"> - <i - class="fa fa-spinner fa-spin" - aria-hidden="true"/> - </div> + + <loading-icon + label="Loading environments" + v-if="isLoading" + size="3" + /> <div class="table-holder" diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js index 59d6508fc02..534e651b030 100644 --- a/app/assets/javascripts/files_comment_button.js +++ b/app/assets/javascripts/files_comment_button.js @@ -3,7 +3,6 @@ /* global notes */ let $commentButtonTemplate; -var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; window.FilesCommentButton = (function() { var COMMENT_BUTTON_CLASS, EMPTY_CELL_CLASS, LINE_COLUMN_CLASSES, LINE_CONTENT_CLASS, LINE_HOLDER_CLASS, LINE_NUMBER_CLASS, OLD_LINE_CLASS, TEXT_FILE_SELECTOR, UNFOLDABLE_LINE_CLASS; @@ -27,8 +26,8 @@ window.FilesCommentButton = (function() { TEXT_FILE_SELECTOR = '.text-file'; function FilesCommentButton(filesContainerElement) { - this.render = bind(this.render, this); - this.hideButton = bind(this.hideButton, this); + this.render = this.render.bind(this); + this.hideButton = this.hideButton.bind(this); this.isParallelView = notes.isParallelView(); filesContainerElement.on('mouseover', LINE_COLUMN_CLASSES, this.render) .on('mouseleave', LINE_COLUMN_CLASSES, this.hideButton); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 9fea563370f..57d247e11a9 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -16,10 +16,14 @@ class FilteredSearchManager { this.recentSearchesStore = new RecentSearchesStore({ isLocalStorageAvailable: RecentSearchesService.isAvailable(), }); - let recentSearchesKey = 'issue-recent-searches'; + const searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown'); + const projectPath = searchHistoryDropdownElement ? + searchHistoryDropdownElement.dataset.projectFullPath : 'project'; + let recentSearchesPagePrefix = 'issue-recent-searches'; if (page === 'merge_requests') { - recentSearchesKey = 'merge-request-recent-searches'; + recentSearchesPagePrefix = 'merge-request-recent-searches'; } + const recentSearchesKey = `${projectPath}-${recentSearchesPagePrefix}`; this.recentSearchesService = new RecentSearchesService(recentSearchesKey); // Fetch recent searches from localStorage @@ -47,7 +51,7 @@ class FilteredSearchManager { this.recentSearchesRoot = new RecentSearchesRoot( this.recentSearchesStore, this.recentSearchesService, - document.querySelector('.js-filtered-search-history-dropdown'), + searchHistoryDropdownElement, ); this.recentSearchesRoot.init(); diff --git a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js index 066be69766a..35fc15e4c87 100644 --- a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js +++ b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js @@ -3,6 +3,7 @@ import _ from 'underscore'; class RecentSearchesStore { constructor(initialState = {}) { this.state = Object.assign({ + isLocalStorageAvailable: true, recentSearches: [], }, initialState); } diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 0c9eb84f0eb..24c423dd01e 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -1,9 +1,8 @@ /* eslint-disable func-names, 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 { isObject } from './lib/utils/type_utility'; -var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote, - bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, - indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; }; +var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote; GitLabDropdownFilter = (function() { var ARROW_KEY_CODES, BLUR_KEYCODES, HAS_VALUE_CLASS; @@ -95,7 +94,7 @@ GitLabDropdownFilter = (function() { // { prop: 'def' } // ] // } - if (gl.utils.isObject(data)) { + if (isObject(data)) { results = {}; for (key in data) { group = data[key]; @@ -213,10 +212,10 @@ GitLabDropdown = (function() { var searchFields, selector, self; this.el = el1; this.options = options; - this.updateLabel = bind(this.updateLabel, this); - this.hidden = bind(this.hidden, this); - this.opened = bind(this.opened, this); - this.shouldPropagate = bind(this.shouldPropagate, this); + this.updateLabel = this.updateLabel.bind(this); + this.hidden = this.hidden.bind(this); + this.opened = this.opened.bind(this); + this.shouldPropagate = this.shouldPropagate.bind(this); self = this; selector = $(this.el).data("target"); this.dropdown = selector != null ? $(selector) : $(this.el).parent(); @@ -398,7 +397,7 @@ GitLabDropdown = (function() { html = [this.noResults()]; } else { // Handle array groups - if (gl.utils.isObject(data)) { + if (isObject(data)) { html = []; for (name in data) { groupData = data[name]; @@ -610,7 +609,12 @@ GitLabDropdown = (function() { var link = document.createElement('a'); link.href = url; - link.innerHTML = text; + + if (this.highlight) { + link.innerHTML = text; + } else { + link.textContent = text; + } if (selected) { link.className = 'is-active'; @@ -627,8 +631,8 @@ GitLabDropdown = (function() { }; GitLabDropdown.prototype.highlightTextMatches = function(text, term) { - var occurrences; - occurrences = fuzzaldrinPlus.match(text, term); + const occurrences = fuzzaldrinPlus.match(text, term); + const indexOf = [].indexOf; return text.split('').map(function(character, i) { if (indexOf.call(occurrences, i) !== -1) { return "<b>" + character + "</b>"; diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js index 521bc77db66..0deb27e522b 100644 --- a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js +++ b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js @@ -2,7 +2,6 @@ import d3 from 'd3'; -const bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; const extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; const hasProp = {}.hasOwnProperty; @@ -95,7 +94,7 @@ export const ContributorsMasterGraph = (function(superClass) { function ContributorsMasterGraph(data1) { this.data = data1; - this.update_content = bind(this.update_content, this); + this.update_content = this.update_content.bind(this); this.width = $('.content').width() - 70; this.height = 200; this.x = null; diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index 834b98e8601..4520e990e6f 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -1,8 +1,8 @@ /* 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 */ -/* global UsersSelect */ /* global bp */ import Cookies from 'js-cookie'; +import UsersSelect from './users_select'; (function() { this.IssuableContext = (function() { diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 687c2bb6110..4310663e0b6 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -1,14 +1,13 @@ /* 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 */ /* global GitLab */ -/* global UsersSelect */ /* global ZenMode */ /* global Autosave */ /* global dateFormat */ /* global Pikaday */ -(function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; +import UsersSelect from './users_select'; +(function() { this.IssuableForm = (function() { IssuableForm.prototype.issueMoveConfirmMsg = 'Are you sure you want to move this issue to another project?'; @@ -17,10 +16,10 @@ function IssuableForm(form) { var $issuableDueDate, calendar; this.form = form; - this.toggleWip = bind(this.toggleWip, this); - this.renderWipExplanation = bind(this.renderWipExplanation, this); - this.resetAutosave = bind(this.resetAutosave, this); - this.handleSubmit = bind(this.handleSubmit, this); + this.toggleWip = this.toggleWip.bind(this); + this.renderWipExplanation = this.renderWipExplanation.bind(this); + this.resetAutosave = this.resetAutosave.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); gl.GfmAutoComplete.setup(); new UsersSelect(); new ZenMode(); diff --git a/app/assets/javascripts/issue_show/actions/tasks.js b/app/assets/javascripts/issue_show/actions/tasks.js deleted file mode 100644 index 0740a9f559c..00000000000 --- a/app/assets/javascripts/issue_show/actions/tasks.js +++ /dev/null @@ -1,27 +0,0 @@ -export default (newStateData, tasks) => { - const $tasks = $('#task_status'); - const $tasksShort = $('#task_status_short'); - const $issueableHeader = $('.issuable-header'); - const tasksStates = { newState: null, currentState: null }; - - if ($tasks.length === 0) { - if (!(newStateData.task_status.indexOf('0 of 0') === 0)) { - $issueableHeader.append(`<span id="task_status">${newStateData.task_status}</span>`); - } else { - $issueableHeader.append('<span id="task_status"></span>'); - } - } else { - tasksStates.newState = newStateData.task_status.indexOf('0 of 0') === 0; - tasksStates.currentState = tasks.indexOf('0 of 0') === 0; - } - - if ($tasks.length !== 0 && !tasksStates.newState) { - $tasks.text(newStateData.task_status); - $tasksShort.text(newStateData.task_status); - } else if (tasksStates.currentState) { - $issueableHeader.append(`<span id="task_status">${newStateData.task_status}</span>`); - } else if (tasksStates.newState) { - $tasks.remove(); - $tasksShort.remove(); - } -}; diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue new file mode 100644 index 00000000000..770a0dcd27e --- /dev/null +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -0,0 +1,96 @@ +<script> +import Visibility from 'visibilityjs'; +import Poll from '../../lib/utils/poll'; +import Service from '../services/index'; +import Store from '../stores'; +import titleComponent from './title.vue'; +import descriptionComponent from './description.vue'; + +export default { + props: { + endpoint: { + required: true, + type: String, + }, + canUpdate: { + required: true, + type: Boolean, + }, + issuableRef: { + type: String, + required: true, + }, + initialTitle: { + type: String, + required: true, + }, + initialDescriptionHtml: { + type: String, + required: false, + default: '', + }, + initialDescriptionText: { + type: String, + required: false, + default: '', + }, + }, + data() { + const store = new Store({ + titleHtml: this.initialTitle, + descriptionHtml: this.initialDescriptionHtml, + descriptionText: this.initialDescriptionText, + }); + + return { + store, + state: store.state, + }; + }, + components: { + descriptionComponent, + titleComponent, + }, + created() { + const resource = new Service(this.endpoint); + const poll = new Poll({ + resource, + method: 'getData', + successCallback: (res) => { + this.store.updateState(res.json()); + }, + errorCallback(err) { + throw new Error(err); + }, + }); + + if (!Visibility.hidden()) { + poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + poll.restart(); + } else { + poll.stop(); + } + }); + }, +}; +</script> + +<template> + <div> + <title-component + :issuable-ref="issuableRef" + :title-html="state.titleHtml" + :title-text="state.titleText" /> + <description-component + v-if="state.descriptionHtml" + :can-update="canUpdate" + :description-html="state.descriptionHtml" + :description-text="state.descriptionText" + :updated-at="state.updatedAt" + :task-status="state.taskStatus" /> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue new file mode 100644 index 00000000000..4ad3eb7dfd7 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -0,0 +1,105 @@ +<script> + import animateMixin from '../mixins/animate'; + + export default { + mixins: [animateMixin], + props: { + canUpdate: { + type: Boolean, + required: true, + }, + descriptionHtml: { + type: String, + required: true, + }, + descriptionText: { + type: String, + required: true, + }, + updatedAt: { + type: String, + required: true, + }, + taskStatus: { + type: String, + required: true, + }, + }, + data() { + return { + preAnimation: false, + pulseAnimation: false, + timeAgoEl: $('.js-issue-edited-ago'), + }; + }, + watch: { + descriptionHtml() { + this.animateChange(); + + this.$nextTick(() => { + const toolTipTime = gl.utils.formatDate(this.updatedAt); + + this.timeAgoEl.attr('datetime', this.updatedAt) + .attr('title', toolTipTime) + .tooltip('fixTitle'); + + this.renderGFM(); + }); + }, + taskStatus() { + const taskRegexMatches = this.taskStatus.match(/(\d+) of (\d+)/); + const $issuableHeader = $('.issuable-meta'); + const $tasks = $('#task_status', $issuableHeader); + const $tasksShort = $('#task_status_short', $issuableHeader); + + if (taskRegexMatches) { + $tasks.text(this.taskStatus); + $tasksShort.text(`${taskRegexMatches[1]}/${taskRegexMatches[2]} task${taskRegexMatches[2] > 1 ? 's' : ''}`); + } else { + $tasks.text(''); + $tasksShort.text(''); + } + }, + }, + methods: { + renderGFM() { + $(this.$refs['gfm-entry-content']).renderGFM(); + + if (this.canUpdate) { + // eslint-disable-next-line no-new + new gl.TaskList({ + dataType: 'issue', + fieldName: 'description', + selector: '.detail-page-description', + }); + } + }, + }, + mounted() { + this.renderGFM(); + }, + }; +</script> + +<template> + <div + class="description" + :class="{ + 'js-task-list-container': canUpdate + }"> + <div + class="wiki" + :class="{ + 'issue-realtime-pre-pulse': preAnimation, + 'issue-realtime-trigger-pulse': pulseAnimation + }" + v-html="descriptionHtml" + ref="gfm-content"> + </div> + <textarea + class="hidden js-task-list-field" + v-if="descriptionText" + v-model="descriptionText"> + </textarea> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue new file mode 100644 index 00000000000..a9dabd4cff1 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -0,0 +1,53 @@ +<script> + import animateMixin from '../mixins/animate'; + + export default { + mixins: [animateMixin], + data() { + return { + preAnimation: false, + pulseAnimation: false, + titleEl: document.querySelector('title'), + }; + }, + props: { + issuableRef: { + type: String, + required: true, + }, + titleHtml: { + type: String, + required: true, + }, + titleText: { + type: String, + required: true, + }, + }, + watch: { + titleHtml() { + this.setPageTitle(); + this.animateChange(); + }, + }, + methods: { + setPageTitle() { + const currentPageTitleScope = this.titleEl.innerText.split('·'); + currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `; + this.titleEl.textContent = currentPageTitleScope.join('·'); + }, + }, + }; +</script> + +<template> + <h2 + class="title" + :class="{ + 'issue-realtime-pre-pulse': preAnimation, + 'issue-realtime-trigger-pulse': pulseAnimation + }" + v-html="titleHtml" + > + </h2> +</template> diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index eb20a597bb5..f06e33dee60 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,20 +1,42 @@ import Vue from 'vue'; -import IssueTitle from './issue_title_description.vue'; +import issuableApp from './components/app.vue'; import '../vue_shared/vue_resource_interceptor'; -(() => { - const issueTitleData = document.querySelector('.issue-title-data').dataset; - const { canUpdateTasksClass, endpoint } = issueTitleData; +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: document.getElementById('js-issuable-app'), + components: { + issuableApp, + }, + data() { + const issuableElement = this.$options.el; + const issuableTitleElement = issuableElement.querySelector('.title'); + const issuableDescriptionElement = issuableElement.querySelector('.wiki'); + const issuableDescriptionTextarea = issuableElement.querySelector('.js-task-list-field'); + const { + canUpdate, + endpoint, + issuableRef, + } = issuableElement.dataset; - const vm = new Vue({ - el: '.issue-title-entrypoint', - render: createElement => createElement(IssueTitle, { + return { + canUpdate: gl.utils.convertPermissionToBoolean(canUpdate), + endpoint, + issuableRef, + initialTitle: issuableTitleElement.innerHTML, + initialDescriptionHtml: issuableDescriptionElement ? issuableDescriptionElement.innerHTML : '', + initialDescriptionText: issuableDescriptionTextarea ? issuableDescriptionTextarea.textContent : '', + }; + }, + render(createElement) { + return createElement('issuable-app', { props: { - canUpdateTasksClass, - endpoint, + canUpdate: this.canUpdate, + endpoint: this.endpoint, + issuableRef: this.issuableRef, + initialTitle: this.initialTitle, + initialDescriptionHtml: this.initialDescriptionHtml, + initialDescriptionText: this.initialDescriptionText, }, - }), - }); - - return vm; -})(); + }); + }, +})); diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue deleted file mode 100644 index dc3ba2550c5..00000000000 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ /dev/null @@ -1,180 +0,0 @@ -<script> -import Visibility from 'visibilityjs'; -import Poll from './../lib/utils/poll'; -import Service from './services/index'; -import tasks from './actions/tasks'; - -export default { - props: { - endpoint: { - required: true, - type: String, - }, - canUpdateTasksClass: { - required: true, - type: String, - }, - }, - data() { - const resource = new Service(this.$http, this.endpoint); - - const poll = new Poll({ - resource, - method: 'getTitle', - successCallback: (res) => { - this.renderResponse(res); - }, - errorCallback: (err) => { - throw new Error(err); - }, - }); - - return { - poll, - apiData: {}, - tasks: '0 of 0', - title: null, - titleText: '', - titleFlag: { - pre: true, - pulse: false, - }, - description: null, - descriptionText: '', - descriptionChange: false, - descriptionFlag: { - pre: true, - pulse: false, - }, - timeAgoEl: $('.issue_edited_ago'), - titleEl: document.querySelector('title'), - }; - }, - methods: { - updateFlag(key, toggle) { - this[key].pre = toggle; - this[key].pulse = !toggle; - }, - renderResponse(res) { - this.apiData = res.json(); - this.triggerAnimation(); - }, - updateTaskHTML() { - tasks(this.apiData, this.tasks); - }, - elementsToVisualize(noTitleChange, noDescriptionChange) { - if (!noTitleChange) { - this.titleText = this.apiData.title_text; - this.updateFlag('titleFlag', true); - } - - if (!noDescriptionChange) { - // only change to true when we need to bind TaskLists the html of description - this.descriptionChange = true; - this.updateTaskHTML(); - this.tasks = this.apiData.task_status; - this.updateFlag('descriptionFlag', true); - } - }, - setTabTitle() { - const currentTabTitleScope = this.titleEl.innerText.split('·'); - currentTabTitleScope[0] = `${this.titleText} (#${this.apiData.issue_number}) `; - this.titleEl.innerText = currentTabTitleScope.join('·'); - }, - animate(title, description) { - this.title = title; - this.description = description; - this.setTabTitle(); - - this.$nextTick(() => { - this.updateFlag('titleFlag', false); - this.updateFlag('descriptionFlag', false); - }); - }, - triggerAnimation() { - // always reset to false before checking the change - this.descriptionChange = false; - - const { title, description } = this.apiData; - this.descriptionText = this.apiData.description_text; - - const noTitleChange = this.title === title; - const noDescriptionChange = this.description === description; - - /** - * since opacity is changed, even if there is no diff for Vue to update - * we must check the title/description even on a 304 to ensure no visual change - */ - if (noTitleChange && noDescriptionChange) return; - - this.elementsToVisualize(noTitleChange, noDescriptionChange); - this.animate(title, description); - }, - updateEditedTimeAgo() { - const toolTipTime = gl.utils.formatDate(this.apiData.updated_at); - this.timeAgoEl.attr('datetime', this.apiData.updated_at); - this.timeAgoEl.attr('title', toolTipTime).tooltip('fixTitle'); - }, - }, - created() { - if (!Visibility.hidden()) { - this.poll.makeRequest(); - } - - Visibility.change(() => { - if (!Visibility.hidden()) { - this.poll.restart(); - } else { - this.poll.stop(); - } - }); - }, - updated() { - // if new html is injected (description changed) - bind TaskList and call renderGFM - if (this.descriptionChange) { - this.updateEditedTimeAgo(); - - $(this.$refs['issue-content-container-gfm-entry']).renderGFM(); - - const tl = new gl.TaskList({ - dataType: 'issue', - fieldName: 'description', - selector: '.detail-page-description', - }); - - return tl && null; - } - - return null; - }, -}; -</script> - -<template> - <div> - <h2 - class="title" - :class="{ 'issue-realtime-pre-pulse': titleFlag.pre, 'issue-realtime-trigger-pulse': titleFlag.pulse }" - ref="issue-title" - v-html="title" - > - </h2> - <div - class="description is-task-list-enabled" - :class="canUpdateTasksClass" - v-if="description" - > - <div - class="wiki" - :class="{ 'issue-realtime-pre-pulse': descriptionFlag.pre, 'issue-realtime-trigger-pulse': descriptionFlag.pulse }" - v-html="description" - ref="issue-content-container-gfm-entry" - > - </div> - <textarea - class="hidden js-task-list-field" - v-if="descriptionText" - >{{descriptionText}}</textarea> - </div> - </div> -</template> diff --git a/app/assets/javascripts/issue_show/mixins/animate.js b/app/assets/javascripts/issue_show/mixins/animate.js new file mode 100644 index 00000000000..eda6302aa8b --- /dev/null +++ b/app/assets/javascripts/issue_show/mixins/animate.js @@ -0,0 +1,13 @@ +export default { + methods: { + animateChange() { + this.preAnimation = true; + this.pulseAnimation = false; + + this.$nextTick(() => { + this.preAnimation = false; + this.pulseAnimation = true; + }); + }, + }, +}; diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js index c4ab0b1e07a..348ad8d6813 100644 --- a/app/assets/javascripts/issue_show/services/index.js +++ b/app/assets/javascripts/issue_show/services/index.js @@ -1,10 +1,16 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + export default class Service { - constructor(resource, endpoint) { - this.resource = resource; + constructor(endpoint) { this.endpoint = endpoint; + + this.resource = Vue.resource(this.endpoint); } - getTitle() { - return this.resource.get(this.endpoint); + getData() { + return this.resource.get(); } } diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js new file mode 100644 index 00000000000..8e89a2b7730 --- /dev/null +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -0,0 +1,25 @@ +export default class Store { + constructor({ + titleHtml, + descriptionHtml, + descriptionText, + }) { + this.state = { + titleHtml, + titleText: '', + descriptionHtml, + descriptionText, + taskStatus: '', + updatedAt: '', + }; + } + + updateState(data) { + this.state.titleHtml = data.title; + this.state.titleText = data.title_text; + this.state.descriptionHtml = data.description; + this.state.descriptionText = data.description_text; + this.state.taskStatus = data.task_status; + this.state.updatedAt = data.updated_at; + } +} diff --git a/app/assets/javascripts/labels.js b/app/assets/javascripts/labels.js index 17a3fc1b1e4..03dd61b4263 100644 --- a/app/assets/javascripts/labels.js +++ b/app/assets/javascripts/labels.js @@ -1,11 +1,9 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, vars-on-top, no-unused-vars, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Labels = (function() { function Labels() { - this.setSuggestedColor = bind(this.setSuggestedColor, this); - this.updateColorPreview = bind(this.updateColorPreview, this); + this.setSuggestedColor = this.setSuggestedColor.bind(this); + this.updateColorPreview = this.updateColorPreview.bind(this); var form; form = $('.label-form'); this.cleanBinding(); diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index a5f99bcdd8f..71064ccc539 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -1,4 +1,5 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, no-unused-vars, one-var, one-var-declaration-per-line, vars-on-top, max-len */ +import _ from 'underscore'; (function() { var hideEndFade; @@ -45,4 +46,13 @@ } }); }); + + function applyScrollNavClass() { + const scrollOpacityHeight = 40; + $('.navbar-border').css('opacity', Math.min($(window).scrollTop() / scrollOpacityHeight, 1)); + } + + $(() => { + $(window).on('scroll', _.throttle(applyScrollNavClass, 100)); + }); }).call(window); diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js index d99eefb5089..cf030d613df 100644 --- a/app/assets/javascripts/lib/utils/ajax_cache.js +++ b/app/assets/javascripts/lib/utils/ajax_cache.js @@ -1,32 +1,54 @@ -const AjaxCache = { - internalStorage: { }, +class AjaxCache { + constructor() { + this.internalStorage = { }; + this.pendingRequests = { }; + } + get(endpoint) { return this.internalStorage[endpoint]; - }, + } + hasData(endpoint) { return Object.prototype.hasOwnProperty.call(this.internalStorage, endpoint); - }, - purge(endpoint) { + } + + remove(endpoint) { delete this.internalStorage[endpoint]; - }, + } + retrieve(endpoint) { - if (AjaxCache.hasData(endpoint)) { - return Promise.resolve(AjaxCache.get(endpoint)); + if (this.hasData(endpoint)) { + return Promise.resolve(this.get(endpoint)); } - return new Promise((resolve, reject) => { - $.ajax(endpoint) // eslint-disable-line promise/catch-or-return - .then(data => resolve(data), - (jqXHR, textStatus, errorThrown) => { - const error = new Error(`${endpoint}: ${errorThrown}`); - error.textStatus = textStatus; - reject(error); - }, - ); - }) - .then((data) => { this.internalStorage[endpoint] = data; }) - .then(() => AjaxCache.get(endpoint)); - }, -}; - -export default AjaxCache; + let pendingRequest = this.pendingRequests[endpoint]; + + if (!pendingRequest) { + pendingRequest = new Promise((resolve, reject) => { + // jQuery 2 is not Promises/A+ compatible (missing catch) + $.ajax(endpoint) // eslint-disable-line promise/catch-or-return + .then(data => resolve(data), + (jqXHR, textStatus, errorThrown) => { + const error = new Error(`${endpoint}: ${errorThrown}`); + error.textStatus = textStatus; + reject(error); + }, + ); + }) + .then((data) => { + this.internalStorage[endpoint] = data; + delete this.pendingRequests[endpoint]; + }) + .catch((error) => { + delete this.pendingRequests[endpoint]; + throw error; + }); + + this.pendingRequests[endpoint] = pendingRequest; + } + + return pendingRequest.then(() => this.get(endpoint)); + } +} + +export default new AjaxCache(); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 2f682fbd2fb..7e62773ae6c 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -135,7 +135,10 @@ gl.utils.getUrlParamsArray = function () { // We can trust that each param has one & since values containing & will be encoded // Remove the first character of search as it is always ? - return window.location.search.slice(1).split('&'); + return window.location.search.slice(1).split('&').map((param) => { + const split = param.split('='); + return [decodeURI(split[0]), split[1]].join('='); + }); }; gl.utils.isMetaKey = function(e) { diff --git a/app/assets/javascripts/lib/utils/simple_poll.js b/app/assets/javascripts/lib/utils/simple_poll.js new file mode 100644 index 00000000000..25ca98afbe7 --- /dev/null +++ b/app/assets/javascripts/lib/utils/simple_poll.js @@ -0,0 +1,15 @@ +export default (fn, interval = 2000, timeout = 60000) => { + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + const stop = arg => ((arg instanceof Error) ? reject(arg) : resolve(arg)); + const next = () => { + if (Date.now() - startTime < timeout) { + setTimeout(fn.bind(null, next, stop), interval); + } else { + reject(new Error('SIMPLE_POLL_TIMEOUT')); + } + }; + fn(next, stop); + }); +}; diff --git a/app/assets/javascripts/lib/utils/type_utility.js b/app/assets/javascripts/lib/utils/type_utility.js index db62e0be324..be86f336bcd 100644 --- a/app/assets/javascripts/lib/utils/type_utility.js +++ b/app/assets/javascripts/lib/utils/type_utility.js @@ -1,15 +1,2 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, no-return-assign, max-len */ -(function() { - (function(w) { - var base; - if (w.gl == null) { - w.gl = {}; - } - if ((base = w.gl).utils == null) { - base.utils = {}; - } - return w.gl.utils.isObject = function(obj) { - return (obj != null) && (obj.constructor === Object); - }; - })(window); -}).call(window); +// eslint-disable-next-line import/prefer-default-export +export const isObject = obj => obj && obj.constructor === Object; diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index 3ac6dedf131..517f03d5aba 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/line_highlighter.js @@ -31,8 +31,6 @@ require('vendor/jquery.scrollTo'); // </div> // (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.LineHighlighter = (function() { // CSS class applied to highlighted lines LineHighlighter.prototype.highlightClass = 'hll'; @@ -47,9 +45,9 @@ require('vendor/jquery.scrollTo'); // hash - String URL hash for dependency injection in tests hash = location.hash; } - this.setHash = bind(this.setHash, this); - this.highlightLine = bind(this.highlightLine, this); - this.clickHandler = bind(this.clickHandler, this); + this.setHash = this.setHash.bind(this); + this.highlightLine = this.highlightLine.bind(this); + this.clickHandler = this.clickHandler.bind(this); this.highlightHash = this.highlightHash.bind(this); this._hash = hash; this.bindEvents(); diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js index e96090da80e..9411f078ecf 100644 --- a/app/assets/javascripts/locale/de/app.js +++ b/app/assets/javascripts/locale/de/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}};
\ No newline at end of file +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-09 13:44+0200","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["Von"],"Commit":["Commit","Commits"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics liefern einen Überblick darüber, wie viel Zeit in Ihrem Projekt von einer Idee bis zum Produktivdeployment vergeht."],"CycleAnalyticsStage|Code":["Code"],"CycleAnalyticsStage|Issue":["Issue"],"CycleAnalyticsStage|Plan":["Planung"],"CycleAnalyticsStage|Production":["Produktiv"],"CycleAnalyticsStage|Review":["Review"],"CycleAnalyticsStage|Staging":["Staging"],"CycleAnalyticsStage|Test":["Test"],"Deploy":["Deployment","Deployments"],"FirstPushedBy|First":["Erster"],"FirstPushedBy|pushed by":["gepusht von"],"From issue creation until deploy to production":["Vom Anlegen des Issues bis zum Produktivdeployment"],"From merge request merge until deploy to production":["Vom Merge Request bis zum Produktivdeployment"],"Introducing Cycle Analytics":["Was sind Cycle Analytics?"],"Last %d day":["Letzter %d Tag","Letzten %d Tage"],"Limited to showing %d event at most":["Eingeschränkt auf maximal %d Ereignis","Eingeschränkt auf maximal %d Ereignisse"],"Median":["Median"],"New Issue":["Neues Issue","Neue Issues"],"Not available":["Nicht verfügbar"],"Not enough data":["Nicht genügend Daten"],"OpenedNDaysAgo|Opened":["Erstellt"],"Pipeline Health":["Pipeline Kennzahlen"],"ProjectLifecycle|Stage":["Phase"],"Read more":["Mehr"],"Related Commits":["Zugehörige Commits"],"Related Deployed Jobs":["Zugehörige Deploymentjobs"],"Related Issues":["Zugehörige Issues"],"Related Jobs":["Zugehörige Jobs"],"Related Merge Requests":["Zugehörige Merge Requests"],"Related Merged Requests":["Zugehörige abgeschlossene Merge Requests"],"Showing %d event":["Zeige %d Ereignis","Zeige %d Ereignisse"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["Die Code-Phase stellt die Zeit vom ersten Commit bis zum Erstellen eines Merge Requests dar. Sobald Sie Ihren ersten Merge Request anlegen, werden dessen Daten automatisch ergänzt."],"The collection of events added to the data gathered for that stage.":["Ereignisse, die für diese Phase ausgewertet wurden."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["Die Issue-Phase stellt die Zeit vom Anlegen eines Issues bis zum Zuweisen eines Meilensteins oder Hinzufügen zum Issue Board dar. Erstellen Sie einen Issue, damit dessen Daten hier erscheinen."],"The phase of the development lifecycle.":["Die Phase im Entwicklungsprozess."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["Die Planungsphase stellt die Zeit von der vorherigen Phase bis zum Pushen des ersten Commits dar. Sobald Sie den ersten Commit pushen, werden dessen Daten hier erscheinen."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["Die Produktiv-Phase stellt die Gesamtzeit vom Anlegen eines Issues bis zum Deployment auf dem Produktivsystem dar. Sobald Sie den vollständigen Entwicklungszyklus von einer Idee bis zum Produktivdeployment durchlaufen haben, erscheinen die zugehörigen Daten hier."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["Die Review-Phase stellt die Zeit vom Anlegen eines Merge Requests bis zum Mergen dar. Sobald Sie Ihren ersten Merge Request abschließen, werden dessen Daten hier automatisch angezeigt."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["Die Staging-Phase stellt die Zeit zwischen Mergen eines Merge Requests und dem Produktivdeployment dar. Sobald Sie das erste Produktivdeployment durchgeführt haben, werden dessen Daten hier automatisch angezeigt."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["Die Test-Phase stellt die Zeit dar, die GitLab CI benötigt um die Pipelines von Merge Requests abzuarbeiten. Sobald die erste Pipeline abgeschlossen ist, werden deren Daten hier automatisch angezeigt."],"The time taken by each data entry gathered by that stage.":["Zeit die für das jeweilige Ereignis in der Phase ermittelt wurde."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["Der mittlere aller erfassten Werte. Zum Beispiel ist für 3, 5, 9 der Median 5. Bei 3, 5, 7, 8 ist der Median (5+7)/2 = 6."],"Time before an issue gets scheduled":["Zeit bis ein Issue geplant wird"],"Time before an issue starts implementation":["Zeit bis die Implementierung für ein Issue beginnt"],"Time between merge request creation and merge/close":["Zeit zwischen Anlegen und Mergen/Schließen eines Merge Requests"],"Time until first merge request":["Zeit bis zum ersten Merge Request"],"Time|hr":["h","h"],"Time|min":["min","min"],"Time|s":["s"],"Total Time":["Gesamtzeit"],"Total test time for all commits/merges":["Gesamte Testlaufzeit für alle Commits/Merges"],"Want to see the data? Please ask an administrator for access.":["Um diese Daten einsehen zu können, wenden Sie sich bitte an Ihren Administrator."],"We don't have enough data to show this stage.":["Es liegen nicht genügend Daten vor, um diese Phase anzuzeigen."],"You need permission.":["Sie benötigen Zugriffsrechte."],"day":["Tag","Tage"]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 1b0d5fc92e3..30636f6afec 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -59,7 +59,6 @@ import './lib/utils/datetime_utility'; import './lib/utils/notify'; import './lib/utils/pretty_time'; import './lib/utils/text_utility'; -import './lib/utils/type_utility'; import './lib/utils/url_utility'; // u2f @@ -123,8 +122,6 @@ import './member_expiration_date'; import './members'; import './merge_request'; import './merge_request_tabs'; -import './merge_request_widget'; -import './merged_buttons'; import './milestone'; import './milestone_select'; import './mini_pipeline_graph_dropdown'; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 5e01aacf2ba..d1cdcadf87d 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -6,8 +6,6 @@ require('./task_list'); require('./merge_request_tabs'); (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.MergeRequest = (function() { function MergeRequest(opts) { // Initialize MergeRequest behavior @@ -16,7 +14,7 @@ require('./merge_request_tabs'); // action - String, current controller action // this.opts = opts != null ? opts : {}; - this.submitNoteForm = bind(this.submitNoteForm, this); + this.submitNoteForm = this.submitNoteForm.bind(this); this.$el = $('.merge-request'); this.$('.show-all-commits').on('click', (function(_this) { return function() { @@ -106,6 +104,21 @@ require('./merge_request_tabs'); }); }; + MergeRequest.prototype.updateStatusText = function(classToRemove, classToAdd, newStatusText) { + $('.detail-page-header .status-box') + .removeClass(classToRemove) + .addClass(classToAdd) + .find('span') + .text(newStatusText); + }; + + MergeRequest.prototype.decreaseCounter = function(by = 1) { + const $el = $('.nav-links .js-merge-counter'); + const count = Math.max((parseInt($el.text().replace(/[^\d]/, ''), 10) - by), 0); + + $el.text(gl.text.addDelimiter(count)); + }; + return MergeRequest; })(); }).call(window); diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 93c30c54a8e..37822dac064 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -1,6 +1,7 @@ /* eslint-disable no-new, class-methods-use-this */ /* global Breakpoints */ /* global Flash */ +/* global notes */ import Cookies from 'js-cookie'; import './breakpoints'; @@ -251,7 +252,8 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; this.ajaxGet({ url: `${urlPathname}.json${location.search}`, success: (data) => { - $('#diffs').html(data.html); + const $container = $('#diffs'); + $container.html(data.html); if (typeof gl.diffNotesCompileComponents !== 'undefined') { gl.diffNotesCompileComponents(); @@ -278,6 +280,20 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; }) .init(); }); + + // Scroll any linked note into view + // Similar to `toggler_behavior` in the discussion tab + const hash = window.gl.utils.getLocationHash(); + const anchor = hash && $container.find(`[id="${hash}"]`); + if (anchor) { + const notesContent = anchor.closest('.notes_content'); + const lineType = notesContent.hasClass('new') ? 'new' : 'old'; + notes.addDiffNote(anchor, lineType, false); + anchor[0].scrollIntoView(); + // We have multiple elements on the page with `#note_xxx` + // (discussion and diff tabs) and `:target` only applies to the first + anchor.addClass('target'); + } }, }); } @@ -353,18 +369,26 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; initAffix() { const $tabs = $('.js-tabs-affix'); + const $fixedNav = $('.navbar-gitlab'); // Screen space on small screens is usually very sparse // So we dont affix the tabs on these if (Breakpoints.get().getBreakpointSize() === 'xs' || !$tabs.length) return; + /** + If the browser does not support position sticky, it returns the position as static. + If the browser does support sticky, then we allow the browser to handle it, if not + then we default back to Bootstraps affix + **/ + if ($tabs.css('position') !== 'static') return; + const $diffTabs = $('#diff-notes-app'); $tabs.off('affix.bs.affix affix-top.bs.affix') .affix({ offset: { top: () => ( - $diffTabs.offset().top - $tabs.height() + $diffTabs.offset().top - $tabs.height() - $fixedNav.height() ), }, }) diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js index 6f6ae9bde92..3f976680b9d 100644 --- a/app/assets/javascripts/merge_request_widget.js +++ b/app/assets/javascripts/merge_request_widget.js @@ -7,8 +7,6 @@ import './smart_interval'; import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; ((global) => { - var indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; }; - const DEPLOYMENT_TEMPLATE = `<div class="mr-widget-heading" id="<%- id %>"> <div class="ci_widget ci-success"> <%= ci_success_icon %> @@ -258,7 +256,7 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; let stateClass = 'btn-danger'; if (!hasCi) { stateClass = 'btn-create'; - } else if (indexOf.call(allowed_states, state) !== -1) { + } else if (allowed_states.indexOf(state) !== -1) { switch (state) { case "failed": case "canceled": diff --git a/app/assets/javascripts/merge_request_widget/ci_bundle.js b/app/assets/javascripts/merge_request_widget/ci_bundle.js deleted file mode 100644 index 21d7c3e168e..00000000000 --- a/app/assets/javascripts/merge_request_widget/ci_bundle.js +++ /dev/null @@ -1,53 +0,0 @@ -/* global merge_request_widget */ - -(() => { - $(() => { - /* TODO: This needs a better home, or should be refactored. It was previously contained - * in a script tag in app/views/projects/merge_requests/widget/open/_accept.html.haml, - * but Vue chokes on script tags and prevents their execution. So it was moved here - * temporarily. - * */ - - $(document) - .off('ajax:send', '.accept-mr-form') - .on('ajax:send', '.accept-mr-form', () => { - $('.accept-mr-form :input').disable(); - }); - - $(document) - .off('click', '.accept-merge-request') - .on('click', '.accept-merge-request', () => { - $('.js-merge-button, .js-merge-when-pipeline-succeeds-button').html('<i class="fa fa-spinner fa-spin"></i> Merge in progress'); - }); - - $(document) - .off('click', '.merge-when-pipeline-succeeds') - .on('click', '.merge-when-pipeline-succeeds', () => { - $('#merge_when_pipeline_succeeds').val('1'); - }); - - $(document) - .off('click', '.js-merge-dropdown a') - .on('click', '.js-merge-dropdown a', (e) => { - e.preventDefault(); - $(e.target).closest('form').submit(); - }); - if ($('.rebase-in-progress').length) { - merge_request_widget.rebaseInProgress(); - } else if ($('.rebase-mr-form').length) { - $(document) - .off('ajax:send', '.rebase-mr-form') - .on('ajax:send', '.rebase-mr-form', () => { - $('.rebase-mr-form :input').disable(); - }); - - $(document) - .off('click', '.js-rebase-button') - .on('click', '.js-rebase-button', () => { - $('.js-rebase-button').html("<i class='fa fa-spinner fa-spin'></i> Rebase in progress"); - }); - } else { - setTimeout(() => merge_request_widget.getMergeStatus(), 200); - } - }); -})(); diff --git a/app/assets/javascripts/merged_buttons.js b/app/assets/javascripts/merged_buttons.js deleted file mode 100644 index 7b0997c6520..00000000000 --- a/app/assets/javascripts/merged_buttons.js +++ /dev/null @@ -1,47 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, max-len */ - -import '~/lib/utils/url_utility'; - -(function() { - this.MergedButtons = (function() { - function MergedButtons() { - this.removeSourceBranch = this.removeSourceBranch.bind(this); - this.removeBranchSuccess = this.removeBranchSuccess.bind(this); - this.removeBranchError = this.removeBranchError.bind(this); - this.$removeBranchWidget = $('.remove_source_branch_widget'); - this.$removeBranchProgress = $('.remove_source_branch_in_progress'); - this.$removeBranchFailed = $('.remove_source_branch_widget.failed'); - this.cleanEventListeners(); - this.initEventListeners(); - } - - MergedButtons.prototype.cleanEventListeners = function() { - $(document).off('click', '.remove_source_branch'); - $(document).off('ajax:success', '.remove_source_branch'); - return $(document).off('ajax:error', '.remove_source_branch'); - }; - - MergedButtons.prototype.initEventListeners = function() { - $(document).on('click', '.remove_source_branch', this.removeSourceBranch); - $(document).on('ajax:success', '.remove_source_branch', this.removeBranchSuccess); - $(document).on('ajax:error', '.remove_source_branch', this.removeBranchError); - }; - - MergedButtons.prototype.removeSourceBranch = function() { - this.$removeBranchWidget.hide(); - return this.$removeBranchProgress.show(); - }; - - MergedButtons.prototype.removeBranchSuccess = function() { - gl.utils.refreshCurrentPage(); - }; - - MergedButtons.prototype.removeBranchError = function() { - this.$removeBranchWidget.hide(); - this.$removeBranchProgress.hide(); - return this.$removeBranchFailed.show(); - }; - - return MergedButtons; - })(); -}).call(window); diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index 11e68c0a3be..9d481d7c003 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -18,12 +18,11 @@ } $els.each(function(i, dropdown) { - var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, showAny, showNo, showUpcoming, showStarted, useId, showMenuAbove; + var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, defaultNo, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, selectedMilestoneDefault, showAny, showNo, showUpcoming, showStarted, useId, showMenuAbove; $dropdown = $(dropdown); projectId = $dropdown.data('project-id'); milestonesUrl = $dropdown.data('milestones'); issueUpdateURL = $dropdown.data('issueUpdate'); - selectedMilestone = $dropdown.data('selected'); showNo = $dropdown.data('show-no'); showAny = $dropdown.data('show-any'); showMenuAbove = $dropdown.data('showMenuAbove'); @@ -31,6 +30,7 @@ showStarted = $dropdown.data('show-started'); useId = $dropdown.data('use-id'); defaultLabel = $dropdown.data('default-label'); + defaultNo = $dropdown.data('default-no'); issuableId = $dropdown.data('issuable-id'); abilityName = $dropdown.data('ability-name'); $selectbox = $dropdown.closest('.selectbox'); @@ -38,6 +38,9 @@ $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon'); $value = $block.find('.value'); $loading = $block.find('.block-loading').fadeOut(); + selectedMilestoneDefault = (showAny ? '' : null); + selectedMilestoneDefault = (showNo && defaultNo ? 'No Milestone' : selectedMilestoneDefault); + selectedMilestone = $dropdown.data('selected') || selectedMilestoneDefault; if (issueUpdateURL) { milestoneLinkTemplate = _.template('<a href="/<%- full_path %>/milestones/<%- iid %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>'); milestoneLinkNoneTemplate = '<span class="no-value">None</span>'; @@ -86,8 +89,18 @@ if (showMenuAbove) { $dropdown.data('glDropdown').positionMenuAbove(); } + $(`[data-milestone-id="${selectedMilestone}"] > a`).addClass('is-active'); }); }, + renderRow: function(milestone) { + return ` + <li data-milestone-id="${milestone.name}"> + <a href='#' class='dropdown-menu-milestone-link'> + ${_.escape(milestone.title)} + </a> + </li> + `; + }, filterable: true, search: { fields: ['title'] @@ -120,15 +133,24 @@ // display:block overrides the hide-collapse rule return $value.css('display', ''); }, + opened: function(e) { + const $el = $(e.currentTarget); + if ($dropdown.hasClass('js-issue-board-sidebar')) { + selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault; + } + $('a.is-active', $el).removeClass('is-active'); + $(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active'); + }, vue: $dropdown.hasClass('js-issue-board-sidebar'), clicked: function(options) { const { $el, e } = options; let selected = options.selectedObj; - - var data, isIssueIndex, isMRIndex, page, boardsStore; + var data, isIssueIndex, isMRIndex, isSelecting, page, boardsStore; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; isMRIndex = (page === page && page === 'projects:merge_requests:index'); + isSelecting = (selected.name !== selectedMilestone); + selectedMilestone = isSelecting ? selected.name : selectedMilestoneDefault; if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { e.preventDefault(); return; @@ -142,16 +164,11 @@ boardsStore[$dropdown.data('field-name')] = selected.name; e.preventDefault(); } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { - if (selected.name != null) { - selectedMilestone = selected.name; - } else { - selectedMilestone = ''; - } 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 (selected.id !== -1) { + if (selected.id !== -1 && isSelecting) { gl.issueBoards.boardStoreIssueSet('milestone', new ListMilestone({ id: selected.id, title: selected.name diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js index 36bc1257cef..426d7f3288e 100644 --- a/app/assets/javascripts/namespace_select.js +++ b/app/assets/javascripts/namespace_select.js @@ -2,11 +2,9 @@ /* global Api */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - window.NamespaceSelect = (function() { function NamespaceSelect(opts) { - this.onSelectItem = bind(this.onSelectItem, this); + this.onSelectItem = this.onSelectItem.bind(this); var fieldName, showAny; this.dropdown = opts.dropdown; showAny = true; diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index 67046d52a65..39fb302b644 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -1,15 +1,14 @@ /* eslint-disable func-names, space-before-function-paren, no-var, one-var, prefer-rest-params, max-len, vars-on-top, wrap-iife, consistent-return, comma-dangle, one-var-declaration-per-line, quotes, no-return-assign, prefer-arrow-callback, prefer-template, no-shadow, no-else-return, max-len, object-shorthand */ -(function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, - indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; }; +import RefSelectDropdown from '~/ref_select_dropdown'; +(function() { this.NewBranchForm = (function() { function NewBranchForm(form, availableRefs) { - this.validate = bind(this.validate, this); + this.validate = this.validate.bind(this); this.branchNameError = form.find('.js-branch-name-error'); this.name = form.find('.js-branch-name'); this.ref = form.find('#ref'); - this.setupAvailableRefs(availableRefs); + new RefSelectDropdown($('.js-branch-select'), availableRefs); // eslint-disable-line no-new this.setupRestrictions(); this.addBinding(); this.init(); @@ -25,49 +24,6 @@ } }; - NewBranchForm.prototype.setupAvailableRefs = function(availableRefs) { - var $branchSelect = $('.js-branch-select'); - - $branchSelect.glDropdown({ - data: availableRefs, - filterable: true, - filterByText: true, - remote: false, - fieldName: $branchSelect.data('field-name'), - filterInput: 'input[type="search"]', - selectable: true, - isSelectable: function(branch, $el) { - return !$el.hasClass('is-active'); - }, - text: function(branch) { - return branch; - }, - id: function(branch) { - return branch; - }, - toggleLabel: function(branch) { - if (branch) { - return branch; - } - } - }); - - const $dropdownContainer = $branchSelect.closest('.dropdown'); - const $fieldInput = $(`input[name="${$branchSelect.data('field-name')}"]`, $dropdownContainer); - const $filterInput = $('input[type="search"]', $dropdownContainer); - - $filterInput.on('keyup', (e) => { - const keyCode = e.keyCode || e.which; - if (keyCode !== 13) return; - - const text = $filterInput.val(); - $fieldInput.val(text); - $('.dropdown-toggle-text', $branchSelect).text(text); - - $dropdownContainer.removeClass('open'); - }); - }; - NewBranchForm.prototype.setupRestrictions = function() { var endsWith, invalid, single, startsWith; startsWith = { @@ -95,6 +51,8 @@ NewBranchForm.prototype.validate = function() { var errorMessage, errors, formatter, unique, validator; + const indexOf = [].indexOf; + this.branchNameError.empty(); unique = function(values, value) { if (indexOf.call(values, value) === -1) { diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js index ad36f08840d..658879607e2 100644 --- a/app/assets/javascripts/new_commit_form.js +++ b/app/assets/javascripts/new_commit_form.js @@ -1,12 +1,10 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-return-assign, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.NewCommitForm = (function() { function NewCommitForm(form, targetBranchName = 'target_branch') { this.form = form; this.targetBranchName = targetBranchName; - this.renderDestination = bind(this.renderDestination, this); + this.renderDestination = this.renderDestination.bind(this); this.targetBranchDropdown = form.find('button.js-target-branch'); this.originalBranch = form.find('.js-original-branch'); this.createMergeRequest = form.find('.js-create-merge-request'); diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 55391ebc089..aaf6e252abb 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -22,33 +22,32 @@ const normalizeNewlines = function(str) { }; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Notes = (function() { const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; - const REGEX_SLASH_COMMANDS = /\/\w+/g; + const REGEX_SLASH_COMMANDS = /^\/\w+.*$/gm; Notes.interval = null; function Notes(notes_url, note_ids, last_fetched_at, view) { - this.updateTargetButtons = bind(this.updateTargetButtons, this); - this.updateComment = bind(this.updateComment, this); - this.visibilityChange = bind(this.visibilityChange, this); - this.cancelDiscussionForm = bind(this.cancelDiscussionForm, this); - this.addDiffNote = bind(this.addDiffNote, this); - this.setupDiscussionNoteForm = bind(this.setupDiscussionNoteForm, this); - this.replyToDiscussionNote = bind(this.replyToDiscussionNote, this); - this.removeNote = bind(this.removeNote, this); - this.cancelEdit = bind(this.cancelEdit, this); - this.updateNote = bind(this.updateNote, this); - this.addDiscussionNote = bind(this.addDiscussionNote, this); - this.addNoteError = bind(this.addNoteError, this); - this.addNote = bind(this.addNote, this); - this.resetMainTargetForm = bind(this.resetMainTargetForm, this); - this.refresh = bind(this.refresh, this); - this.keydownNoteText = bind(this.keydownNoteText, this); - this.toggleCommitList = bind(this.toggleCommitList, this); - this.postComment = bind(this.postComment, this); + this.updateTargetButtons = this.updateTargetButtons.bind(this); + this.updateComment = this.updateComment.bind(this); + this.visibilityChange = this.visibilityChange.bind(this); + this.cancelDiscussionForm = this.cancelDiscussionForm.bind(this); + this.onAddDiffNote = this.onAddDiffNote.bind(this); + this.setupDiscussionNoteForm = this.setupDiscussionNoteForm.bind(this); + this.onReplyToDiscussionNote = this.onReplyToDiscussionNote.bind(this); + this.removeNote = this.removeNote.bind(this); + this.cancelEdit = this.cancelEdit.bind(this); + this.updateNote = this.updateNote.bind(this); + this.addDiscussionNote = this.addDiscussionNote.bind(this); + this.addNoteError = this.addNoteError.bind(this); + this.addNote = this.addNote.bind(this); + this.resetMainTargetForm = this.resetMainTargetForm.bind(this); + this.refresh = this.refresh.bind(this); + this.keydownNoteText = this.keydownNoteText.bind(this); + this.toggleCommitList = this.toggleCommitList.bind(this); + this.postComment = this.postComment.bind(this); + this.clearFlashWrapper = this.clearFlash.bind(this); this.notes_url = notes_url; this.note_ids = note_ids; @@ -59,6 +58,7 @@ const normalizeNewlines = function(str) { this.notesCountBadge || (this.notesCountBadge = $(".issuable-details").find(".notes-tab .badge")); this.basePollingInterval = 15000; this.maxPollingSteps = 4; + this.flashErrors = []; this.cleanBinding(); this.addBinding(); @@ -102,9 +102,9 @@ const normalizeNewlines = function(str) { // update the file name when an attachment is selected $(document).on("change", ".js-note-attachment-input", this.updateFormAttachment); // reply to diff/discussion notes - $(document).on("click", ".js-discussion-reply-button", this.replyToDiscussionNote); + $(document).on("click", ".js-discussion-reply-button", this.onReplyToDiscussionNote); // add diff note - $(document).on("click", ".js-add-diff-note-button", this.addDiffNote); + $(document).on("click", ".js-add-diff-note-button", this.onAddDiffNote); // hide diff note form $(document).on("click", ".js-close-discussion-note-form", this.cancelDiscussionForm); // toggle commit list @@ -175,7 +175,7 @@ const normalizeNewlines = function(str) { if ($textarea.val() !== '') { return; } - myLastNote = $("li.note[data-author-id='" + gon.current_user_id + "'][data-editable]:last"); + myLastNote = $(`li.note[data-author-id='${gon.current_user_id}'][data-editable]:last`, $textarea.closest('.note, #notes')); if (myLastNote.length) { myLastNoteEditBtn = myLastNote.find('.js-note-edit'); return myLastNoteEditBtn.trigger('click', [true, myLastNote]); @@ -276,7 +276,7 @@ const normalizeNewlines = function(str) { var votesBlock; if (noteEntity.commands_changes) { if ('merge' in noteEntity.commands_changes) { - $.get(mrRefreshWidgetUrl); + Notes.checkMergeRequestStatus(); } if ('emoji_award' in noteEntity.commands_changes) { @@ -300,7 +300,7 @@ const normalizeNewlines = function(str) { if (!noteEntity.valid) { if (noteEntity.errors.commands_only) { - new Flash(noteEntity.errors.commands_only, 'notice', this.parentTimeline); + this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline); this.refresh(); } return; @@ -424,6 +424,7 @@ const normalizeNewlines = function(str) { } gl.utils.localTimeAgo($('.js-timeago'), false); + Notes.checkMergeRequestStatus(); return this.updateNotesCount(1); }; @@ -552,14 +553,14 @@ const normalizeNewlines = function(str) { return this.renderNote(note); }; - Notes.prototype.addNoteError = ($form) => { + Notes.prototype.addNoteError = function($form) { let formParentTimeline; if ($form.hasClass('js-main-target-form')) { formParentTimeline = $form.parents('.timeline'); } else if ($form.hasClass('js-discussion-note-form')) { formParentTimeline = $form.closest('.discussion-notes').find('.notes'); } - return new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', formParentTimeline); + return this.addFlash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', formParentTimeline); }; Notes.prototype.updateNoteError = $parentTimeline => new Flash('Your comment could not be updated! Please check your network connection and try again.'); @@ -769,7 +770,8 @@ const normalizeNewlines = function(str) { } }; })(this)); - // Decrement the "Discussions" counter only once + + Notes.checkMergeRequestStatus(); return this.updateNotesCount(-1); }; @@ -794,10 +796,14 @@ const normalizeNewlines = function(str) { Shows the note form below the notes. */ - Notes.prototype.replyToDiscussionNote = function(e) { + Notes.prototype.onReplyToDiscussionNote = function(e) { + this.replyToDiscussionNote(e.target); + }; + + Notes.prototype.replyToDiscussionNote = function(target) { var form, replyLink; form = this.cleanForm(this.formClone.clone()); - replyLink = $(e.target).closest(".js-discussion-reply-button"); + replyLink = $(target).closest(".js-discussion-reply-button"); // insert the form after the button replyLink .closest('.discussion-reply-holder') @@ -867,35 +873,43 @@ const normalizeNewlines = function(str) { Sets up the form and shows it. */ - Notes.prototype.addDiffNote = function(e) { - var $link, addForm, hasNotes, lineType, newForm, nextRow, noteForm, notesContent, notesContentSelector, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar; + Notes.prototype.onAddDiffNote = function(e) { e.preventDefault(); - $link = $(e.currentTarget || e.target); + const $link = $(e.currentTarget || e.target); + const showReplyInput = !$link.hasClass('js-diff-comment-avatar'); + this.addDiffNote($link, $link.data('lineType'), showReplyInput); + }; + + Notes.prototype.addDiffNote = function(target, lineType, showReplyInput) { + var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar; + $link = $(target); row = $link.closest("tr"); - nextRow = row.next(); - hasNotes = nextRow.is(".notes_holder"); + const nextRow = row.next(); + let targetRow = row; + if (nextRow.is('.notes_holder')) { + targetRow = nextRow; + } + + hasNotes = targetRow.is(".notes_holder"); addForm = false; - notesContentSelector = ".notes_content"; + let lineTypeSelector = ''; rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"><div class=\"content\"></div></td></tr>"; - isDiffCommentAvatar = $link.hasClass('js-diff-comment-avatar'); // In parallel view, look inside the correct left/right pane if (this.isParallelView()) { - lineType = $link.data("lineType"); - notesContentSelector += "." + lineType; + lineTypeSelector = `.${lineType}`; rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line old\"></td><td class=\"notes_content parallel old\"><div class=\"content\"></div></td><td class=\"notes_line new\"></td><td class=\"notes_content parallel new\"><div class=\"content\"></div></td></tr>"; } - notesContentSelector += " .content"; - notesContent = nextRow.find(notesContentSelector); + const notesContentSelector = `.notes_content${lineTypeSelector} .content`; + let notesContent = targetRow.find(notesContentSelector); - if (hasNotes && !isDiffCommentAvatar) { - nextRow.show(); - notesContent = nextRow.find(notesContentSelector); + if (hasNotes && showReplyInput) { + targetRow.show(); + notesContent = targetRow.find(notesContentSelector); if (notesContent.length) { notesContent.show(); replyButton = notesContent.find(".js-discussion-reply-button:visible"); if (replyButton.length) { - e.target = replyButton[0]; - $.proxy(this.replyToDiscussionNote, replyButton[0], e).call(); + this.replyToDiscussionNote(replyButton[0]); } else { // In parallel view, the form may not be present in one of the panes noteForm = notesContent.find(".js-discussion-note-form"); @@ -904,18 +918,18 @@ const normalizeNewlines = function(str) { } } } - } else if (!isDiffCommentAvatar) { + } else if (showReplyInput) { // add a notes row and insert the form row.after(rowCssToAdd); - nextRow = row.next(); - notesContent = nextRow.find(notesContentSelector); + targetRow = row.next(); + notesContent = targetRow.find(notesContentSelector); addForm = true; } else { - nextRow.show(); + targetRow.show(); notesContent.toggle(!notesContent.is(':visible')); - if (!nextRow.find('.content:not(:empty)').is(':visible')) { - nextRow.hide(); + if (!targetRow.find('.content:not(:empty)').is(':visible')) { + targetRow.hide(); } } @@ -1101,6 +1115,15 @@ const normalizeNewlines = function(str) { }); }; + Notes.prototype.addFlash = function(...flashParams) { + this.flashErrors.push(new Flash(...flashParams)); + }; + + Notes.prototype.clearFlash = function() { + this.flashErrors.forEach(flash => flash.flashContainer.remove()); + this.flashErrors = []; + }; + Notes.prototype.cleanForm = function($form) { // Remove JS classes that are not needed here $form @@ -1115,6 +1138,12 @@ const normalizeNewlines = function(str) { return $form; }; + Notes.checkMergeRequestStatus = function() { + if (gl.utils.getPagePath(1) === 'merge_requests') { + gl.mrWidget.checkStatus(); + } + }; + Notes.animateAppendNote = function(noteHtml, $notesList) { const $note = $(noteHtml); @@ -1164,6 +1193,7 @@ const normalizeNewlines = function(str) { */ Notes.prototype.createPlaceholderNote = function({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname }) { const discussionClass = isDiscussionNote ? 'discussion' : ''; + const escapedFormContent = _.escape(formContent); const $tempNote = $( `<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry"> <div class="timeline-entry-inner"> @@ -1177,14 +1207,11 @@ const normalizeNewlines = function(str) { <span class="hidden-xs">${currentUserFullname}</span> <span class="note-headline-light">@${currentUsername}</span> </a> - <span class="note-headline-light"> - <i class="fa fa-spinner fa-spin" aria-label="Comment is being posted" aria-hidden="true"></i> - </span> </div> </div> <div class="note-body"> <div class="note-text"> - <p>${formContent}</p> + <p>${escapedFormContent}</p> </div> </div> </div> @@ -1275,6 +1302,8 @@ const normalizeNewlines = function(str) { .then((note) => { // Submission successful! remove placeholder $notesContainer.find(`#${uniqueId}`).remove(); + // Clear previous form errors + this.clearFlashWrapper(); // Check if this was discussion comment if (isDiscussionForm) { @@ -1314,7 +1343,7 @@ const normalizeNewlines = function(str) { // Show form again on UI on failure if (isDiscussionForm && $notesContainer.length) { const replyButton = $notesContainer.parent().find('.js-discussion-reply-button'); - $.proxy(this.replyToDiscussionNote, replyButton[0], { target: replyButton[0] }).call(); + this.replyToDiscussionNote(replyButton[0]); $form = $notesContainer.parent().find('form'); } diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js index 5005af90d48..2ab9c4fed2c 100644 --- a/app/assets/javascripts/notifications_form.js +++ b/app/assets/javascripts/notifications_form.js @@ -1,10 +1,8 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, newline-per-chained-call, comma-dangle, consistent-return, prefer-arrow-callback, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.NotificationsForm = (function() { function NotificationsForm() { - this.toggleCheckbox = bind(this.toggleCheckbox, this); + this.toggleCheckbox = this.toggleCheckbox.bind(this); this.removeEventListeners(); this.initEventListeners(); } diff --git a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js index 27ffe6ea304..5109b110b31 100644 --- a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js +++ b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js @@ -4,8 +4,10 @@ import illustrationSvg from '../icons/intro_illustration.svg'; const cookieKey = 'pipeline_schedules_callout_dismissed'; export default { + name: 'PipelineSchedulesCallout', data() { return { + docsUrl: document.getElementById('pipeline-schedules-callout').dataset.docsUrl, illustrationSvg, calloutDismissed: Cookies.get(cookieKey) === 'true', }; @@ -28,13 +30,15 @@ export default { <div class="svg-container" v-html="illustrationSvg"></div> <div class="user-callout-copy"> <h4>Scheduling Pipelines</h4> - <p> - The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. + <p> + The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user. </p> <p> Learn more in the - <!-- FIXME --> - <a href="random.com">pipeline schedules documentation</a>. + <a + :href="docsUrl" + target="_blank" + rel="nofollow">pipeline schedules documentation</a>. <!-- oneline to prevent extra space before period --> </p> </div> </div> diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js b/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js index e36dc5db2ab..6584549ad06 100644 --- a/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js +++ b/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js @@ -1,9 +1,12 @@ import Vue from 'vue'; import PipelineSchedulesCallout from './components/pipeline_schedules_callout'; -const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout); - -document.addEventListener('DOMContentLoaded', () => { - new PipelineSchedulesCalloutComponent() - .$mount('#scheduling-pipelines-callout'); -}); +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '#pipeline-schedules-callout', + components: { + 'pipeline-schedules-callout': PipelineSchedulesCallout, + }, + render(createElement) { + return createElement('pipeline-schedules-callout'); + }, +})); diff --git a/app/assets/javascripts/pipelines/components/async_button.vue b/app/assets/javascripts/pipelines/components/async_button.vue index d1c60b570de..37a6f02d8fd 100644 --- a/app/assets/javascripts/pipelines/components/async_button.vue +++ b/app/assets/javascripts/pipelines/components/async_button.vue @@ -3,6 +3,7 @@ /* global Flash */ import '~/flash'; import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { props: { @@ -37,6 +38,10 @@ export default { }, }, + components: { + loadingIcon, + }, + data() { return { isLoading: false, @@ -94,9 +99,6 @@ export default { <i :class="iconClass" aria-hidden="true" /> - <i - class="fa fa-spinner fa-spin" - aria-hidden="true" - v-if="isLoading" /> + <loading-icon v-if="isLoading" /> </button> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index a84161ef5e7..14c98847d93 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -5,11 +5,13 @@ import PipelineService from '../../services/pipeline_service'; import PipelineStore from '../../stores/pipeline_store'; import stageColumnComponent from './stage_column_component.vue'; + import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; import '../../../flash'; export default { components: { stageColumnComponent, + loadingIcon, }, data() { @@ -64,6 +66,24 @@ capitalizeStageName(name) { return name.charAt(0).toUpperCase() + name.slice(1); }, + + isFirstColumn(index) { + return index === 0; + }, + + stageConnectorClass(index, stage) { + let className; + + // If it's the first stage column and only has one job + if (index === 0 && stage.groups.length === 1) { + className = 'no-margin'; + } else if (index > 0) { + // If it is not the first column + className = 'left-margin'; + } + + return className; + }, }, }; </script> @@ -71,21 +91,22 @@ <div class="build-content middle-block js-pipeline-graph"> <div class="pipeline-visualization pipeline-graph"> <div class="text-center"> - <i + <loading-icon v-if="isLoading" - class="loading-icon fa fa-spin fa-spinner fa-3x" - aria-label="Loading" - aria-hidden="true" /> + size="3" + /> </div> <ul v-if="!isLoading" class="stage-column-list"> <stage-column-component - v-for="stage in state.graph" + v-for="(stage, index) in state.graph" :title="capitalizeStageName(stage.name)" :jobs="stage.groups" - :key="stage.name"/> + :key="stage.name" + :stage-connector-class="stageConnectorClass(index, stage)" + :is-first-column="isFirstColumn(index)"/> </ul> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index b7da185e280..9b1bbb0906f 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -13,6 +13,18 @@ export default { type: Array, required: true, }, + + isFirstColumn: { + type: Boolean, + required: false, + default: false, + }, + + stageConnectorClass: { + type: String, + required: false, + default: '', + }, }, components: { @@ -28,20 +40,27 @@ export default { jobId(job) { return `ci-badge-${job.name}`; }, + + buildConnnectorClass(index) { + return index === 0 && !this.isFirstColumn ? 'left-connector' : ''; + }, }, }; </script> <template> - <li class="stage-column"> + <li + class="stage-column" + :class="stageConnectorClass"> <div class="stage-name"> {{title}} </div> <div class="builds-container"> <ul> <li - v-for="job in jobs" + v-for="(job, index) in jobs" :key="job.id" class="build" + :class="buildConnnectorClass(index)" :id="jobId(job)"> <div class="curve"></div> diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.js b/app/assets/javascripts/pipelines/components/pipeline_url.js index 4e183d5c8ec..ea8aaca6c9c 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.js +++ b/app/assets/javascripts/pipelines/components/pipeline_url.js @@ -29,7 +29,7 @@ export default { </a> <span v-if="!user" - class="js-pipeline-url-api api monospace"> + class="js-pipeline-url-api api"> API </span> <span diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.js b/app/assets/javascripts/pipelines/components/pipelines_actions.js index ffda18d2e0f..b9e066c5db1 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_actions.js +++ b/app/assets/javascripts/pipelines/components/pipelines_actions.js @@ -3,6 +3,7 @@ import '~/flash'; import playIconSvg from 'icons/_icon_play.svg'; import eventHub from '../event_hub'; +import loadingIconComponent from '../../vue_shared/components/loading_icon.vue'; export default { props: { @@ -17,6 +18,10 @@ export default { }, }, + components: { + loadingIconComponent, + }, + data() { return { playIconSvg, @@ -65,10 +70,7 @@ export default { <i class="fa fa-caret-down" aria-hidden="true" /> - <i - v-if="isLoading" - class="fa fa-spinner fa-spin" - aria-hidden="true" /> + <loading-icon v-if="isLoading" /> </button> <ul class="dropdown-menu dropdown-menu-align-right"> diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue index 310f44b06df..7fc19fce1ff 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -15,6 +15,7 @@ /* global Flash */ import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { props: { @@ -38,6 +39,10 @@ export default { }; }, + components: { + loadingIcon, + }, + updated() { if (this.dropdownContent.length > 0) { this.stopDropdownClickPropagation(); @@ -153,15 +158,7 @@ export default { :class="dropdownClass" class="js-builds-dropdown-list scrollable-menu"> - <div - class="text-center" - v-if="isLoading"> - <i - class="fa fa-spin fa-spinner" - aria-hidden="true" - aria-label="Loading"> - </i> - </div> + <loading-icon v-if="isLoading"/> <ul v-else diff --git a/app/assets/javascripts/pipelines/components/status.js b/app/assets/javascripts/pipelines/components/status.js deleted file mode 100644 index 21a281af438..00000000000 --- a/app/assets/javascripts/pipelines/components/status.js +++ /dev/null @@ -1,60 +0,0 @@ -import canceledSvg from 'icons/_icon_status_canceled.svg'; -import createdSvg from 'icons/_icon_status_created.svg'; -import failedSvg from 'icons/_icon_status_failed.svg'; -import manualSvg from 'icons/_icon_status_manual.svg'; -import pendingSvg from 'icons/_icon_status_pending.svg'; -import runningSvg from 'icons/_icon_status_running.svg'; -import skippedSvg from 'icons/_icon_status_skipped.svg'; -import successSvg from 'icons/_icon_status_success.svg'; -import warningSvg from 'icons/_icon_status_warning.svg'; - -export default { - props: { - pipeline: { - type: Object, - required: true, - }, - }, - - data() { - const svgsDictionary = { - icon_status_canceled: canceledSvg, - icon_status_created: createdSvg, - icon_status_failed: failedSvg, - icon_status_manual: manualSvg, - icon_status_pending: pendingSvg, - icon_status_running: runningSvg, - icon_status_skipped: skippedSvg, - icon_status_success: successSvg, - icon_status_warning: warningSvg, - }; - - return { - svg: svgsDictionary[this.pipeline.details.status.icon], - }; - }, - - computed: { - cssClasses() { - return `ci-status ci-${this.pipeline.details.status.group}`; - }, - - detailsPath() { - const { status } = this.pipeline.details; - return status.has_details ? status.details_path : false; - }, - - content() { - return `${this.svg} ${this.pipeline.details.status.text}`; - }, - }, - template: ` - <td class="commit-link"> - <a - :class="cssClasses" - :href="detailsPath" - v-html="content"> - </a> - </td> - `, -}; diff --git a/app/assets/javascripts/pipelines/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js index 934bd7deb31..050551e5075 100644 --- a/app/assets/javascripts/pipelines/pipelines.js +++ b/app/assets/javascripts/pipelines/pipelines.js @@ -2,11 +2,12 @@ import Visibility from 'visibilityjs'; import PipelinesService from './services/pipelines_service'; import eventHub from './event_hub'; import PipelinesTableComponent from '../vue_shared/components/pipelines_table'; -import TablePaginationComponent from '../vue_shared/components/table_pagination'; +import tablePagination from '../vue_shared/components/table_pagination.vue'; import EmptyState from './components/empty_state.vue'; import ErrorState from './components/error_state.vue'; import NavigationTabs from './components/navigation_tabs'; import NavigationControls from './components/nav_controls'; +import loadingIcon from '../vue_shared/components/loading_icon.vue'; import Poll from '../lib/utils/poll'; export default { @@ -18,12 +19,13 @@ export default { }, components: { - 'gl-pagination': TablePaginationComponent, + tablePagination, 'pipelines-table-component': PipelinesTableComponent, 'empty-state': EmptyState, 'error-state': ErrorState, 'navigation-tabs': NavigationTabs, 'navigation-controls': NavigationControls, + loadingIcon, }, data() { @@ -244,13 +246,11 @@ export default { <div class="content-list pipelines"> - <div - class="realtime-loading" - v-if="isLoading"> - <i - class="fa fa-spinner fa-spin" - aria-hidden="true" /> - </div> + <loading-icon + label="Loading Pipelines" + size="3" + v-if="isLoading" + /> <empty-state v-if="shouldRenderEmptyState" @@ -275,12 +275,13 @@ export default { /> </div> - <gl-pagination + <table-pagination v-if="shouldRenderPagination" :pagenum="pagenum" :change="change" :count="state.count.all" - :pageInfo="state.pageInfo"/> + :pageInfo="state.pageInfo" + /> </div> </div> `, diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index e01668eabef..11f9754780d 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -2,18 +2,16 @@ /* global fuzzaldrinPlus */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.ProjectFindFile = (function() { var highlighter; function ProjectFindFile(element1, options) { this.element = element1; this.options = options; - this.goToBlob = bind(this.goToBlob, this); - this.goToTree = bind(this.goToTree, this); - this.selectRowDown = bind(this.selectRowDown, this); - this.selectRowUp = bind(this.selectRowUp, this); + this.goToBlob = this.goToBlob.bind(this); + this.goToTree = this.goToTree.bind(this); + this.selectRowDown = this.selectRowDown.bind(this); + this.selectRowUp = this.selectRowUp.bind(this); this.filePaths = {}; this.inputElement = this.element.find(".file-finder-input"); // init event diff --git a/app/assets/javascripts/project_new.js b/app/assets/javascripts/project_new.js index e9927c1bf51..04b381fe0e0 100644 --- a/app/assets/javascripts/project_new.js +++ b/app/assets/javascripts/project_new.js @@ -1,11 +1,9 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-vars, one-var, no-underscore-dangle, prefer-template, no-else-return, prefer-arrow-callback, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.ProjectNew = (function() { function ProjectNew() { - this.toggleSettings = bind(this.toggleSettings, this); + this.toggleSettings = this.toggleSettings.bind(this); this.$selects = $('.features select'); this.$repoSelects = this.$selects.filter('.js-repo-select'); diff --git a/app/assets/javascripts/ref_select_dropdown.js b/app/assets/javascripts/ref_select_dropdown.js new file mode 100644 index 00000000000..215cd6fbdfd --- /dev/null +++ b/app/assets/javascripts/ref_select_dropdown.js @@ -0,0 +1,46 @@ +class RefSelectDropdown { + constructor($dropdownButton, availableRefs) { + $dropdownButton.glDropdown({ + data: availableRefs, + filterable: true, + filterByText: true, + remote: false, + fieldName: $dropdownButton.data('field-name'), + filterInput: 'input[type="search"]', + selectable: true, + isSelectable(branch, $el) { + return !$el.hasClass('is-active'); + }, + text(branch) { + return branch; + }, + id(branch) { + return branch; + }, + toggleLabel(branch) { + return branch; + }, + }); + + const $dropdownContainer = $dropdownButton.closest('.dropdown'); + const $fieldInput = $(`input[name="${$dropdownButton.data('field-name')}"]`, $dropdownContainer); + const $filterInput = $('input[type="search"]', $dropdownContainer); + + $filterInput.on('keyup', (e) => { + const keyCode = e.keyCode || e.which; + if (keyCode !== 13) return; + + const ref = $filterInput.val().trim(); + if (ref === '') { + return; + } + + $fieldInput.val(ref); + $('.dropdown-toggle-text', $dropdownButton).text(ref); + + $dropdownContainer.removeClass('open'); + }); + } +} + +export default RefSelectDropdown; diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index a9b3de281e1..b71c3097706 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -3,11 +3,9 @@ import Cookies from 'js-cookie'; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Sidebar = (function() { function Sidebar(currentUser) { - this.toggleTodo = bind(this.toggleTodo, this); + this.toggleTodo = this.toggleTodo.bind(this); this.sidebar = $('aside'); this.removeListeners(); this.addEventListeners(); diff --git a/app/assets/javascripts/search.js b/app/assets/javascripts/search.js index 15f5963353a..39e4006ac4e 100644 --- a/app/assets/javascripts/search.js +++ b/app/assets/javascripts/search.js @@ -1,4 +1,5 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, object-shorthand, prefer-arrow-callback, comma-dangle, prefer-template, quotes, no-else-return, max-len */ +/* global Flash */ /* global Api */ (function() { @@ -7,6 +8,7 @@ var $groupDropdown, $projectDropdown; $groupDropdown = $('.js-search-group-dropdown'); $projectDropdown = $('.js-search-project-dropdown'); + this.groupId = $groupDropdown.data('group-id'); this.eventListeners(); $groupDropdown.glDropdown({ selectable: true, @@ -46,14 +48,18 @@ search: { fields: ['name'] }, - data: function(term, callback) { - return Api.projects(term, { order_by: 'id' }, function(data) { - data.unshift({ - name_with_namespace: 'Any' - }); - data.splice(1, 0, 'divider'); - return callback(data); - }); + data: (term, callback) => { + this.getProjectsData(term) + .then((data) => { + data.unshift({ + name_with_namespace: 'Any' + }); + data.splice(1, 0, 'divider'); + + return data; + }) + .then(data => callback(data)) + .catch(() => new Flash('Error fetching projects')); }, id: function(obj) { return obj.id; @@ -95,6 +101,18 @@ return $('.js-search-input').val('').trigger('keyup').focus(); }; + Search.prototype.getProjectsData = function(term) { + return new Promise((resolve) => { + if (this.groupId) { + Api.groupProjects(this.groupId, term, resolve); + } else { + Api.projects(term, { + order_by: 'id', + }, resolve); + } + }); + }; + return Search; })(); }).call(window); diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js index 85659d7fa39..8ac71797c14 100644 --- a/app/assets/javascripts/shortcuts.js +++ b/app/assets/javascripts/shortcuts.js @@ -4,11 +4,9 @@ import findAndFollowLink from './shortcuts_dashboard_navigation'; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Shortcuts = (function() { function Shortcuts(skipResetBindings) { - this.onToggleHelp = bind(this.onToggleHelp, this); + this.onToggleHelp = this.onToggleHelp.bind(this); this.enabledHelp = []; if (!skipResetBindings) { Mousetrap.reset(); diff --git a/app/assets/javascripts/sidebar/event_hub.js b/app/assets/javascripts/sidebar/event_hub.js index 0948c2e5352..f35506fd5de 100644 --- a/app/assets/javascripts/sidebar/event_hub.js +++ b/app/assets/javascripts/sidebar/event_hub.js @@ -1,3 +1,8 @@ import Vue from 'vue'; -export default new Vue(); +const eventHub = new Vue(); + +// TODO: remove eventHub hack after code splitting refactor +window.emitSidebarEvent = (...args) => eventHub.$emit(...args); + +export default eventHub; diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index 294d087554e..bacb26734c9 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -1,8 +1,6 @@ /* eslint-disable func-names, prefer-arrow-callback, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, consistent-return, no-param-reassign, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - window.SingleFileDiff = (function() { var COLLAPSED_HTML, ERROR_HTML, LOADING_HTML, WRAPPER; @@ -16,7 +14,7 @@ function SingleFileDiff(file) { this.file = file; - this.toggleDiff = bind(this.toggleDiff, this); + this.toggleDiff = this.toggleDiff.bind(this); this.content = $('.diff-content', this.file); this.$toggleIcon = $('.diff-toggle-caret', this.file); this.diffForPath = this.content.find('[data-diff-for-path]').data('diff-for-path'); diff --git a/app/assets/javascripts/todos.js b/app/assets/javascripts/todos.js index 8be58023c84..7230946b484 100644 --- a/app/assets/javascripts/todos.js +++ b/app/assets/javascripts/todos.js @@ -1,5 +1,6 @@ /* eslint-disable class-methods-use-this, no-unneeded-ternary, quote-props */ -/* global UsersSelect */ + +import UsersSelect from './users_select'; class Todos { constructor() { diff --git a/app/assets/javascripts/u2f/authenticate.js b/app/assets/javascripts/u2f/authenticate.js index 500b78fc5d8..cd5280948fd 100644 --- a/app/assets/javascripts/u2f/authenticate.js +++ b/app/assets/javascripts/u2f/authenticate.js @@ -10,18 +10,16 @@ (function() { const global = window.gl || (window.gl = {}); - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - global.U2FAuthenticate = (function() { function U2FAuthenticate(container, form, u2fParams, fallbackButton, fallbackUI) { this.container = container; - this.renderNotSupported = bind(this.renderNotSupported, this); - this.renderAuthenticated = bind(this.renderAuthenticated, this); - this.renderError = bind(this.renderError, this); - this.renderInProgress = bind(this.renderInProgress, this); - this.renderTemplate = bind(this.renderTemplate, this); - this.authenticate = bind(this.authenticate, this); - this.start = bind(this.start, this); + this.renderNotSupported = this.renderNotSupported.bind(this); + this.renderAuthenticated = this.renderAuthenticated.bind(this); + this.renderError = this.renderError.bind(this); + this.renderInProgress = this.renderInProgress.bind(this); + this.renderTemplate = this.renderTemplate.bind(this); + this.authenticate = this.authenticate.bind(this); + this.start = this.start.bind(this); this.appId = u2fParams.app_id; this.challenge = u2fParams.challenge; this.form = form; diff --git a/app/assets/javascripts/u2f/error.js b/app/assets/javascripts/u2f/error.js index fd1829efe18..3119b3480c3 100644 --- a/app/assets/javascripts/u2f/error.js +++ b/app/assets/javascripts/u2f/error.js @@ -2,12 +2,10 @@ /* global u2f */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.U2FError = (function() { function U2FError(errorCode, u2fFlowType) { this.errorCode = errorCode; - this.message = bind(this.message, this); + this.message = this.message.bind(this); this.httpsDisabled = window.location.protocol !== 'https:'; this.u2fFlowType = u2fFlowType; } diff --git a/app/assets/javascripts/u2f/register.js b/app/assets/javascripts/u2f/register.js index 17631f2908d..1234d17b8fd 100644 --- a/app/assets/javascripts/u2f/register.js +++ b/app/assets/javascripts/u2f/register.js @@ -8,19 +8,17 @@ // State Flow #1: setup -> in_progress -> registered -> POST to server // State Flow #2: setup -> in_progress -> error -> setup (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.U2FRegister = (function() { function U2FRegister(container, u2fParams) { this.container = container; - this.renderNotSupported = bind(this.renderNotSupported, this); - this.renderRegistered = bind(this.renderRegistered, this); - this.renderError = bind(this.renderError, this); - this.renderInProgress = bind(this.renderInProgress, this); - this.renderSetup = bind(this.renderSetup, this); - this.renderTemplate = bind(this.renderTemplate, this); - this.register = bind(this.register, this); - this.start = bind(this.start, this); + this.renderNotSupported = this.renderNotSupported.bind(this); + this.renderRegistered = this.renderRegistered.bind(this); + this.renderError = this.renderError.bind(this); + this.renderInProgress = this.renderInProgress.bind(this); + this.renderSetup = this.renderSetup.bind(this); + this.renderTemplate = this.renderTemplate.bind(this); + this.register = this.register.bind(this); + this.start = this.start.bind(this); this.appId = u2fParams.app_id; this.registerRequests = u2fParams.register_requests; this.signRequests = u2fParams.sign_requests; diff --git a/app/assets/javascripts/users/calendar.js b/app/assets/javascripts/users/calendar.js index 32ffa2f0ac0..b11f691e424 100644 --- a/app/assets/javascripts/users/calendar.js +++ b/app/assets/javascripts/users/calendar.js @@ -3,12 +3,10 @@ import d3 from 'd3'; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Calendar = (function() { function Calendar(timestamps, calendar_activities_path) { this.calendar_activities_path = calendar_activities_path; - this.clickDay = bind(this.clickDay, this); + this.clickDay = this.clickDay.bind(this); this.currentSelectedDate = ''; this.daySpace = 1; this.daySize = 15; diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index be29b08c343..8119a8cd000 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -1,658 +1,653 @@ /* eslint-disable func-names, space-before-function-paren, one-var, no-var, prefer-rest-params, wrap-iife, quotes, max-len, one-var-declaration-per-line, vars-on-top, prefer-arrow-callback, consistent-return, comma-dangle, object-shorthand, no-shadow, no-unused-vars, no-else-return, no-self-compare, prefer-template, no-unused-expressions, no-lonely-if, yoda, prefer-spread, no-void, camelcase, no-param-reassign */ /* global Issuable */ +/* global emitSidebarEvent */ + +// TODO: remove eventHub hack after code splitting refactor +window.emitSidebarEvent = window.emitSidebarEvent || $.noop; + +function UsersSelect(currentUser, els) { + var $els; + this.users = this.users.bind(this); + this.user = this.user.bind(this); + this.usersPath = "/autocomplete/users.json"; + this.userPath = "/autocomplete/users/:id.json"; + if (currentUser != null) { + if (typeof currentUser === 'object') { + this.currentUser = currentUser; + } else { + this.currentUser = JSON.parse(currentUser); + } + } + + $els = $(els); + + if (!els) { + $els = $('.js-user-search'); + } + + $els.each((function(_this) { + return function(i, dropdown) { + var options = {}; + var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, defaultNullUser, firstUser, issueURL, selectedId, selectedIdDefault, showAnyUser, showNullUser, showMenuAbove; + $dropdown = $(dropdown); + options.projectId = $dropdown.data('project-id'); + options.groupId = $dropdown.data('group-id'); + options.showCurrentUser = $dropdown.data('current-user'); + options.todoFilter = $dropdown.data('todo-filter'); + options.todoStateFilter = $dropdown.data('todo-state-filter'); + showNullUser = $dropdown.data('null-user'); + defaultNullUser = $dropdown.data('null-user-default'); + showMenuAbove = $dropdown.data('showMenuAbove'); + showAnyUser = $dropdown.data('any-user'); + firstUser = $dropdown.data('first-user'); + options.authorId = $dropdown.data('author-id'); + defaultLabel = $dropdown.data('default-label'); + issueURL = $dropdown.data('issueUpdate'); + $selectbox = $dropdown.closest('.selectbox'); + $block = $selectbox.closest('.block'); + abilityName = $dropdown.data('ability-name'); + $value = $block.find('.value'); + $collapsedSidebar = $block.find('.sidebar-collapsed-user'); + $loading = $block.find('.block-loading').fadeOut(); + selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null; + selectedId = $dropdown.data('selected') || selectedIdDefault; + + const assignYourself = function () { + const unassignedSelected = $dropdown.closest('.selectbox') + .find(`input[name='${$dropdown.data('field-name')}'][value=0]`); + + if (unassignedSelected) { + unassignedSelected.remove(); + } -import eventHub from './sidebar/event_hub'; - -(function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, - slice = [].slice; - - this.UsersSelect = (function() { - function UsersSelect(currentUser, els) { - var $els; - this.users = bind(this.users, this); - this.user = bind(this.user, this); - this.usersPath = "/autocomplete/users.json"; - this.userPath = "/autocomplete/users/:id.json"; - if (currentUser != null) { - if (typeof currentUser === 'object') { - this.currentUser = currentUser; - } else { - this.currentUser = JSON.parse(currentUser); + // Save current selected user to the DOM + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = $dropdown.data('field-name'); + + const currentUserInfo = $dropdown.data('currentUserInfo'); + + if (currentUserInfo) { + input.value = currentUserInfo.id; + input.dataset.meta = currentUserInfo.name; + } else if (_this.currentUser) { + input.value = _this.currentUser.id; } - } - $els = $(els); + if ($selectbox) { + $dropdown.parent().before(input); + } else { + $dropdown.after(input); + } + }; - if (!els) { - $els = $('.js-user-search'); + if ($block[0]) { + $block[0].addEventListener('assignYourself', assignYourself); } - $els.each((function(_this) { - return function(i, dropdown) { - var options = {}; - var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, defaultNullUser, firstUser, issueURL, selectedId, selectedIdDefault, showAnyUser, showNullUser, showMenuAbove; - $dropdown = $(dropdown); - options.projectId = $dropdown.data('project-id'); - options.groupId = $dropdown.data('group-id'); - options.showCurrentUser = $dropdown.data('current-user'); - options.todoFilter = $dropdown.data('todo-filter'); - options.todoStateFilter = $dropdown.data('todo-state-filter'); - showNullUser = $dropdown.data('null-user'); - defaultNullUser = $dropdown.data('null-user-default'); - showMenuAbove = $dropdown.data('showMenuAbove'); - showAnyUser = $dropdown.data('any-user'); - firstUser = $dropdown.data('first-user'); - options.authorId = $dropdown.data('author-id'); - defaultLabel = $dropdown.data('default-label'); - issueURL = $dropdown.data('issueUpdate'); - $selectbox = $dropdown.closest('.selectbox'); - $block = $selectbox.closest('.block'); - abilityName = $dropdown.data('ability-name'); - $value = $block.find('.value'); - $collapsedSidebar = $block.find('.sidebar-collapsed-user'); - $loading = $block.find('.block-loading').fadeOut(); - selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null; - selectedId = $dropdown.data('selected') || selectedIdDefault; - - const assignYourself = function () { - const unassignedSelected = $dropdown.closest('.selectbox') - .find(`input[name='${$dropdown.data('field-name')}'][value=0]`); - - if (unassignedSelected) { - unassignedSelected.remove(); - } + const getSelectedUserInputs = function() { + return $selectbox + .find(`input[name="${$dropdown.data('field-name')}"]`); + }; + + const getSelected = function() { + return getSelectedUserInputs() + .map((index, input) => parseInt(input.value, 10)) + .get(); + }; + + const checkMaxSelect = function() { + const maxSelect = $dropdown.data('max-select'); + if (maxSelect) { + const selected = getSelected(); + + if (selected.length > maxSelect) { + const firstSelectedId = selected[0]; + const firstSelected = $dropdown.closest('.selectbox') + .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`); + + firstSelected.remove(); + emitSidebarEvent('sidebar.removeAssignee', { + id: firstSelectedId, + }); + } + } + }; + + const getMultiSelectDropdownTitle = function(selectedUser, isSelected) { + const selectedUsers = getSelected() + .filter(u => u !== 0); + + const firstUser = getSelectedUserInputs() + .map((index, input) => ({ + name: input.dataset.meta, + value: parseInt(input.value, 10), + })) + .filter(u => u.id !== 0) + .get(0); + + if (selectedUsers.length === 0) { + return 'Unassigned'; + } else if (selectedUsers.length === 1) { + return firstUser.name; + } else if (isSelected) { + const otherSelected = selectedUsers.filter(s => s !== selectedUser.id); + return `${selectedUser.name} + ${otherSelected.length} more`; + } else { + return `${firstUser.name} + ${selectedUsers.length - 1} more`; + } + }; - // Save current selected user to the DOM - const input = document.createElement('input'); - input.type = 'hidden'; - input.name = $dropdown.data('field-name'); + $('.assign-to-me-link').on('click', (e) => { + e.preventDefault(); + $(e.currentTarget).hide(); - const currentUserInfo = $dropdown.data('currentUserInfo'); + if ($dropdown.data('multiSelect')) { + assignYourself(); + checkMaxSelect(); - if (currentUserInfo) { - input.value = currentUserInfo.id; - input.dataset.meta = currentUserInfo.name; - } else if (_this.currentUser) { - input.value = _this.currentUser.id; - } + const currentUserInfo = $dropdown.data('currentUserInfo'); + $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default'); + } else { + const $input = $(`input[name="${$dropdown.data('field-name')}"]`); + $input.val(gon.current_user_id); + selectedId = $input.val(); + $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default'); + } + }); - if ($selectbox) { - $dropdown.parent().before(input); - } else { - $dropdown.after(input); - } - }; + $block.on('click', '.js-assign-yourself', (e) => { + e.preventDefault(); + return assignTo(_this.currentUser.id); + }); - if ($block[0]) { - $block[0].addEventListener('assignYourself', assignYourself); + assignTo = function(selected) { + var data; + data = {}; + data[abilityName] = {}; + data[abilityName].assignee_id = selected != null ? selected : null; + $loading.removeClass('hidden').fadeIn(); + $dropdown.trigger('loading.gl.dropdown'); + + return $.ajax({ + type: 'PUT', + dataType: 'json', + url: issueURL, + data: data + }).done(function(data) { + var user; + $dropdown.trigger('loaded.gl.dropdown'); + $loading.fadeOut(); + if (data.assignee) { + user = { + name: data.assignee.name, + username: data.assignee.username, + avatar: data.assignee.avatar_url + }; + } else { + user = { + name: 'Unassigned', + username: '', + avatar: '' + }; } - - const getSelectedUserInputs = function() { - return $selectbox - .find(`input[name="${$dropdown.data('field-name')}"]`); - }; - - const getSelected = function() { - return getSelectedUserInputs() - .map((index, input) => parseInt(input.value, 10)) - .get(); - }; - - const checkMaxSelect = function() { - const maxSelect = $dropdown.data('max-select'); - if (maxSelect) { - const selected = getSelected(); - - if (selected.length > maxSelect) { - const firstSelectedId = selected[0]; - const firstSelected = $dropdown.closest('.selectbox') - .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`); - - firstSelected.remove(); - eventHub.$emit('sidebar.removeAssignee', { - id: firstSelectedId, - }); + $value.html(assigneeTemplate(user)); + $collapsedSidebar.attr('title', user.name).tooltip('fixTitle'); + return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); + }); + }; + collapsedAssigneeTemplate = _.template('<% if( avatar ) { %> <a class="author_link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>'); + assigneeTemplate = _.template('<% if (username) { %> <a class="author_link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>'); + return $dropdown.glDropdown({ + showMenuAbove: showMenuAbove, + data: function(term, callback) { + var isAuthorFilter; + isAuthorFilter = $('.js-author-search'); + return _this.users(term, options, function(users) { + // GitLabDropdownFilter returns this.instance + // GitLabDropdownRemote returns this.options.instance + const glDropdown = this.instance || this.options.instance; + glDropdown.options.processData(term, users, callback); + }.bind(this)); + }, + processData: function(term, users, callback) { + let anyUser; + let index; + let j; + let len; + let name; + let obj; + let showDivider; + if (term.length === 0) { + showDivider = 0; + if (firstUser) { + // Move current user to the front of the list + for (index = j = 0, len = users.length; j < len; index = (j += 1)) { + obj = users[index]; + if (obj.username === firstUser) { + users.splice(index, 1); + users.unshift(obj); + break; + } } } - }; - - const getMultiSelectDropdownTitle = function(selectedUser, isSelected) { - const selectedUsers = getSelected() - .filter(u => u !== 0); - - const firstUser = getSelectedUserInputs() - .map((index, input) => ({ - name: input.dataset.meta, - value: parseInt(input.value, 10), - })) - .filter(u => u.id !== 0) - .get(0); - - if (selectedUsers.length === 0) { - return 'Unassigned'; - } else if (selectedUsers.length === 1) { - return firstUser.name; - } else if (isSelected) { - const otherSelected = selectedUsers.filter(s => s !== selectedUser.id); - return `${selectedUser.name} + ${otherSelected.length} more`; - } else { - return `${firstUser.name} + ${selectedUsers.length - 1} more`; + if (showNullUser) { + showDivider += 1; + users.unshift({ + beforeDivider: true, + name: 'Unassigned', + id: 0 + }); + } + if (showAnyUser) { + showDivider += 1; + name = showAnyUser; + if (name === true) { + name = 'Any User'; + } + anyUser = { + beforeDivider: true, + name: name, + id: null + }; + users.unshift(anyUser); } - }; - - $('.assign-to-me-link').on('click', (e) => { - e.preventDefault(); - $(e.currentTarget).hide(); - - if ($dropdown.data('multiSelect')) { - assignYourself(); - checkMaxSelect(); - const currentUserInfo = $dropdown.data('currentUserInfo'); - $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default'); - } else { - const $input = $(`input[name="${$dropdown.data('field-name')}"]`); - $input.val(gon.current_user_id); - selectedId = $input.val(); - $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default'); + if (showDivider) { + users.splice(showDivider, 0, 'divider'); } - }); - $block.on('click', '.js-assign-yourself', (e) => { - e.preventDefault(); - return assignTo(_this.currentUser.id); - }); + if ($dropdown.hasClass('js-multiselect')) { + const selected = getSelected().filter(i => i !== 0); - assignTo = function(selected) { - var data; - data = {}; - data[abilityName] = {}; - data[abilityName].assignee_id = selected != null ? selected : null; - $loading.removeClass('hidden').fadeIn(); - $dropdown.trigger('loading.gl.dropdown'); - - return $.ajax({ - type: 'PUT', - dataType: 'json', - url: issueURL, - data: data - }).done(function(data) { - var user; - $dropdown.trigger('loaded.gl.dropdown'); - $loading.fadeOut(); - if (data.assignee) { - user = { - name: data.assignee.name, - username: data.assignee.username, - avatar: data.assignee.avatar_url - }; - } else { - user = { - name: 'Unassigned', - username: '', - avatar: '' - }; - } - $value.html(assigneeTemplate(user)); - $collapsedSidebar.attr('title', user.name).tooltip('fixTitle'); - return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); - }); - }; - collapsedAssigneeTemplate = _.template('<% if( avatar ) { %> <a class="author_link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>'); - assigneeTemplate = _.template('<% if (username) { %> <a class="author_link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>'); - return $dropdown.glDropdown({ - showMenuAbove: showMenuAbove, - data: function(term, callback) { - var isAuthorFilter; - isAuthorFilter = $('.js-author-search'); - return _this.users(term, options, function(users) { - // GitLabDropdownFilter returns this.instance - // GitLabDropdownRemote returns this.options.instance - const glDropdown = this.instance || this.options.instance; - glDropdown.options.processData(term, users, callback); - }.bind(this)); - }, - processData: function(term, users, callback) { - let anyUser; - let index; - let j; - let len; - let name; - let obj; - let showDivider; - if (term.length === 0) { - showDivider = 0; - if (firstUser) { - // Move current user to the front of the list - for (index = j = 0, len = users.length; j < len; index = (j += 1)) { - obj = users[index]; - if (obj.username === firstUser) { - users.splice(index, 1); - users.unshift(obj); - break; - } - } - } - if (showNullUser) { + if (selected.length > 0) { + if ($dropdown.data('dropdown-header')) { showDivider += 1; - users.unshift({ - beforeDivider: true, - name: 'Unassigned', - id: 0 + users.splice(showDivider, 0, { + header: $dropdown.data('dropdown-header'), }); } - if (showAnyUser) { - showDivider += 1; - name = showAnyUser; - if (name === true) { - name = 'Any User'; - } - anyUser = { - beforeDivider: true, - name: name, - id: null - }; - users.unshift(anyUser); - } - if (showDivider) { - users.splice(showDivider, 0, 'divider'); - } + const selectedUsers = users + .filter(u => selected.indexOf(u.id) !== -1) + .sort((a, b) => a.name > b.name); - if ($dropdown.hasClass('js-multiselect')) { - const selected = getSelected().filter(i => i !== 0); + users = users.filter(u => selected.indexOf(u.id) === -1); - if (selected.length > 0) { - if ($dropdown.data('dropdown-header')) { - showDivider += 1; - users.splice(showDivider, 0, { - header: $dropdown.data('dropdown-header'), - }); - } - - const selectedUsers = users - .filter(u => selected.indexOf(u.id) !== -1) - .sort((a, b) => a.name > b.name); + selectedUsers.forEach((selectedUser) => { + showDivider += 1; + users.splice(showDivider, 0, selectedUser); + }); - users = users.filter(u => selected.indexOf(u.id) === -1); + users.splice(showDivider + 1, 0, 'divider'); + } + } + } - selectedUsers.forEach((selectedUser) => { - showDivider += 1; - users.splice(showDivider, 0, selectedUser); - }); + callback(users); + if (showMenuAbove) { + $dropdown.data('glDropdown').positionMenuAbove(); + } + }, + filterable: true, + filterRemote: true, + search: { + fields: ['name', 'username'] + }, + selectable: true, + fieldName: $dropdown.data('field-name'), + toggleLabel: function(selected, el, glDropdown) { + const inputValue = glDropdown.filterInput.val(); + + if (this.multiSelect && inputValue === '') { + // Remove non-users from the fullData array + const users = glDropdown.filteredFullData(); + const callback = glDropdown.parseData.bind(glDropdown); + + // Update the data model + this.processData(inputValue, users, callback); + } - users.splice(showDivider + 1, 0, 'divider'); - } - } - } + if (this.multiSelect) { + return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); + } - callback(users); - if (showMenuAbove) { - $dropdown.data('glDropdown').positionMenuAbove(); - } - }, - filterable: true, - filterRemote: true, - search: { - fields: ['name', 'username'] - }, - selectable: true, - fieldName: $dropdown.data('field-name'), - toggleLabel: function(selected, el, glDropdown) { - const inputValue = glDropdown.filterInput.val(); - - if (this.multiSelect && inputValue === '') { - // Remove non-users from the fullData array - const users = glDropdown.filteredFullData(); - const callback = glDropdown.parseData.bind(glDropdown); - - // Update the data model - this.processData(inputValue, users, callback); - } + if (selected && 'id' in selected && $(el).hasClass('is-active')) { + $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); + if (selected.text) { + return selected.text; + } else { + return selected.name; + } + } else { + $dropdown.find('.dropdown-toggle-text').addClass('is-default'); + return defaultLabel; + } + }, + defaultLabel: defaultLabel, + hidden: function(e) { + if ($dropdown.hasClass('js-multiselect')) { + emitSidebarEvent('sidebar.saveAssignees'); + } - if (this.multiSelect) { - return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); - } + if (!$dropdown.data('always-show-selectbox')) { + $selectbox.hide(); - if (selected && 'id' in selected && $(el).hasClass('is-active')) { - $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); - if (selected.text) { - return selected.text; - } else { - return selected.name; - } - } else { - $dropdown.find('.dropdown-toggle-text').addClass('is-default'); - return defaultLabel; - } - }, - defaultLabel: defaultLabel, - hidden: function(e) { - if ($dropdown.hasClass('js-multiselect')) { - eventHub.$emit('sidebar.saveAssignees'); - } + // Recalculate where .value is because vue might have changed it + $block = $selectbox.closest('.block'); + $value = $block.find('.value'); + // display:block overrides the hide-collapse rule + $value.css('display', ''); + } + }, + multiSelect: $dropdown.hasClass('js-multiselect'), + inputMeta: $dropdown.data('input-meta'), + clicked: function(options) { + const { $el, e, isMarking } = options; + const user = options.selectedObj; + + if ($dropdown.hasClass('js-multiselect')) { + const isActive = $el.hasClass('is-active'); + const previouslySelected = $dropdown.closest('.selectbox') + .find("input[name='" + ($dropdown.data('field-name')) + "'][value!=0]"); + + // Enables support for limiting the number of users selected + // Automatically removes the first on the list if more users are selected + checkMaxSelect(); + + if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') { + // Unassigned selected + previouslySelected.each((index, element) => { + const id = parseInt(element.value, 10); + element.remove(); + }); + emitSidebarEvent('sidebar.removeAllAssignees'); + } else if (isActive) { + // user selected + emitSidebarEvent('sidebar.addAssignee', user); - if (!$dropdown.data('always-show-selectbox')) { - $selectbox.hide(); + // Remove unassigned selection (if it was previously selected) + const unassignedSelected = $dropdown.closest('.selectbox') + .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]"); - // Recalculate where .value is because vue might have changed it - $block = $selectbox.closest('.block'); - $value = $block.find('.value'); - // display:block overrides the hide-collapse rule - $value.css('display', ''); + if (unassignedSelected) { + unassignedSelected.remove(); + } + } else { + if (previouslySelected.length === 0) { + // Select unassigned because there is no more selected users + this.addInput($dropdown.data('field-name'), 0, {}); } - }, - multiSelect: $dropdown.hasClass('js-multiselect'), - inputMeta: $dropdown.data('input-meta'), - clicked: function(options) { - const { $el, e, isMarking } = options; - const user = options.selectedObj; - - if ($dropdown.hasClass('js-multiselect')) { - const isActive = $el.hasClass('is-active'); - const previouslySelected = $dropdown.closest('.selectbox') - .find("input[name='" + ($dropdown.data('field-name')) + "'][value!=0]"); - - // Enables support for limiting the number of users selected - // Automatically removes the first on the list if more users are selected - checkMaxSelect(); - - if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') { - // Unassigned selected - previouslySelected.each((index, element) => { - const id = parseInt(element.value, 10); - element.remove(); - }); - eventHub.$emit('sidebar.removeAllAssignees'); - } else if (isActive) { - // user selected - eventHub.$emit('sidebar.addAssignee', user); - // Remove unassigned selection (if it was previously selected) - const unassignedSelected = $dropdown.closest('.selectbox') - .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]"); + // User unselected + emitSidebarEvent('sidebar.removeAssignee', user); + } - if (unassignedSelected) { - unassignedSelected.remove(); - } - } else { - if (previouslySelected.length === 0) { - // Select unassigned because there is no more selected users - this.addInput($dropdown.data('field-name'), 0, {}); - } + if (getSelected().find(u => u === gon.current_user_id)) { + $('.assign-to-me-link').hide(); + } else { + $('.assign-to-me-link').show(); + } + } - // User unselected - eventHub.$emit('sidebar.removeAssignee', user); - } + var isIssueIndex, isMRIndex, page, selected; + page = $('body').data('page'); + isIssueIndex = page === 'projects:issues:index'; + isMRIndex = (page === page && page === 'projects:merge_requests:index'); + if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { + e.preventDefault(); - if (getSelected().find(u => u === gon.current_user_id)) { - $('.assign-to-me-link').hide(); - } else { - $('.assign-to-me-link').show(); - } - } + const isSelecting = (user.id !== selectedId); + selectedId = isSelecting ? user.id : selectedIdDefault; - var isIssueIndex, isMRIndex, page, selected; - page = $('body').data('page'); - isIssueIndex = page === 'projects:issues:index'; - isMRIndex = (page === page && page === 'projects:merge_requests:index'); - if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { - e.preventDefault(); + if (selectedId === gon.current_user_id) { + $('.assign-to-me-link').hide(); + } else { + $('.assign-to-me-link').show(); + } + return; + } + if ($el.closest('.add-issues-modal').length) { + gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id; + } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + return Issuable.filterResults($dropdown.closest('form')); + } else if ($dropdown.hasClass('js-filter-submit')) { + return $dropdown.closest('form').submit(); + } else if (!$dropdown.hasClass('js-multiselect')) { + selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val(); + return assignTo(selected); + } + }, + id: function (user) { + return user.id; + }, + opened: function(e) { + const $el = $(e.currentTarget); + if ($dropdown.hasClass('js-issue-board-sidebar')) { + selectedId = parseInt($dropdown[0].dataset.selected, 10) || selectedIdDefault; + } + $el.find('.is-active').removeClass('is-active'); - const isSelecting = (user.id !== selectedId); - selectedId = isSelecting ? user.id : selectedIdDefault; + function highlightSelected(id) { + $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active'); + } - if (selectedId === gon.current_user_id) { - $('.assign-to-me-link').hide(); - } else { - $('.assign-to-me-link').show(); - } - return; - } - if ($el.closest('.add-issues-modal').length) { - gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id; - } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { - return Issuable.filterResults($dropdown.closest('form')); - } else if ($dropdown.hasClass('js-filter-submit')) { - return $dropdown.closest('form').submit(); - } else if (!$dropdown.hasClass('js-multiselect')) { - selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val(); - return assignTo(selected); - } - }, - id: function (user) { - return user.id; - }, - opened: function(e) { - const $el = $(e.currentTarget); - if ($dropdown.hasClass('js-issue-board-sidebar')) { - selectedId = parseInt($dropdown[0].dataset.selected, 10) || selectedIdDefault; - } - $el.find('.is-active').removeClass('is-active'); + if ($selectbox[0]) { + getSelected().forEach(selectedId => highlightSelected(selectedId)); + } else { + highlightSelected(selectedId); + } + }, + updateLabel: $dropdown.data('dropdown-title'), + renderRow: function(user) { + var avatar, img, listClosingTags, listWithName, listWithUserName, username; + username = user.username ? "@" + user.username : ""; + avatar = user.avatar_url ? user.avatar_url : false; - function highlightSelected(id) { - $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active'); - } + let selected = user.id === parseInt(selectedId, 10); - if ($selectbox[0]) { - getSelected().forEach(selectedId => highlightSelected(selectedId)); - } else { - highlightSelected(selectedId); - } - }, - updateLabel: $dropdown.data('dropdown-title'), - renderRow: function(user) { - var avatar, img, listClosingTags, listWithName, listWithUserName, username; - username = user.username ? "@" + user.username : ""; - avatar = user.avatar_url ? user.avatar_url : false; + if (this.multiSelect) { + const fieldName = this.fieldName; + const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']"); - let selected = user.id === parseInt(selectedId, 10); + if (field.length) { + selected = true; + } + } - if (this.multiSelect) { - const fieldName = this.fieldName; - const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']"); + img = ""; + if (user.beforeDivider != null) { + `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`; + } else { + if (avatar) { + img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />"; + } + } - if (field.length) { - selected = true; + return ` + <li data-user-id=${user.id}> + <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'> + ${img} + <strong class='dropdown-menu-user-full-name'> + ${user.name} + </strong> + ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''} + </a> + </li> + `; + } + }); + }; + })(this)); + $('.ajax-users-select').each((function(_this) { + return function(i, select) { + var firstUser, showAnyUser, showEmailUser, showNullUser; + var options = {}; + options.skipLdap = $(select).hasClass('skip_ldap'); + options.projectId = $(select).data('project-id'); + options.groupId = $(select).data('group-id'); + options.showCurrentUser = $(select).data('current-user'); + options.pushCodeToProtectedBranches = $(select).data('push-code-to-protected-branches'); + options.authorId = $(select).data('author-id'); + options.skipUsers = $(select).data('skip-users'); + showNullUser = $(select).data('null-user'); + showAnyUser = $(select).data('any-user'); + showEmailUser = $(select).data('email-user'); + firstUser = $(select).data('first-user'); + return $(select).select2({ + placeholder: "Search for a user", + multiple: $(select).hasClass('multiselect'), + minimumInputLength: 0, + query: function(query) { + return _this.users(query.term, options, function(users) { + var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref; + data = { + results: users + }; + if (query.term.length === 0) { + if (firstUser) { + // Move current user to the front of the list + ref = data.results; + for (index = j = 0, len = ref.length; j < len; index = (j += 1)) { + obj = ref[index]; + if (obj.username === firstUser) { + data.results.splice(index, 1); + data.results.unshift(obj); + break; + } } } - - img = ""; - if (user.beforeDivider != null) { - `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`; - } else { - if (avatar) { - img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />"; + if (showNullUser) { + nullUser = { + name: 'Unassigned', + id: 0 + }; + data.results.unshift(nullUser); + } + if (showAnyUser) { + name = showAnyUser; + if (name === true) { + name = 'Any User'; } + anyUser = { + name: name, + id: null + }; + data.results.unshift(anyUser); } - - return ` - <li data-user-id=${user.id}> - <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'> - ${img} - <strong class='dropdown-menu-user-full-name'> - ${user.name} - </strong> - ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''} - </a> - </li> - `; } - }); - }; - })(this)); - $('.ajax-users-select').each((function(_this) { - return function(i, select) { - var firstUser, showAnyUser, showEmailUser, showNullUser; - var options = {}; - options.skipLdap = $(select).hasClass('skip_ldap'); - options.projectId = $(select).data('project-id'); - options.groupId = $(select).data('group-id'); - options.showCurrentUser = $(select).data('current-user'); - options.pushCodeToProtectedBranches = $(select).data('push-code-to-protected-branches'); - options.authorId = $(select).data('author-id'); - options.skipUsers = $(select).data('skip-users'); - showNullUser = $(select).data('null-user'); - showAnyUser = $(select).data('any-user'); - showEmailUser = $(select).data('email-user'); - firstUser = $(select).data('first-user'); - return $(select).select2({ - placeholder: "Search for a user", - multiple: $(select).hasClass('multiselect'), - minimumInputLength: 0, - query: function(query) { - return _this.users(query.term, options, function(users) { - var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref; - data = { - results: users - }; - if (query.term.length === 0) { - if (firstUser) { - // Move current user to the front of the list - ref = data.results; - for (index = j = 0, len = ref.length; j < len; index = (j += 1)) { - obj = ref[index]; - if (obj.username === firstUser) { - data.results.splice(index, 1); - data.results.unshift(obj); - break; - } - } - } - if (showNullUser) { - nullUser = { - name: 'Unassigned', - id: 0 - }; - data.results.unshift(nullUser); - } - if (showAnyUser) { - name = showAnyUser; - if (name === true) { - name = 'Any User'; - } - anyUser = { - name: name, - id: null - }; - data.results.unshift(anyUser); - } - } - if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) { - var trimmed = query.term.trim(); - emailUser = { - name: "Invite \"" + query.term + "\"", - username: trimmed, - id: trimmed - }; - data.results.unshift(emailUser); - } - return query.callback(data); - }); - }, - initSelection: function() { - var args; - args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return _this.initSelection.apply(_this, args); - }, - formatResult: function() { - var args; - args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return _this.formatResult.apply(_this, args); - }, - formatSelection: function() { - var args; - args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return _this.formatSelection.apply(_this, args); - }, - dropdownCssClass: "ajax-users-dropdown", - // we do not want to escape markup since we are displaying html in results - escapeMarkup: function(m) { - return m; + if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) { + var trimmed = query.term.trim(); + emailUser = { + name: "Invite \"" + query.term + "\"", + username: trimmed, + id: trimmed + }; + data.results.unshift(emailUser); } + return query.callback(data); }); - }; - })(this)); - } - - UsersSelect.prototype.initSelection = function(element, callback) { - var id, nullUser; - id = $(element).val(); - if (id === "0") { - nullUser = { - name: 'Unassigned' - }; - return callback(nullUser); - } else if (id !== "") { - return this.user(id, callback); - } - }; - - UsersSelect.prototype.formatResult = function(user) { - var avatar; - if (user.avatar_url) { - avatar = user.avatar_url; - } else { - avatar = gon.default_avatar_url; - } - return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar s24' src='" + avatar + "'></div> <div class='user-name'>" + user.name + "</div> <div class='user-username'>" + (user.username || "") + "</div> </div>"; - }; - - UsersSelect.prototype.formatSelection = function(user) { - return user.name; - }; - - UsersSelect.prototype.user = function(user_id, callback) { - if (!/^\d+$/.test(user_id)) { - return false; - } - - var url; - url = this.buildUrl(this.userPath); - url = url.replace(':id', user_id); - return $.ajax({ - url: url, - dataType: "json" - }).done(function(user) { - return callback(user); - }); - }; - - // Return users list. Filtered by query - // Only active users retrieved - UsersSelect.prototype.users = function(query, options, callback) { - var url; - url = this.buildUrl(this.usersPath); - return $.ajax({ - url: url, - data: { - search: query, - per_page: 20, - active: true, - project_id: options.projectId || null, - group_id: options.groupId || null, - skip_ldap: options.skipLdap || null, - todo_filter: options.todoFilter || null, - todo_state_filter: options.todoStateFilter || null, - current_user: options.showCurrentUser || null, - push_code_to_protected_branches: options.pushCodeToProtectedBranches || null, - author_id: options.authorId || null, - skip_users: options.skipUsers || null }, - dataType: "json" - }).done(function(users) { - return callback(users); + initSelection: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.initSelection.apply(_this, args); + }, + formatResult: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.formatResult.apply(_this, args); + }, + formatSelection: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.formatSelection.apply(_this, args); + }, + dropdownCssClass: "ajax-users-dropdown", + // we do not want to escape markup since we are displaying html in results + escapeMarkup: function(m) { + return m; + } }); }; - - UsersSelect.prototype.buildUrl = function(url) { - if (gon.relative_url_root != null) { - url = gon.relative_url_root.replace(/\/$/, '') + url; - } - return url; + })(this)); +} + +UsersSelect.prototype.initSelection = function(element, callback) { + var id, nullUser; + id = $(element).val(); + if (id === "0") { + nullUser = { + name: 'Unassigned' }; - - return UsersSelect; - })(); -}).call(window); + return callback(nullUser); + } else if (id !== "") { + return this.user(id, callback); + } +}; + +UsersSelect.prototype.formatResult = function(user) { + var avatar; + if (user.avatar_url) { + avatar = user.avatar_url; + } else { + avatar = gon.default_avatar_url; + } + return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar s24' src='" + avatar + "'></div> <div class='user-name'>" + user.name + "</div> <div class='user-username'>" + (user.username || "") + "</div> </div>"; +}; + +UsersSelect.prototype.formatSelection = function(user) { + return user.name; +}; + +UsersSelect.prototype.user = function(user_id, callback) { + if (!/^\d+$/.test(user_id)) { + return false; + } + + var url; + url = this.buildUrl(this.userPath); + url = url.replace(':id', user_id); + return $.ajax({ + url: url, + dataType: "json" + }).done(function(user) { + return callback(user); + }); +}; + +// Return users list. Filtered by query +// Only active users retrieved +UsersSelect.prototype.users = function(query, options, callback) { + var url; + url = this.buildUrl(this.usersPath); + return $.ajax({ + url: url, + data: { + search: query, + per_page: 20, + active: true, + project_id: options.projectId || null, + group_id: options.groupId || null, + skip_ldap: options.skipLdap || null, + todo_filter: options.todoFilter || null, + todo_state_filter: options.todoStateFilter || null, + current_user: options.showCurrentUser || null, + push_code_to_protected_branches: options.pushCodeToProtectedBranches || null, + author_id: options.authorId || null, + skip_users: options.skipUsers || null + }, + dataType: "json" + }).done(function(users) { + return callback(users); + }); +}; + +UsersSelect.prototype.buildUrl = function(url) { + if (gon.relative_url_root != null) { + url = gon.relative_url_root.replace(/\/$/, '') + url; + } + return url; +}; + +export default UsersSelect; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js new file mode 100644 index 00000000000..a01cb8cc202 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js @@ -0,0 +1,23 @@ +export default { + name: 'MRWidgetAuthor', + props: { + author: { type: Object, required: true }, + showAuthorName: { type: Boolean, required: false, default: true }, + showAuthorTooltip: { type: Boolean, required: false, default: false }, + }, + template: ` + <a + :href="author.webUrl || author.web_url" + class="author-link" + :class="{ 'has-tooltip': showAuthorTooltip }" + :title="author.name"> + <img + :src="author.avatarUrl || author.avatar_url" + class="avatar avatar-inline s16" /> + <span + v-if="showAuthorName" + class="author">{{author.name}} + </span> + </a> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js new file mode 100644 index 00000000000..6d2ed5fda64 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js @@ -0,0 +1,27 @@ +import MRWidgetAuthor from './mr_widget_author'; + +export default { + name: 'MRWidgetAuthorTime', + props: { + actionText: { type: String, required: true }, + author: { type: Object, required: true }, + dateTitle: { type: String, required: true }, + dateReadable: { type: String, required: true }, + }, + components: { + 'mr-widget-author': MRWidgetAuthor, + }, + template: ` + <h4 class="js-mr-widget-author"> + {{actionText}} + <mr-widget-author :author="author" /> + <time + :title="dateTitle" + data-toggle="tooltip" + data-placement="top" + data-container="body"> + {{dateReadable}} + </time> + </h4> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js new file mode 100644 index 00000000000..8b59e018836 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js @@ -0,0 +1,116 @@ +/* global Flash */ + +import '~/lib/utils/datetime_utility'; +import { statusIconEntityMap } from '../../vue_shared/ci_status_icons'; +import MemoryUsage from './mr_widget_memory_usage'; +import MRWidgetService from '../services/mr_widget_service'; + +export default { + name: 'MRWidgetDeployment', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + components: { + 'mr-widget-memory-usage': MemoryUsage, + }, + computed: { + svg() { + return statusIconEntityMap.icon_status_success; + }, + }, + methods: { + formatDate(date) { + return gl.utils.getTimeago().format(date); + }, + hasExternalUrls(deployment = {}) { + return deployment.external_url && deployment.external_url_formatted; + }, + hasDeploymentTime(deployment = {}) { + return deployment.deployed_at && deployment.deployed_at_formatted; + }, + hasDeploymentMeta(deployment = {}) { + return deployment.url && deployment.name; + }, + stopEnvironment(deployment) { + const msg = 'Are you sure you want to stop this environment?'; + const isConfirmed = confirm(msg); // eslint-disable-line + + if (isConfirmed) { + MRWidgetService.stopEnvironment(deployment.stop_url) + .then(res => res.json()) + .then((res) => { + if (res.redirect_url) { + gl.utils.visitUrl(res.redirect_url); + } + }) + .catch(() => { + new Flash('Something went wrong while stopping this environment. Please try again.'); // eslint-disable-line + }); + } + }, + }, + template: ` + <div class="mr-widget-heading"> + <div v-for="deployment in mr.deployments"> + <div class="ci-widget"> + <div class="ci-status-icon ci-status-icon-success"> + <span class="js-icon-link icon-link"> + <span + v-html="svg" + aria-hidden="true"></span> + </span> + </div> + <span> + <span + v-if="hasDeploymentMeta(deployment)"> + Deployed to + </span> + <a + v-if="hasDeploymentMeta(deployment)" + :href="deployment.url" + target="_blank" + rel="noopener noreferrer nofollow" + class="js-deploy-meta"> + {{deployment.name}} + </a> + <span + v-if="hasExternalUrls(deployment)"> + on + </span> + <a + v-if="hasExternalUrls(deployment)" + :href="deployment.external_url" + target="_blank" + rel="noopener noreferrer nofollow" + class="js-deploy-url"> + <i + class="fa fa-external-link" + aria-hidden="true" /> + {{deployment.external_url_formatted}} + </a> + <span + v-if="hasDeploymentTime(deployment)" + :data-title="deployment.deployed_at_formatted" + class="js-deploy-time" + data-toggle="tooltip" + data-placement="top"> + {{formatDate(deployment.deployed_at)}} + </span> + <button + type="button" + v-if="deployment.stop_url" + @click="stopEnvironment(deployment)" + class="btn btn-default btn-xs"> + Stop environment + </button> + </span> + </div> + <mr-widget-memory-usage + v-if="deployment.metrics_url" + :metricsUrl="deployment.metrics_url" + /> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js new file mode 100644 index 00000000000..9e7299fcdeb --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js @@ -0,0 +1,109 @@ +require('../../lib/utils/text_utility'); + +export default { + name: 'MRWidgetHeader', + props: { + mr: { type: Object, required: true }, + }, + computed: { + shouldShowCommitsBehindText() { + return this.mr.divergedCommitsCount > 0; + }, + commitsText() { + return gl.text.pluralize('commit', this.mr.divergedCommitsCount); + }, + branchNameClipboardData() { + // This supports code in app/assets/javascripts/copy_to_clipboard.js that + // works around ClipboardJS limitations to allow the context-specific + // copy/pasting of plain text or GFM. + return JSON.stringify({ + text: this.mr.sourceBranch, + gfm: `\`${this.mr.sourceBranch}\``, + }); + }, + }, + methods: { + isBranchTitleLong(branchTitle) { + return branchTitle.length > 32; + }, + }, + template: ` + <div class="mr-source-target"> + <div + v-if="mr.isOpen" + class="pull-right"> + <a + href="#modal_merge_info" + data-toggle="modal" + class="btn inline btn-grouped btn-sm"> + Check out branch + </a> + <span class="dropdown inline prepend-left-5"> + <a + class="btn btn-sm dropdown-toggle" + data-toggle="dropdown" + aria-label="Download as" + role="button"> + <i + class="fa fa-download" + aria-hidden="true" /> + <i + class="fa fa-caret-down" + aria-hidden="true" /> + </a> + <ul class="dropdown-menu dropdown-menu-align-right"> + <li> + <a + :href="mr.emailPatchesPath" + download> + Email patches + </a> + </li> + <li> + <a + :href="mr.plainDiffPath" + download> + Plain diff + </a> + </li> + </ul> + </span> + </div> + <div class="normal"> + <strong> + Request to merge + <span + class="label-branch" + :class="{'label-truncated has-tooltip': isBranchTitleLong(mr.sourceBranch)}" + :title="isBranchTitleLong(mr.sourceBranch) ? mr.sourceBranch : ''" + data-placement="bottom" + v-html="mr.sourceBranchLink"></span> + <button + class="btn btn-transparent btn-clipboard has-tooltip" + data-title="Copy branch name to clipboard" + :data-clipboard-text="branchNameClipboardData"> + <i + aria-hidden="true" + class="fa fa-clipboard"></i> + </button> + into + <span + class="label-branch" + :class="{'label-truncated has-tooltip': isBranchTitleLong(mr.targetBranch)}" + :title="isBranchTitleLong(mr.targetBranch) ? mr.targetBranch : ''" + data-placement="bottom"> + <a + :href="mr.targetBranchPath"> + {{mr.targetBranch}} + </a> + </span> + </strong> + <span + v-if="shouldShowCommitsBehindText" + class="diverged-commits-count"> + ({{mr.divergedCommitsCount}} {{commitsText}} behind) + </span> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js new file mode 100644 index 00000000000..486b13e60af --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js @@ -0,0 +1,125 @@ +import statusCodes from '~/lib/utils/http_status'; +import MemoryGraph from '../../vue_shared/components/memory_graph'; +import MRWidgetService from '../services/mr_widget_service'; + +export default { + name: 'MemoryUsage', + props: { + metricsUrl: { type: String, required: true }, + }, + data() { + return { + // memoryFrom: 0, + // memoryTo: 0, + memoryMetrics: [], + deploymentTime: 0, + hasMetrics: false, + loadFailed: false, + loadingMetrics: true, + backOffRequestCounter: 0, + }; + }, + components: { + 'mr-memory-graph': MemoryGraph, + }, + computed: { + shouldShowLoading() { + return this.loadingMetrics && !this.hasMetrics && !this.loadFailed; + }, + shouldShowMemoryGraph() { + return !this.loadingMetrics && this.hasMetrics && !this.loadFailed; + }, + shouldShowLoadFailure() { + return !this.loadingMetrics && !this.hasMetrics && this.loadFailed; + }, + shouldShowMetricsUnavailable() { + return !this.loadingMetrics && !this.hasMetrics && !this.loadFailed; + }, + }, + methods: { + computeGraphData(metrics, deploymentTime) { + this.loadingMetrics = false; + const { memory_values } = metrics; + // if (memory_previous.length > 0) { + // this.memoryFrom = Number(memory_previous[0].value[1]).toFixed(2); + // } + // + // if (memory_current.length > 0) { + // this.memoryTo = Number(memory_current[0].value[1]).toFixed(2); + // } + + if (memory_values.length > 0) { + this.hasMetrics = true; + this.memoryMetrics = memory_values[0].values; + this.deploymentTime = deploymentTime; + } + }, + loadMetrics() { + gl.utils.backOff((next, stop) => { + MRWidgetService.fetchMetrics(this.metricsUrl) + .then((res) => { + if (res.status === statusCodes.NO_CONTENT) { + this.backOffRequestCounter = this.backOffRequestCounter += 1; + /* eslint-disable no-unused-expressions */ + this.backOffRequestCounter < 3 ? next() : stop(res); + } else { + stop(res); + } + }) + .catch(stop); + }) + .then((res) => { + if (res.status === statusCodes.NO_CONTENT) { + return res; + } + + return res.json(); + }) + .then((res) => { + this.computeGraphData(res.metrics, res.deployment_time); + return res; + }) + .catch(() => { + this.loadFailed = true; + this.loadingMetrics = false; + }); + }, + }, + mounted() { + this.loadingMetrics = true; + this.loadMetrics(); + }, + template: ` + <div class="mr-info-list clearfix mr-memory-usage js-mr-memory-usage"> + <div class="legend"></div> + <p + v-if="shouldShowLoading" + class="usage-info js-usage-info usage-info-loading"> + <i + class="fa fa-spinner fa-spin usage-info-load-spinner" + aria-hidden="true" />Loading deployment statistics. + </p> + <p + v-if="shouldShowMemoryGraph" + class="usage-info js-usage-info"> + Deployment memory usage: + </p> + <p + v-if="shouldShowLoadFailure" + class="usage-info js-usage-info usage-info-failed"> + Failed to load deployment statistics. + </p> + <p + v-if="shouldShowMetricsUnavailable" + class="usage-info js-usage-info usage-info-unavailable"> + Deployment statistics are not available currently. + </p> + <mr-memory-graph + v-if="shouldShowMemoryGraph" + :metrics="memoryMetrics" + :deploymentTime="deploymentTime" + height="25" + width="100" /> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js new file mode 100644 index 00000000000..2fecebce7a0 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js @@ -0,0 +1,23 @@ +export default { + name: 'MRWidgetMergeHelp', + props: { + missingBranch: { type: String, required: false, default: '' }, + }, + template: ` + <section class="mr-widget-help"> + <template + v-if="missingBranch"> + If the {{missingBranch}} branch exists in your local repository, you + </template> + <template v-else> + You + </template> + can merge this merge request manually using the + <a + data-toggle="modal" + href="#modal_merge_info"> + command line. + </a> + </section> + `, +}; 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 new file mode 100644 index 00000000000..c02e10128e2 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js @@ -0,0 +1,88 @@ +import PipelineStage from '../../pipelines/components/stage.vue'; +import ciIcon from '../../vue_shared/components/ci_icon.vue'; +import { statusIconEntityMap } from '../../vue_shared/ci_status_icons'; + +export default { + name: 'MRWidgetPipeline', + props: { + mr: { type: Object, required: true }, + }, + components: { + 'pipeline-stage': PipelineStage, + ciIcon, + }, + computed: { + hasCIError() { + const { hasCI, ciStatus } = this.mr; + + return hasCI && !ciStatus; + }, + svg() { + return statusIconEntityMap.icon_status_failed; + }, + stageText() { + return this.mr.pipeline.details.stages.length > 1 ? 'stages' : 'stage'; + }, + status() { + return this.mr.pipeline.details.status || {}; + }, + }, + template: ` + <div class="mr-widget-heading"> + <div class="ci-widget"> + <template v-if="hasCIError"> + <div class="ci-status-icon ci-status-icon-failed ci-error js-ci-error"> + <span class="js-icon-link icon-link"> + <span + v-html="svg" + aria-hidden="true"></span> + </span> + </div> + <span>Could not connect to the CI server. Please check your settings and try again.</span> + </template> + <template v-else> + <div> + <a + class="icon-link" + :href="this.status.details_path"> + <ci-icon :status="status" /> + </a> + </div> + <span> + Pipeline + <a + :href="mr.pipeline.path" + class="pipeline-id">#{{mr.pipeline.id}}</a> + {{mr.pipeline.details.status.label}} + </span> + <span + v-if="mr.pipeline.details.stages.length > 0"> + with {{stageText}} + </span> + <div class="mr-widget-pipeline-graph"> + <div class="stage-cell"> + <div + v-if="mr.pipeline.details.stages.length > 0" + v-for="stage in mr.pipeline.details.stages" + class="stage-container dropdown js-mini-pipeline-graph"> + <pipeline-stage :stage="stage" /> + </div> + </div> + </div> + <span> + for + <a + :href="mr.pipeline.commit.commit_path" + class="commit-sha js-commit-link"> + {{mr.pipeline.commit.short_id}}</a>. + </span> + <span + v-if="mr.pipeline.coverage" + class="js-mr-coverage"> + Coverage {{mr.pipeline.coverage}}%. + </span> + </template> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js new file mode 100644 index 00000000000..205804670fa --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js @@ -0,0 +1,42 @@ +export default { + name: 'MRWidgetRelatedLinks', + props: { + relatedLinks: { type: Object, required: true }, + }, + computed: { + hasLinks() { + const { closing, mentioned, assignToMe } = this.relatedLinks; + return closing || mentioned || assignToMe; + }, + }, + methods: { + hasMultipleIssues(text) { + return !text ? false : text.match(/<\/a> and <a/); + }, + issueLabel(field) { + return this.hasMultipleIssues(this.relatedLinks[field]) ? 'issues' : 'issue'; + }, + verbLabel(field) { + return this.hasMultipleIssues(this.relatedLinks[field]) ? 'are' : 'is'; + }, + }, + template: ` + <section + v-if="hasLinks" + class="mr-info-list mr-links"> + <div class="legend"></div> + <p v-if="relatedLinks.closing"> + Closes {{issueLabel('closing')}} + <span v-html="relatedLinks.closing"></span>. + </p> + <p v-if="relatedLinks.mentioned"> + <span class="capitalize">{{issueLabel('mentioned')}}</span> + <span v-html="relatedLinks.mentioned"></span> + {{verbLabel('mentioned')}} mentioned but will not be closed. + </p> + <p v-if="relatedLinks.assignToMe"> + <span v-html="relatedLinks.assignToMe"></span> + </p> + </section> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js new file mode 100644 index 00000000000..c7f25a1697c --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js @@ -0,0 +1,16 @@ +export default { + name: 'MRWidgetArchived', + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + This project is archived, write access has been disabled. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js new file mode 100644 index 00000000000..fcccb17f58d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js @@ -0,0 +1,22 @@ +export default { + name: 'MRWidgetAutoMergeFailed', + props: { + mr: { type: Object, required: true }, + }, + template: ` + <div class="mr-widget-body"> + <button + class="btn btn-success btn-small" + disabled="true" + type="button"> + Merge + </button> + <span class="bold danger"> + This merge request failed to be merged automatically. + </span> + <div class="merge-error-text"> + {{mr.mergeError}} + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js new file mode 100644 index 00000000000..8515b54e62d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js @@ -0,0 +1,19 @@ +export default { + name: 'MRWidgetChecking', + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + Checking ability to merge automatically. + <i + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js new file mode 100644 index 00000000000..fc2e42c6821 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js @@ -0,0 +1,30 @@ +import mrWidgetAuthorTime from '../../components/mr_widget_author_time'; + +export default { + name: 'MRWidgetClosed', + props: { + mr: { type: Object, required: true }, + }, + components: { + 'mr-widget-author-and-time': mrWidgetAuthorTime, + }, + template: ` + <div class="mr-widget-body"> + <mr-widget-author-and-time + actionText="Closed by" + :author="mr.closedBy" + :dateTitle="mr.updatedAt" + :dateReadable="mr.closedAt" + /> + <section> + <p> + The changes were not merged into + <a + :href="mr.targetBranchPath" + class="label-branch"> + {{mr.targetBranch}}</a>. + </p> + </section> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js new file mode 100644 index 00000000000..36596c6f37e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js @@ -0,0 +1,39 @@ +export default { + name: 'MRWidgetConflicts', + props: { + mr: { type: Object, required: true }, + }, + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + There are merge conflicts. + <span v-if="!mr.canMerge"> + Resolve these conflicts or ask someone with write access to this repository to merge it locally. + </span> + </span> + <div + v-if="mr.canMerge" + class="btn-group"> + <a + v-if="mr.conflictResolutionPath" + :href="mr.conflictResolutionPath" + class="btn btn-default btn-xs js-resolve-conflicts-button"> + Resolve conflicts + </a> + <a + v-if="mr.canMerge" + class="btn btn-default btn-xs js-merge-locally-button" + data-toggle="modal" + href="#modal_merge_info"> + Merge locally + </a> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js new file mode 100644 index 00000000000..600b4d42e3d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js @@ -0,0 +1,76 @@ +import eventHub from '../../event_hub'; + +export default { + name: 'MRWidgetFailedToMerge', + props: { + mr: { type: Object, required: true }, + }, + data() { + return { + timer: 10, + isRefreshing: false, + }; + }, + mounted() { + setInterval(() => { + this.updateTimer(); + }, 1000); + }, + created() { + eventHub.$emit('DisablePolling'); + }, + computed: { + timerText() { + return this.timer > 1 ? `${this.timer} seconds` : 'a second'; + }, + }, + methods: { + refresh() { + this.isRefreshing = true; + eventHub.$emit('MRWidgetUpdateRequested'); + eventHub.$emit('EnablePolling'); + }, + updateTimer() { + this.timer = this.timer - 1; + + if (this.timer === 0) { + this.refresh(); + } + }, + }, + template: ` + <div class="mr-widget-body"> + <button + class="btn btn-success btn-small" + disabled="true" + type="button"> + Merge + </button> + <span + v-if="!isRefreshing" + class="bold danger"> + <span + class="has-error-message" + v-if="mr.mergeError"> + {{mr.mergeError}} + </span> + <span v-else>Merge failed.</span> + <span + :class="{ 'has-custom-error': mr.mergeError }"> + Refreshing in {{timerText}} to show the updated status... + </span> + <button + @click="refresh" + class="btn btn-default btn-xs js-refresh-button" + type="button"> + Refresh now + </button> + </span> + <span + v-if="isRefreshing" + class="bold js-refresh-label"> + Refreshing now... + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js new file mode 100644 index 00000000000..0bd31731a0b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js @@ -0,0 +1,24 @@ +export default { + name: 'MRWidgetLocked', + props: { + mr: { type: Object, required: true }, + }, + template: ` + <div class="mr-widget-body mr-state-locked"> + <span class="state-label">Locked</span> + This merge request is in the process of being merged, during which time it is locked and cannot be closed. + <i + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + <section class="mr-info-list mr-links"> + <div class="legend"></div> + <p> + The changes will be merged into + <span class="label-branch"> + <a :href="mr.targetBranchPath">{{mr.targetBranch}}</a> + </span>. + </p> + </section> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js new file mode 100644 index 00000000000..419d174f3ff --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js @@ -0,0 +1,116 @@ +/* global Flash */ + +import MRWidgetAuthor from '../../components/mr_widget_author'; +import eventHub from '../../event_hub'; + +export default { + name: 'MRWidgetMergeWhenPipelineSucceeds', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + components: { + 'mr-widget-author': MRWidgetAuthor, + }, + data() { + return { + isCancellingAutoMerge: false, + isRemovingSourceBranch: false, + }; + }, + computed: { + canRemoveSourceBranch() { + const { shouldRemoveSourceBranch, canRemoveSourceBranch, + mergeUserId, currentUserId } = this.mr; + + return !shouldRemoveSourceBranch && canRemoveSourceBranch && mergeUserId === currentUserId; + }, + }, + methods: { + cancelAutomaticMerge() { + this.isCancellingAutoMerge = true; + this.service.cancelAutomaticMerge() + .then(res => res.json()) + .then((res) => { + eventHub.$emit('UpdateWidgetData', res); + }) + .catch(() => { + this.isCancellingAutoMerge = false; + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + removeSourceBranch() { + const options = { + sha: this.mr.sha, + merge_when_pipeline_succeeds: true, + should_remove_source_branch: true, + }; + + this.isRemovingSourceBranch = true; + this.service.mergeResource.save(options) + .then(res => res.json()) + .then((res) => { + if (res.status === 'merge_when_pipeline_succeeds') { + eventHub.$emit('MRWidgetUpdateRequested'); + } + }) + .catch(() => { + this.isRemovingSourceBranch = false; + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + }, + template: ` + <div class="mr-widget-body"> + <h4> + Set by + <mr-widget-author :author="mr.setToMWPSBy" /> + to be merged automatically when the pipeline succeeds. + <a + v-if="mr.canCancelAutomaticMerge" + @click.prevent="cancelAutomaticMerge" + :disabled="isCancellingAutoMerge" + role="button" + href="#" + class="btn btn-xs btn-default js-cancel-auto-merge"> + <i + v-if="isCancellingAutoMerge" + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + Cancel automatic merge + </a> + </h4> + <section class="mr-info-list"> + <div class="legend"></div> + <p>The changes will be merged into + <a + :href="mr.targetBranchPath" + class="label-branch"> + {{mr.targetBranch}} + </a>. + </p> + <p v-if="mr.shouldRemoveSourceBranch"> + The source branch will be removed. + </p> + <p + v-else + class="with-button"> + The source branch will not be removed. + <a + v-if="canRemoveSourceBranch" + :disabled="isRemovingSourceBranch" + @click.prevent="removeSourceBranch" + role="button" + class="btn btn-xs btn-default js-remove-source-branch" + href="#"> + <i + v-if="isRemovingSourceBranch" + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + Remove source branch + </a> + </p> + </section> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js new file mode 100644 index 00000000000..c7d32d18141 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js @@ -0,0 +1,130 @@ +/* global Flash */ + +import mrWidgetAuthorTime from '../../components/mr_widget_author_time'; +import eventHub from '../../event_hub'; + +export default { + name: 'MRWidgetMerged', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + components: { + 'mr-widget-author-and-time': mrWidgetAuthorTime, + }, + data() { + return { + isMakingRequest: false, + }; + }, + computed: { + shouldShowRemoveSourceBranch() { + const { sourceBranchRemoved, isRemovingSourceBranch, canRemoveSourceBranch } = this.mr; + + return !sourceBranchRemoved && canRemoveSourceBranch && + !this.isMakingRequest && !isRemovingSourceBranch; + }, + shouldShowSourceBranchRemoving() { + const { sourceBranchRemoved, isRemovingSourceBranch } = this.mr; + return !sourceBranchRemoved && (isRemovingSourceBranch || this.isMakingRequest); + }, + shouldShowMergedButtons() { + const { canRevertInCurrentMR, canCherryPickInCurrentMR, revertInForkPath, + cherryPickInForkPath } = this.mr; + + return canRevertInCurrentMR || canCherryPickInCurrentMR || + revertInForkPath || cherryPickInForkPath; + }, + }, + methods: { + removeSourceBranch() { + this.isMakingRequest = true; + this.service.removeSourceBranch() + .then(res => res.json()) + .then((res) => { + if (res.message === 'Branch was removed') { + eventHub.$emit('MRWidgetUpdateRequested', () => { + this.isMakingRequest = false; + }); + } + }) + .catch(() => { + this.isMakingRequest = false; + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + }, + template: ` + <div class="mr-widget-body"> + <mr-widget-author-and-time + actionText="Merged by" + :author="mr.mergedBy" + :dateTitle="mr.updatedAt" + :dateReadable="mr.mergedAt" /> + <section class="mr-info-list"> + <div class="legend"></div> + <p> + The changes were merged into + <span class="label-branch"> + <a :href="mr.targetBranchPath">{{mr.targetBranch}}</a> + </span> + </p> + <p v-if="mr.sourceBranchRemoved">The source branch has been removed.</p> + <p v-if="shouldShowRemoveSourceBranch"> + You can remove source branch now. + <button + @click="removeSourceBranch" + :class="{ disabled: isMakingRequest }" + type="button" + class="btn btn-xs btn-default js-remove-branch-button"> + Remove Source Branch + </button> + </p> + <p v-if="shouldShowSourceBranchRemoving"> + <i + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + The source branch is being removed. + </p> + </section> + <div + v-if="shouldShowMergedButtons" + class="merged-buttons clearfix"> + <a + v-if="mr.canRevertInCurrentMR" + class="btn btn-close btn-sm has-tooltip" + href="#modal-revert-commit" + data-toggle="modal" + data-container="body" + title="Revert this merge request in a new merge request"> + Revert + </a> + <a + v-else-if="mr.revertInForkPath" + class="btn btn-close btn-sm has-tooltip" + data-method="post" + :href="mr.revertInForkPath" + title="Revert this merge request in a new merge request"> + Revert + </a> + <a + v-if="mr.canCherryPickInCurrentMR" + class="btn btn-default btn-sm has-tooltip" + href="#modal-cherry-pick-commit" + data-toggle="modal" + data-container="body" + title="Cherry-pick this merge request in a new merge request"> + Cherry-pick + </a> + <a + v-else-if="mr.cherryPickInForkPath" + class="btn btn-default btn-sm has-tooltip" + data-method="post" + :href="mr.cherryPickInForkPath" + title="Cherry-pick this merge request in a new merge request"> + Cherry-pick + </a> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js new file mode 100644 index 00000000000..328382485f6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js @@ -0,0 +1,34 @@ +import mrWidgetMergeHelp from '../../components/mr_widget_merge_help'; + +export default { + name: 'MRWidgetMissingBranch', + props: { + mr: { type: Object, required: true }, + }, + components: { + 'mr-widget-merge-help': mrWidgetMergeHelp, + }, + computed: { + missingBranchName() { + return this.mr.sourceBranchRemoved ? 'source' : 'target'; + }, + }, + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold js-branch-text"> + <span class="capitalize"> + {{missingBranchName}} + </span> branch does not exist. + Please restore the {{missingBranchName}} branch or use a different {{missingBranchName}} branch. + </span> + <mr-widget-merge-help + :missing-branch="missingBranchName" /> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js new file mode 100644 index 00000000000..07169b349be --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js @@ -0,0 +1,17 @@ +export default { + name: 'MRWidgetNotAllowed', + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + Ready to be merged automatically. + Ask someone with write access to this repository to merge this request. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js new file mode 100644 index 00000000000..8c4535f1337 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js @@ -0,0 +1,17 @@ +export default { + name: 'MRWidgetNothingToMerge', + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + There is nothing to merge from source branch into target branch. + Please push new commits or use a different branch. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js new file mode 100644 index 00000000000..31c53b679ed --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js @@ -0,0 +1,16 @@ +export default { + name: 'MRWidgetPipelineBlocked', + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + Pipeline blocked. The pipeline for this merge request requires a manual action to proceed. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js new file mode 100644 index 00000000000..002820123ca --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js @@ -0,0 +1,16 @@ +export default { + name: 'MRWidgetPipelineBlocked', + template: ` + <div class="mr-widget-body"> + <button + class="btn btn-success btn-small" + disabled="true" + type="button"> + Merge + </button> + <span class="bold"> + The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js new file mode 100644 index 00000000000..ebcc03e531b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js @@ -0,0 +1,309 @@ +/* global Flash */ + +import successSvg from 'icons/_icon_status_success.svg'; +import warningSvg from 'icons/_icon_status_warning.svg'; +import simplePoll from '~/lib/utils/simple_poll'; +import eventHub from '../../event_hub'; + +export default { + name: 'MRWidgetReadyToMerge', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + data() { + return { + removeSourceBranch: true, + mergeWhenBuildSucceeds: false, + useCommitMessageWithDescription: false, + setToMergeWhenPipelineSucceeds: false, + showCommitMessageEditor: false, + isMakingRequest: false, + isMergingImmediately: false, + commitMessage: this.mr.commitMessage, + successSvg, + warningSvg, + }; + }, + computed: { + commitMessageLinkTitle() { + const withDesc = 'Include description in commit message'; + const withoutDesc = "Don't include description in commit message"; + + return this.useCommitMessageWithDescription ? withoutDesc : withDesc; + }, + mergeButtonClass() { + const defaultClass = 'btn btn-success accept-merge-request'; + const failedClass = `${defaultClass} btn-danger`; + const inActionClass = `${defaultClass} btn-info`; + const { pipeline, isPipelineActive, isPipelineFailed, hasCI, ciStatus } = this.mr; + + if (hasCI && !ciStatus) { + return failedClass; + } else if (!pipeline) { + return defaultClass; + } else if (isPipelineActive) { + return inActionClass; + } else if (isPipelineFailed) { + return failedClass; + } + + return defaultClass; + }, + mergeButtonText() { + if (this.isMergingImmediately) { + return 'Merge in progress'; + } else if (this.mr.isPipelineActive) { + return 'Merge when pipeline succeeds'; + } + + return 'Merge'; + }, + shouldShowMergeOptionsDropdown() { + return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds; + }, + isMergeButtonDisabled() { + const { commitMessage } = this; + return Boolean(!commitMessage.length + || !this.isMergeAllowed() + || this.isMakingRequest + || this.mr.preventMerge); + }, + shouldShowSquashBeforeMerge() { + const { commitsCount, enableSquashBeforeMerge } = this.mr; + return enableSquashBeforeMerge && commitsCount > 1; + }, + }, + methods: { + isMergeAllowed() { + return !(this.mr.onlyAllowMergeIfPipelineSucceeds && this.mr.isPipelineFailed); + }, + updateCommitMessage() { + const cmwd = this.mr.commitMessageWithDescription; + this.useCommitMessageWithDescription = !this.useCommitMessageWithDescription; + this.commitMessage = this.useCommitMessageWithDescription ? cmwd : this.mr.commitMessage; + }, + toggleCommitMessageEditor() { + this.showCommitMessageEditor = !this.showCommitMessageEditor; + }, + handleMergeButtonClick(mergeWhenBuildSucceeds, mergeImmediately) { + // TODO: Remove no-param-reassign + if (mergeWhenBuildSucceeds === undefined) { + mergeWhenBuildSucceeds = this.mr.isPipelineActive; // eslint-disable-line no-param-reassign + } else if (mergeImmediately) { + this.isMergingImmediately = true; + } + + this.setToMergeWhenPipelineSucceeds = mergeWhenBuildSucceeds === true; + + const options = { + sha: this.mr.sha, + commit_message: this.commitMessage, + merge_when_pipeline_succeeds: this.setToMergeWhenPipelineSucceeds, + should_remove_source_branch: this.removeSourceBranch === true, + }; + + // Only truthy in EE extension of this component + if (this.setAdditionalParams) { + this.setAdditionalParams(options); + } + + this.isMakingRequest = true; + this.service.merge(options) + .then(res => res.json()) + .then((res) => { + const hasError = res.status === 'failed' || res.status === 'hook_validation_error'; + + if (res.status === 'merge_when_pipeline_succeeds') { + eventHub.$emit('MRWidgetUpdateRequested'); + } else if (res.status === 'success') { + this.initiateMergePolling(); + } else if (hasError) { + eventHub.$emit('FailedToMerge', res.merge_error); + } + }) + .catch(() => { + this.isMakingRequest = false; + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + initiateMergePolling() { + simplePoll((continuePolling, stopPolling) => { + this.handleMergePolling(continuePolling, stopPolling); + }); + }, + handleMergePolling(continuePolling, stopPolling) { + this.service.poll() + .then(res => res.json()) + .then((res) => { + if (res.state === 'merged') { + // If state is merged we should update the widget and stop the polling + eventHub.$emit('MRWidgetUpdateRequested'); + eventHub.$emit('FetchActionsContent'); + if (window.mergeRequest) { + window.mergeRequest.updateStatusText('status-box-open', 'status-box-merged', 'Merged'); + window.mergeRequest.decreaseCounter(); + } + stopPolling(); + + // If user checked remove source branch and we didn't remove the branch yet + // we should start another polling for source branch remove process + if (this.removeSourceBranch && res.source_branch_exists) { + this.initiateRemoveSourceBranchPolling(); + } + } else if (res.merge_error) { + eventHub.$emit('FailedToMerge', res.merge_error); + stopPolling(); + } else { + // MR is not merged yet, continue polling until the state becomes 'merged' + continuePolling(); + } + }) + .catch(() => { + new Flash('Something went wrong while merging this merge request. Please try again.'); // eslint-disable-line + }); + }, + initiateRemoveSourceBranchPolling() { + // We need to show source branch is being removed spinner in another component + eventHub.$emit('SetBranchRemoveFlag', [true]); + + simplePoll((continuePolling, stopPolling) => { + this.handleRemoveBranchPolling(continuePolling, stopPolling); + }); + }, + handleRemoveBranchPolling(continuePolling, stopPolling) { + this.service.poll() + .then(res => res.json()) + .then((res) => { + // If source branch exists then we should continue polling + // because removing a source branch is a background task and takes time + if (res.source_branch_exists) { + continuePolling(); + } else { + // Branch is removed. Update widget, stop polling and hide the spinner + eventHub.$emit('MRWidgetUpdateRequested', () => { + eventHub.$emit('SetBranchRemoveFlag', [false]); + }); + stopPolling(); + } + }) + .catch(() => { + new Flash('Something went wrong while removing the source branch. Please try again.'); // eslint-disable-line + }); + }, + }, + template: ` + <div class="mr-widget-body"> + <span class="btn-group"> + <button + @click="handleMergeButtonClick()" + :disabled="isMergeButtonDisabled" + :class="mergeButtonClass" + type="button"> + <i + v-if="isMakingRequest" + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + {{mergeButtonText}} + </button> + <button + v-if="shouldShowMergeOptionsDropdown" + :disabled="isMergeButtonDisabled" + type="button" + class="btn btn-info dropdown-toggle" + data-toggle="dropdown"> + <i + class="fa fa-caret-down" + aria-hidden="true" /> + <span class="sr-only"> + Select merge moment + </span> + </button> + <ul + v-if="shouldShowMergeOptionsDropdown" + class="dropdown-menu dropdown-menu-right" + role="menu"> + <li> + <a + @click.prevent="handleMergeButtonClick(true)" + class="merge_when_pipeline_succeeds" + href="#"> + <span + v-html="successSvg" + class="merge-opt-icon" + aria-hidden="true"></span> + <span class="merge-opt-title">Merge when pipeline succeeds</span> + </a> + </li> + <li> + <a + @click.prevent="handleMergeButtonClick(false, true)" + class="accept-merge-request" + href="#"> + <span + v-html="warningSvg" + class="merge-opt-icon" + aria-hidden="true"></span> + <span class="merge-opt-title">Merge immediately</span> + </a> + </li> + </ul> + </span> + <template v-if="isMergeAllowed()"> + <label class="spacing"> + <input + v-model="removeSourceBranch" + :disabled="isMergeButtonDisabled" + type="checkbox"/> Remove source branch + </label> + + <!-- Placeholder for EE extension of this component --> + <squash-before-merge + v-if="shouldShowSquashBeforeMerge" + :mr="mr" + :is-merge-button-disabled="isMergeButtonDisabled" /> + + <button + @click="toggleCommitMessageEditor" + :disabled="isMergeButtonDisabled" + class="btn btn-default btn-xs" + type="button"> + Modify commit message + </button> + <div + v-if="showCommitMessageEditor" + class="prepend-top-default commit-message-editor"> + <div class="form-group clearfix"> + <label + class="control-label" + for="commit-message"> + Commit message + </label> + <div class="col-sm-10"> + <div class="commit-message-container"> + <div class="max-width-marker"></div> + <textarea + v-model="commitMessage" + class="form-control js-commit-message" + required="required" + rows="14" + name="Commit message"></textarea> + </div> + <p class="hint">Try to keep the first line under 52 characters and the others under 72.</p> + <div class="hint"> + <a + @click.prevent="updateCommitMessage" + href="#">{{commitMessageLinkTitle}}</a> + </div> + </div> + </div> + </div> + </template> + <template v-else> + <span class="bold"> + The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure. + </span> + </template> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js new file mode 100644 index 00000000000..79f8ef408e6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js @@ -0,0 +1,16 @@ +export default { + name: 'MRWidgetSHAMismatch', + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + The source branch HEAD has recently changed. Please reload the page and review the changes before merging. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js new file mode 100644 index 00000000000..bf8628d18a6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js @@ -0,0 +1,15 @@ +/* +The squash-before-merge button is EE only, but it's located right in the middle +of the readyToMerge state component template. + +If we didn't declare this component in CE, we'd need to maintain a separate copy +of the readyToMergeState template in EE, which is pretty big and likely to change. + +Instead, in CE, we declare the component, but it's hidden and is configured to do nothing. +In EE, the configuration extends this object to add a functioning squash-before-merge +button. +*/ + +export default { + template: '', +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js new file mode 100644 index 00000000000..f4ab2d9fa58 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js @@ -0,0 +1,27 @@ +export default { + name: 'MRWidgetUnresolvedDiscussions', + props: { + mr: { type: Object, required: true }, + }, + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + There are unresolved discussions. Please resolve these discussions + <span v-if="mr.canCreateIssue">or</span> + <span v-else>.</span> + </span> + <a + v-if="mr.createIssueToResolveDiscussionsPath" + :href="mr.createIssueToResolveDiscussionsPath" + class="btn btn-default btn-xs js-create-issue"> + Create an issue to resolve them later + </a> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js new file mode 100644 index 00000000000..cb02ffe93bd --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js @@ -0,0 +1,59 @@ +/* global Flash */ +import eventHub from '../../event_hub'; + +export default { + name: 'MRWidgetWIP', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + data() { + return { + isMakingRequest: false, + }; + }, + methods: { + removeWIP() { + this.isMakingRequest = true; + this.service.removeWIP() + .then(res => res.json()) + .then((res) => { + eventHub.$emit('UpdateWidgetData', res); + new Flash('The merge request can now be merged.', 'notice'); // eslint-disable-line + $('.merge-request .detail-page-description .title').text(this.mr.title); + }) + .catch(() => { + this.isMakingRequest = false; + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + }, + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge</button> + <span class="bold"> + This merge request is currently Work In Progress and therefore unable to merge + </span> + <template v-if="mr.removeWIPPath"> + <i + class="fa fa-question-circle has-tooltip" + title="When this merge request is ready, remove the WIP: prefix from the title to allow it to be merged." /> + <button + @click="removeWIP" + :disabled="isMakingRequest" + type="button" + class="btn btn-default btn-xs js-remove-wip"> + <i + v-if="isMakingRequest" + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + Resolve WIP status + </button> + </template> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js new file mode 100644 index 00000000000..bfe30ee4c08 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -0,0 +1,43 @@ +/** + * This file is the centerpiece of an attempt to reduce potential conflicts + * between the CE and EE versions of the MR widget. EE additions to the MR widget should + * be contained in the ./vue_merge_request_widget/ee directory, and should **extend** + * rather than mutate CE MR Widget code. + * + * This file should be the only source of conflicts between EE and CE. EE-only components should + * imported directly where they are needed, and import paths for EE extensions of CE components + * should overwrite import paths **without** changing the order of dependencies listed here. + */ + +export { default as Vue } from 'vue'; +export { default as SmartInterval } from '~/smart_interval'; +export { default as WidgetHeader } from './components/mr_widget_header'; +export { default as WidgetMergeHelp } from './components/mr_widget_merge_help'; +export { default as WidgetPipeline } from './components/mr_widget_pipeline'; +export { default as WidgetDeployment } from './components/mr_widget_deployment'; +export { default as WidgetRelatedLinks } from './components/mr_widget_related_links'; +export { default as MergedState } from './components/states/mr_widget_merged'; +export { default as FailedToMerge } from './components/states/mr_widget_failed_to_merge'; +export { default as ClosedState } from './components/states/mr_widget_closed'; +export { default as LockedState } from './components/states/mr_widget_locked'; +export { default as WipState } from './components/states/mr_widget_wip'; +export { default as ArchivedState } from './components/states/mr_widget_archived'; +export { default as ConflictsState } from './components/states/mr_widget_conflicts'; +export { default as NothingToMergeState } from './components/states/mr_widget_nothing_to_merge'; +export { default as MissingBranchState } from './components/states/mr_widget_missing_branch'; +export { default as NotAllowedState } from './components/states/mr_widget_not_allowed'; +export { default as ReadyToMergeState } from './components/states/mr_widget_ready_to_merge'; +export { default as SHAMismatchState } from './components/states/mr_widget_sha_mismatch'; +export { default as UnresolvedDiscussionsState } from './components/states/mr_widget_unresolved_discussions'; +export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked'; +export { default as PipelineFailedState } from './components/states/mr_widget_pipeline_failed'; +export { default as MergeWhenPipelineSucceedsState } from './components/states/mr_widget_merge_when_pipeline_succeeds'; +export { default as AutoMergeFailed } from './components/states/mr_widget_auto_merge_failed'; +export { default as CheckingState } from './components/states/mr_widget_checking'; +export { default as MRWidgetStore } from './stores/mr_widget_store'; +export { default as MRWidgetService } from './services/mr_widget_service'; +export { default as eventHub } from './event_hub'; +export { default as getStateKey } from './stores/get_state_key'; +export { default as mrWidgetOptions } from './mr_widget_options'; +export { default as stateMaps } from './stores/state_maps'; +export { default as SquashBeforeMerge } from './components/states/mr_widget_squash_before_merge'; diff --git a/app/assets/javascripts/vue_merge_request_widget/event_hub.js b/app/assets/javascripts/vue_merge_request_widget/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js new file mode 100644 index 00000000000..cd65ac069c5 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -0,0 +1,12 @@ +import { + Vue, + mrWidgetOptions, +} from './dependencies'; + +document.addEventListener('DOMContentLoaded', () => { + const vm = new Vue(mrWidgetOptions); + + window.gl.mrWidget = { + checkStatus: vm.checkStatus, + }; +}); diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js new file mode 100644 index 00000000000..5452e19bd8e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js @@ -0,0 +1,236 @@ +/* global Flash */ + +import { + WidgetHeader, + WidgetMergeHelp, + WidgetPipeline, + WidgetDeployment, + WidgetRelatedLinks, + MergedState, + ClosedState, + LockedState, + WipState, + ArchivedState, + ConflictsState, + NothingToMergeState, + MissingBranchState, + NotAllowedState, + ReadyToMergeState, + SHAMismatchState, + UnresolvedDiscussionsState, + PipelineBlockedState, + PipelineFailedState, + FailedToMerge, + MergeWhenPipelineSucceedsState, + AutoMergeFailed, + CheckingState, + MRWidgetStore, + MRWidgetService, + eventHub, + stateMaps, + SquashBeforeMerge, +} from './dependencies'; + +export default { + el: '#js-vue-mr-widget', + name: 'MRWidget', + data() { + const store = new MRWidgetStore(gl.mrWidgetData); + const service = this.createService(store); + return { + mr: store, + service, + }; + }, + computed: { + componentName() { + return stateMaps.stateToComponentMap[this.mr.state]; + }, + shouldRenderMergeHelp() { + return stateMaps.statesToShowHelpWidget.indexOf(this.mr.state) > -1; + }, + shouldRenderPipelines() { + return Object.keys(this.mr.pipeline).length || this.mr.hasCI; + }, + shouldRenderRelatedLinks() { + return this.mr.relatedLinks; + }, + shouldRenderDeployments() { + return this.mr.deployments.length; + }, + }, + methods: { + createService(store) { + const endpoints = { + mergePath: store.mergePath, + mergeCheckPath: store.mergeCheckPath, + cancelAutoMergePath: store.cancelAutoMergePath, + removeWIPPath: store.removeWIPPath, + sourceBranchPath: store.sourceBranchPath, + ciEnvironmentsStatusPath: store.ciEnvironmentsStatusPath, + statusPath: store.statusPath, + mergeActionsContentPath: store.mergeActionsContentPath, + }; + return new MRWidgetService(endpoints); + }, + checkStatus(cb) { + this.service.checkStatus() + .then(res => res.json()) + .then((res) => { + this.mr.setData(res); + this.setFavicon(); + if (cb) { + cb.call(null, res); + } + }) + .catch(() => { + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + initPolling() { + this.pollingInterval = new gl.SmartInterval({ + callback: this.checkStatus, + startingInterval: 10000, + maxInterval: 30000, + hiddenInterval: 120000, + incrementByFactorOf: 5000, + }); + }, + initDeploymentsPolling() { + this.deploymentsInterval = new gl.SmartInterval({ + callback: this.fetchDeployments, + startingInterval: 30000, + maxInterval: 120000, + hiddenInterval: 240000, + incrementByFactorOf: 15000, + immediateExecution: true, + }); + }, + setFavicon() { + if (this.mr.ciStatusFaviconPath) { + gl.utils.setFavicon(this.mr.ciStatusFaviconPath); + } + }, + fetchDeployments() { + this.service.fetchDeployments() + .then(res => res.json()) + .then((res) => { + if (res.length) { + this.mr.deployments = res; + } + }) + .catch(() => { + new Flash('Something went wrong while fetching the environments for this merge request. Please try again.'); // eslint-disable-line + }); + }, + fetchActionsContent() { + this.service.fetchMergeActionsContent() + .then((res) => { + if (res.body) { + const el = document.createElement('div'); + el.innerHTML = res.body; + document.body.appendChild(el); + } + }) + .catch(() => { + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + resumePolling() { + this.pollingInterval.resume(); + }, + stopPolling() { + this.pollingInterval.stopTimer(); + }, + bindEventHubListeners() { + eventHub.$on('MRWidgetUpdateRequested', (cb) => { + this.checkStatus(cb); + }); + + // `params` should be an Array contains a Boolean, like `[true]` + // Passing parameter as Boolean didn't work. + eventHub.$on('SetBranchRemoveFlag', (params) => { + this.mr.isRemovingSourceBranch = params[0]; + }); + + eventHub.$on('FailedToMerge', (mergeError) => { + this.mr.state = 'failedToMerge'; + this.mr.mergeError = mergeError; + }); + + eventHub.$on('UpdateWidgetData', (data) => { + this.mr.setData(data); + }); + + eventHub.$on('FetchActionsContent', () => { + this.fetchActionsContent(); + }); + + eventHub.$on('EnablePolling', () => { + this.resumePolling(); + }); + + eventHub.$on('DisablePolling', () => { + this.stopPolling(); + }); + }, + handleMounted() { + this.checkStatus(); + this.setFavicon(); + this.initDeploymentsPolling(); + }, + }, + created() { + this.initPolling(); + this.bindEventHubListeners(); + }, + mounted() { + this.handleMounted(); + }, + components: { + 'mr-widget-header': WidgetHeader, + 'mr-widget-merge-help': WidgetMergeHelp, + 'mr-widget-pipeline': WidgetPipeline, + 'mr-widget-deployment': WidgetDeployment, + 'mr-widget-related-links': WidgetRelatedLinks, + 'mr-widget-merged': MergedState, + 'mr-widget-closed': ClosedState, + 'mr-widget-locked': LockedState, + 'mr-widget-failed-to-merge': FailedToMerge, + 'mr-widget-wip': WipState, + 'mr-widget-archived': ArchivedState, + 'mr-widget-conflicts': ConflictsState, + 'mr-widget-nothing-to-merge': NothingToMergeState, + 'mr-widget-not-allowed': NotAllowedState, + 'mr-widget-missing-branch': MissingBranchState, + 'mr-widget-ready-to-merge': ReadyToMergeState, + 'mr-widget-sha-mismatch': SHAMismatchState, + 'mr-widget-squash-before-merge': SquashBeforeMerge, + 'mr-widget-checking': CheckingState, + 'mr-widget-unresolved-discussions': UnresolvedDiscussionsState, + 'mr-widget-pipeline-blocked': PipelineBlockedState, + 'mr-widget-pipeline-failed': PipelineFailedState, + 'mr-widget-merge-when-pipeline-succeeds': MergeWhenPipelineSucceedsState, + 'mr-widget-auto-merge-failed': AutoMergeFailed, + }, + template: ` + <div class="mr-state-widget prepend-top-default"> + <mr-widget-header :mr="mr" /> + <mr-widget-pipeline + v-if="shouldRenderPipelines" + :mr="mr" /> + <mr-widget-deployment + v-if="shouldRenderDeployments" + :mr="mr" + :service="service" /> + <component + :is="componentName" + :mr="mr" + :service="service" /> + <mr-widget-related-links + v-if="shouldRenderRelatedLinks" + :related-links="mr.relatedLinks" /> + <mr-widget-merge-help v-if="shouldRenderMergeHelp" /> + </div> + `, +}; 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 new file mode 100644 index 00000000000..42493be3372 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -0,0 +1,57 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class MRWidgetService { + constructor(endpoints) { + this.mergeResource = Vue.resource(endpoints.mergePath); + this.mergeCheckResource = Vue.resource(endpoints.mergeCheckPath); + this.cancelAutoMergeResource = Vue.resource(endpoints.cancelAutoMergePath); + 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.mergeActionsContentResource = Vue.resource(endpoints.mergeActionsContentPath); + } + + merge(data) { + return this.mergeResource.save(data); + } + + cancelAutomaticMerge() { + return this.cancelAutoMergeResource.save(); + } + + removeWIP() { + return this.removeWIPResource.save(); + } + + removeSourceBranch() { + return this.removeSourceBranchResource.delete(); + } + + fetchDeployments() { + return this.deploymentsResource.get(); + } + + poll() { + return this.pollResource.get(); + } + + checkStatus() { + return this.mergeCheckResource.get(); + } + + fetchMergeActionsContent() { + return this.mergeActionsContentResource.get(); + } + + static stopEnvironment(url) { + return Vue.http.post(url); + } + + static fetchMetrics(metricsUrl) { + return Vue.http.get(`${metricsUrl}.json`); + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js new file mode 100644 index 00000000000..fb78ea92da1 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -0,0 +1,30 @@ +export default function deviseState(data) { + if (data.project_archived) { + return 'archived'; + } else if (data.branch_missing) { + return 'missingBranch'; + } else if (!data.commits_count) { + return 'nothingToMerge'; + } else if (this.mergeStatus === 'unchecked') { + return 'checking'; + } else if (data.has_conflicts) { + return 'conflicts'; + } else if (data.work_in_progress) { + return 'workInProgress'; + } else if (this.mergeWhenPipelineSucceeds) { + return this.mergeError ? 'autoMergeFailed' : 'mergeWhenPipelineSucceeds'; + } else if (!this.canMerge) { + return 'notAllowedToMerge'; + } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { + return 'pipelineFailed'; + } else if (this.hasMergeableDiscussionsState) { + return 'unresolvedDiscussions'; + } else if (this.isPipelineBlocked) { + return 'pipelineBlocked'; + } else if (this.hasSHAChanged) { + return 'shaMismatch'; + } else if (this.canBeMerged) { + return 'readyToMerge'; + } + return null; +} diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js new file mode 100644 index 00000000000..05e67706983 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -0,0 +1,136 @@ +import Timeago from 'timeago.js'; +import { getStateKey } from '../dependencies'; + +export default class MergeRequestStore { + + constructor(data) { + this.startingSha = data.diff_head_sha; + this.setData(data); + } + + setData(data) { + const currentUser = data.current_user; + const pipelineStatus = data.pipeline ? data.pipeline.details.status : null; + + this.title = data.title; + this.targetBranch = data.target_branch; + this.sourceBranch = data.source_branch; + this.mergeStatus = data.merge_status; + this.sha = data.diff_head_sha; + this.commitMessage = data.merge_commit_message; + this.commitMessageWithDescription = data.merge_commit_message_with_description; + this.commitsCount = data.commits_count; + this.divergedCommitsCount = data.diverged_commits_count; + this.pipeline = data.pipeline || {}; + this.deployments = this.deployments || data.deployments || []; + + if (data.issues_links) { + const links = data.issues_links; + const { closing } = links; + const mentioned = links.mentioned_but_not_closing; + const assignToMe = links.assign_to_closing; + + if (closing || mentioned || assignToMe) { + this.relatedLinks = { closing, mentioned, assignToMe }; + } + } + + this.updatedAt = data.updated_at; + this.mergedAt = MergeRequestStore.getEventDate(data.merge_event); + this.closedAt = MergeRequestStore.getEventDate(data.closed_event); + this.mergedBy = MergeRequestStore.getAuthorObject(data.merge_event); + this.closedBy = MergeRequestStore.getAuthorObject(data.closed_event); + this.setToMWPSBy = MergeRequestStore.getAuthorObject({ author: data.merge_user || {} }); + this.mergeUserId = data.merge_user_id; + this.currentUserId = gon.current_user_id; + this.sourceBranchPath = data.source_branch_path; + this.sourceBranchLink = data.source_branch_with_namespace_link; + this.mergeError = data.merge_error; + this.targetBranchPath = data.target_branch_commits_path; + this.conflictResolutionPath = data.conflict_resolution_path; + this.cancelAutoMergePath = data.cancel_merge_when_pipeline_succeeds_path; + this.removeWIPPath = data.remove_wip_path; + this.sourceBranchRemoved = !data.source_branch_exists; + this.shouldRemoveSourceBranch = (data.merge_params || {}).should_remove_source_branch || false; + this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false; + this.mergeWhenPipelineSucceeds = data.merge_when_pipeline_succeeds || false; + this.mergePath = data.merge_path; + this.statusPath = data.status_path; + this.emailPatchesPath = data.email_patches_path; + this.plainDiffPath = data.plain_diff_path; + this.createIssueToResolveDiscussionsPath = data.create_issue_to_resolve_discussions_path; + this.mergeCheckPath = data.merge_check_path; + this.mergeActionsContentPath = data.commit_change_content_path; + this.isRemovingSourceBranch = this.isRemovingSourceBranch || false; + this.isOpen = data.state === 'opened' || data.state === 'reopened' || false; + this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false; + this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false; + this.canMerge = !!data.merge_path; + this.canCreateIssue = currentUser.can_create_issue || false; + this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path; + this.hasSHAChanged = this.sha !== this.startingSha; + this.canBeMerged = data.can_be_merged || false; + + // Cherry-pick and Revert actions related + this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false; + this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false; + this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path; + this.revertInForkPath = currentUser.revert_in_fork_path; + + // CI related + this.ciEnvironmentsStatusPath = data.ci_environments_status_path; + this.hasCI = data.has_ci; + this.ciStatus = data.ci_status; + this.isPipelineFailed = this.ciStatus ? (this.ciStatus === 'failed' || this.ciStatus === 'canceled') : false; + this.pipelineDetailedStatus = pipelineStatus; + this.isPipelineActive = data.pipeline ? data.pipeline.active : false; + this.isPipelineBlocked = pipelineStatus ? pipelineStatus.group === 'manual' : false; + this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null; + + this.setState(data); + } + + setState(data) { + if (this.isOpen) { + this.state = getStateKey.call(this, data); + } else { + switch (data.state) { + case 'merged': + this.state = 'merged'; + break; + case 'closed': + this.state = 'closed'; + break; + case 'locked': + this.state = 'locked'; + break; + default: + this.state = null; + } + } + } + + static getAuthorObject(event) { + if (!event) { + return {}; + } + + return { + name: event.author.name || '', + username: event.author.username || '', + webUrl: event.author.web_url || '', + avatarUrl: event.author.avatar_url || '', + }; + } + + static getEventDate(event) { + const timeagoInstance = new Timeago(); + + if (!event) { + return ''; + } + + return timeagoInstance.format(event.updated_at); + } + +} diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js new file mode 100644 index 00000000000..605dd3a1ff4 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -0,0 +1,37 @@ +const stateToComponentMap = { + merged: 'mr-widget-merged', + closed: 'mr-widget-closed', + locked: 'mr-widget-locked', + conflicts: 'mr-widget-conflicts', + missingBranch: 'mr-widget-missing-branch', + workInProgress: 'mr-widget-wip', + readyToMerge: 'mr-widget-ready-to-merge', + nothingToMerge: 'mr-widget-nothing-to-merge', + notAllowedToMerge: 'mr-widget-not-allowed', + archived: 'mr-widget-archived', + checking: 'mr-widget-checking', + unresolvedDiscussions: 'mr-widget-unresolved-discussions', + pipelineBlocked: 'mr-widget-pipeline-blocked', + pipelineFailed: 'mr-widget-pipeline-failed', + mergeWhenPipelineSucceeds: 'mr-widget-merge-when-pipeline-succeeds', + failedToMerge: 'mr-widget-failed-to-merge', + autoMergeFailed: 'mr-widget-auto-merge-failed', + shaMismatch: 'mr-widget-sha-mismatch', +}; + +const statesToShowHelpWidget = [ + 'locked', + 'conflicts', + 'workInProgress', + 'readyToMerge', + 'checking', + 'unresolvedDiscussions', + 'pipelineFailed', + 'pipelineBlocked', + 'autoMergeFailed', +]; + +export default { + stateToComponentMap, + statesToShowHelpWidget, +}; diff --git a/app/assets/javascripts/vue_shared/ci_action_icons.js b/app/assets/javascripts/vue_shared/ci_action_icons.js index 734b3c6c45e..b21f0ab49fd 100644 --- a/app/assets/javascripts/vue_shared/ci_action_icons.js +++ b/app/assets/javascripts/vue_shared/ci_action_icons.js @@ -1,22 +1,21 @@ 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) { - let icon; - switch (action) { - case 'icon_action_cancel': - icon = cancelSVG; - break; - case 'icon_action_retry': - icon = retrySVG; - break; - case 'icon_action_play': - icon = playSVG; - break; - default: - icon = ''; - } + const icons = { + icon_action_cancel: cancelSVG, + icon_action_play: playSVG, + icon_action_retry: retrySVG, + icon_action_stop: stopSVG, + }; - return icon; + 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 index 48ad9214ac8..d9d0cad38e4 100644 --- a/app/assets/javascripts/vue_shared/ci_status_icons.js +++ b/app/assets/javascripts/vue_shared/ci_status_icons.js @@ -41,15 +41,3 @@ export const statusIconEntityMap = { icon_status_success: SUCCESS_SVG, icon_status_warning: WARNING_SVG, }; - -export const statusCssClasses = { - icon_status_canceled: 'canceled', - icon_status_created: 'created', - icon_status_failed: 'failed', - icon_status_manual: 'manual', - icon_status_pending: 'pending', - icon_status_running: 'running', - icon_status_skipped: 'skipped', - icon_status_success: 'success', - icon_status_warning: 'warning', -}; diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue new file mode 100644 index 00000000000..caa28bff6db --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -0,0 +1,52 @@ +<script> +import ciIcon from './ci_icon.vue'; +/** + * Renders CI Badge link with CI icon and status text based on + * API response shared between all places where it is used. + * + * Receives status object containing: + * status: { + * details_path: "/gitlab-org/gitlab-ce/pipelines/8150156" // url + * group:"running" // used for CSS class + * icon: "icon_status_running" // used to render the icon + * label:"running" // used for potential tooltip + * text:"running" // text rendered + * } + * + * Used in: + * - Pipelines table - first column + * - Jobs table - first column + * - Pipeline show view - header + * - Job show view - header + * - MR widget + */ + +export default { + props: { + status: { + type: Object, + required: true, + }, + }, + + components: { + ciIcon, + }, + + computed: { + cssClass() { + const className = this.status.group; + + return className ? `ci-status ci-${this.status.group}` : 'ci-status'; + }, + }, +}; +</script> +<template> + <a + :href="status.details_path" + :class="cssClass"> + <ci-icon :status="status" /> + {{status.text}} + </a> +</template> diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index 4d44baaa3c4..ec88119e16c 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -1,6 +1,27 @@ <script> - import { statusIconEntityMap, statusCssClasses } from '../../vue_shared/ci_status_icons'; + import { statusIconEntityMap } from '../ci_status_icons'; + /** + * Renders CI icon based on API response shared between all places where it is used. + * + * Receives status object containing: + * status: { + * details_path: "/gitlab-org/gitlab-ce/pipelines/8150156" // url + * group:"running" // used for CSS class + * icon: "icon_status_running" // used to render the icon + * label:"running" // used for potential tooltip + * text:"running" // text rendered + * } + * + * Used in: + * - Pipelines table Badge + * - Pipelines table mini graph + * - Pipeline graph + * - Pipeline show view badge + * - Jobs table + * - Jobs show view header + * - Jobs show view sidebar + */ export default { props: { status: { @@ -15,7 +36,7 @@ }, cssClass() { - const status = statusCssClasses[this.status.icon]; + const status = this.status.group; return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`; }, }, diff --git a/app/assets/javascripts/vue_shared/components/commit.js b/app/assets/javascripts/vue_shared/components/commit.js index fb68abd95a2..9b060a0a35f 100644 --- a/app/assets/javascripts/vue_shared/components/commit.js +++ b/app/assets/javascripts/vue_shared/components/commit.js @@ -119,14 +119,14 @@ export default { </div> <a v-if="hasCommitRef" - class="monospace branch-name" + class="ref-name" :href="commitRef.ref_url"> {{commitRef.name}} </a> <div v-html="commitIconSvg" class="commit-icon js-commit-icon"></div> - <a class="commit-id monospace" + <a class="commit-sha" :href="commitUrl"> {{shortSha}} </a> diff --git a/app/assets/javascripts/vue_shared/components/loading_icon.vue b/app/assets/javascripts/vue_shared/components/loading_icon.vue new file mode 100644 index 00000000000..41b1d0165b0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/loading_icon.vue @@ -0,0 +1,33 @@ +<script> + export default { + props: { + label: { + type: String, + required: false, + default: 'Loading', + }, + + size: { + type: String, + required: false, + default: '1', + }, + }, + + computed: { + cssClass() { + return `fa-${this.size}x`; + }, + }, + }; +</script> +<template> + <div class="text-center"> + <i + class="fa fa-spin fa-spinner" + :class="cssClass" + aria-hidden="true" + :aria-label="label"> + </i> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/memory_graph.js b/app/assets/javascripts/vue_shared/components/memory_graph.js new file mode 100644 index 00000000000..643b77e04c7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/memory_graph.js @@ -0,0 +1,115 @@ +export default { + name: 'MemoryGraph', + props: { + metrics: { type: Array, required: true }, + deploymentTime: { type: Number, required: true }, + width: { type: String, required: true }, + height: { type: String, required: true }, + }, + data() { + return { + pathD: '', + pathViewBox: '', + dotX: '', + dotY: '', + }; + }, + computed: { + getFormattedMedian() { + const deployedSince = gl.utils.getTimeago().format(this.deploymentTime * 1000); + return `Deployed ${deployedSince}`; + }, + }, + methods: { + /** + * Returns metric value index in metrics array + * with timestamp closest to matching median + */ + getMedianMetricIndex(median, metrics) { + let matchIndex = 0; + let timestampDiff = 0; + let smallestDiff = 0; + + const metricTimestamps = metrics.map(v => v[0]); + + // Find metric timestamp which is closest to deploymentTime + timestampDiff = Math.abs(metricTimestamps[0] - median); + metricTimestamps.forEach((timestamp, index) => { + if (index === 0) { // Skip first element + return; + } + + smallestDiff = Math.abs(timestamp - median); + if (smallestDiff < timestampDiff) { + matchIndex = index; + timestampDiff = smallestDiff; + } + }); + + return matchIndex; + }, + + /** + * Get Graph Plotting values to render Line and Dot + */ + getGraphPlotValues(median, metrics) { + const renderData = metrics.map(v => v[1]); + const medianMetricIndex = this.getMedianMetricIndex(median, metrics); + let cx = 0; + let cy = 0; + + // Find Maximum and Minimum values from `renderData` array + const maxMemory = Math.max.apply(null, renderData); + const minMemory = Math.min.apply(null, renderData); + + // Find difference between extreme ends + const diff = maxMemory - minMemory; + const lineWidth = renderData.length; + + // Iterate over metrics values and perform following + // 1. Find x & y co-ords for deploymentTime's memory value + // 2. Return line path against maxMemory + const linePath = renderData.map((y, x) => { + if (medianMetricIndex === x) { + cx = x; + cy = maxMemory - y; + } + return `${x} ${maxMemory - y}`; + }); + + return { + pathD: linePath, + pathViewBox: { + lineWidth, + diff, + }, + dotX: cx, + dotY: cy, + }; + }, + + /** + * Render Graph based on provided median and metrics values + */ + renderGraph(median, metrics) { + const { pathD, pathViewBox, dotX, dotY } = this.getGraphPlotValues(median, metrics); + + // Set props and update graph on UI. + this.pathD = `M ${pathD}`; + this.pathViewBox = `0 0 ${pathViewBox.lineWidth} ${pathViewBox.diff}`; + this.dotX = dotX; + this.dotY = dotY; + }, + }, + mounted() { + this.renderGraph(this.deploymentTime, this.metrics); + }, + template: ` + <div class="memory-graph-container"> + <svg class="has-tooltip" :title="getFormattedMedian" :width="width" :height="height" xmlns="http://www.w3.org/2000/svg"> + <path :d="pathD" :viewBox="pathViewBox" /> + <circle r="1.5" :cx="dotX" :cy="dotY" tranform="translate(0 -1)" /> + </svg> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js index fbae85c85f6..30d16e4ed3e 100644 --- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js +++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js @@ -2,7 +2,7 @@ import AsyncButtonComponent from '../../pipelines/components/async_button.vue'; import PipelinesActionsComponent from '../../pipelines/components/pipelines_actions'; import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts'; -import PipelinesStatusComponent from '../../pipelines/components/status'; +import ciBadge from './ci_badge_link.vue'; import PipelinesStageComponent from '../../pipelines/components/stage.vue'; import PipelinesUrlComponent from '../../pipelines/components/pipeline_url'; import PipelinesTimeagoComponent from '../../pipelines/components/time_ago'; @@ -39,7 +39,7 @@ export default { 'commit-component': CommitComponent, 'dropdown-stage': PipelinesStageComponent, 'pipeline-url': PipelinesUrlComponent, - 'status-scope': PipelinesStatusComponent, + ciBadge, 'time-ago': PipelinesTimeagoComponent, }, @@ -62,10 +62,12 @@ export default { commitAuthor() { let commitAuthorInformation; + if (!this.pipeline || !this.pipeline.commit) { + return null; + } + // 1. person who is an author of a commit might be a GitLab user - if (this.pipeline && - this.pipeline.commit && - this.pipeline.commit.author) { + if (this.pipeline.commit.author) { // 2. if person who is an author of a commit is a GitLab user // he/she can have a GitLab avatar if (this.pipeline.commit.author.avatar_url) { @@ -77,11 +79,8 @@ export default { avatar_url: this.pipeline.commit.author_gravatar_url, }); } - } - - // 4. If committer is not a GitLab User he/she can have a Gravatar - if (this.pipeline && - this.pipeline.commit) { + // 4. If committer is not a GitLab User he/she can have a Gravatar + } else { commitAuthorInformation = { avatar_url: this.pipeline.commit.author_gravatar_url, web_url: `mailto:${this.pipeline.commit.author_email}`, @@ -197,11 +196,20 @@ export default { return ''; }, + + pipelineStatus() { + if (this.pipeline.details && this.pipeline.details.status) { + return this.pipeline.details.status; + } + return {}; + }, }, template: ` <tr class="commit"> - <status-scope :pipeline="pipeline"/> + <td class="commit-link"> + <ci-badge :status="pipelineStatus"/> + </td> <pipeline-url :pipeline="pipeline"></pipeline-url> diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.js b/app/assets/javascripts/vue_shared/components/table_pagination.vue index ebb14912b00..5e7df22dd83 100644 --- a/app/assets/javascripts/vue_shared/components/table_pagination.js +++ b/app/assets/javascripts/vue_shared/components/table_pagination.vue @@ -1,3 +1,4 @@ +<script> const PAGINATION_UI_BUTTON_LIMIT = 4; const UI_LIMIT = 6; const SPREAD = '...'; @@ -114,22 +115,23 @@ export default { return items; }, }, - template: ` - <div class="gl-pagination"> - <ul class="pagination clearfix"> - <li v-for='item in getItems' - :class='{ - page: item.page, - prev: item.prev, - next: item.next, - separator: item.separator, - active: item.active, - disabled: item.disabled - }' - > - <a @click="changePage($event)">{{item.title}}</a> - </li> - </ul> - </div> - `, }; +</script> +<template> + <div class="gl-pagination"> + <ul class="pagination clearfix"> + <li + v-for="item in getItems" + :class="{ + page: item.page, + prev: item.prev, + next: item.next, + separator: item.separator, + active: item.active, + disabled: item.disabled + }"> + <a @click="changePage($event)">{{item.title}}</a> + </li> + </ul> + </div> +</template> diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 5bb7e8caec1..d2ec1791d2b 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -47,3 +47,4 @@ @import "framework/emoji-sprites.scss"; @import "framework/icons.scss"; @import "framework/snippets.scss"; +@import "framework/memory_graph.scss"; diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index ac1fc0eb8ae..3dec911d289 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -312,7 +312,7 @@ } .empty-state { - margin: 100px 0 0; + margin: 5% auto 0; .text-content { max-width: 460px; @@ -335,27 +335,12 @@ } .btn { - margin: $btn-side-margin $btn-side-margin 0 0; - } - - @media(max-width: $screen-xs-max) { - margin-top: 50px; - text-align: center; + margin: $btn-side-margin 5px; - .btn { + @media(max-width: $screen-xs-max) { width: 100%; } } - - @media(min-width: $screen-xs-max) { - &.merge-requests .text-content { - margin-top: 40px; - } - - &.labels .text-content { - margin-top: 70px; - } - } } .flex-container-block { diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 1a6f36d032d..57387b913dc 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -92,7 +92,8 @@ hr { .item-title { font-weight: 600; } /** FLASH message **/ -.author_link { +.author_link, +.author-link { color: $gl-link-color; } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 1dd0e5ab581..f8674b763c8 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -4,13 +4,14 @@ */ .file-holder { border: 1px solid $border-color; + border-radius: $border-radius-default; &.file-holder-no-border { border: 0; } &.readme-holder { - margin: $gl-padding-top 0; + margin: $gl-padding 0; } table { @@ -25,7 +26,7 @@ text-align: left; padding: 10px $gl-padding; word-wrap: break-word; - border-radius: 3px 3px 0 0; + border-radius: $border-radius-default $border-radius-default 0 0; &.file-title-clear { padding-left: 0; @@ -94,9 +95,16 @@ tr { border-bottom: 1px solid $blame-border; + + &:last-child { + border-bottom: none; + } } td { + border-top: none; + border-bottom: none; + &:first-child { border-left: none; } @@ -107,7 +115,7 @@ } td.blame-commit { - padding: 0 10px; + padding: 5px 10px; min-width: 400px; background: $gray-light; } @@ -246,7 +254,7 @@ span.idiff { border-bottom: 1px solid $border-color; padding: 5px $gl-padding; margin: 0; - border-radius: 3px 3px 0 0; + border-radius: $border-radius-default $border-radius-default 0 0; .file-header-content { white-space: nowrap; diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss index c0de09f3968..dbdd5a4464b 100644 --- a/app/assets/stylesheets/framework/gfm.scss +++ b/app/assets/stylesheets/framework/gfm.scss @@ -2,7 +2,7 @@ * Styles that apply to all GFM related forms. */ +.gfm-commit, .gfm-commit_range { - font-family: $monospace_font; - font-size: 90%; + @extend .commit-sha; } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 6d9218310eb..65b5f4af037 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -30,13 +30,18 @@ header { background-color: $gray-light; border: none; border-bottom: 1px solid $border-color; + position: fixed; + z-index: 300; + top: 0; + left: 0; + right: 0; @media (max-width: $screen-xs-min) { padding: 0 16px; } &.with-horizontal-nav { - border-bottom: none; + border-color: transparent; } .container-fluid { @@ -110,6 +115,16 @@ header { } } + .navbar-border { + height: 1px; + position: absolute; + right: 0; + left: 0; + bottom: 0; + background-color: $border-color; + opacity: 0; + } + .global-dropdown { position: absolute; left: -10px; diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 87667f39ab8..1b7d4e42258 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -1,4 +1,5 @@ -.ci-status-icon-success { +.ci-status-icon-success, +.ci-status-icon-passed { color: $green-500; svg { diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 20c7bc93c28..9e8acf4e73c 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -25,6 +25,10 @@ body { .content-wrapper { padding-bottom: 100px; + + &:not(.page-with-layout-nav) { + margin-top: $header-height; + } } .container { diff --git a/app/assets/stylesheets/framework/memory_graph.scss b/app/assets/stylesheets/framework/memory_graph.scss new file mode 100644 index 00000000000..81cdf6b59e4 --- /dev/null +++ b/app/assets/stylesheets/framework/memory_graph.scss @@ -0,0 +1,22 @@ +.memory-graph-container { + svg { + background: $white-light; + cursor: pointer; + + &:hover { + box-shadow: 0 0 4px $gray-darkest inset; + } + } + + path { + fill: none; + stroke: $blue-500; + stroke-width: 2px; + } + + circle { + stroke: $blue-700; + fill: $blue-700; + stroke-width: 4px; + } +} diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index eb73f7cc794..678af978edd 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -112,7 +112,7 @@ } } - .issue_edited_ago, + .issue-edited-ago, .note_edited_ago { display: none; } diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index b6cf5101d60..d2f6a227128 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -24,10 +24,10 @@ } @mixin scrolling-links() { - white-space: nowrap; overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; + display: flex; &::-webkit-scrollbar { display: none; @@ -35,6 +35,7 @@ } .nav-links { + display: flex; padding: 0; margin: 0; list-style: none; @@ -42,17 +43,16 @@ border-bottom: 1px solid $border-color; li { - display: inline-block; + display: flex; a { - display: inline-block; padding: $gl-btn-padding; padding-bottom: 11px; - margin-bottom: -1px; font-size: 14px; line-height: 28px; color: $gl-text-color-secondary; border-bottom: 2px solid transparent; + white-space: nowrap; &:hover, &:active, @@ -85,10 +85,10 @@ .container-fluid { background-color: $gray-normal; margin-bottom: 0; + display: flex; } li { - &.active a { border-bottom: none; color: $link-underline-blue; @@ -137,9 +137,9 @@ } .nav-links { - display: inline-block; margin-bottom: 0; border-bottom: none; + float: left; &.wide { width: 100%; @@ -291,6 +291,7 @@ border-bottom: 1px solid $border-color; transition: padding $sidebar-transition-duration; text-align: center; + margin-top: $header-height; .container-fluid { position: relative; @@ -336,6 +337,10 @@ border-bottom: none; height: 51px; + @media (min-width: $screen-sm-min) { + justify-content: center; + } + li { a { padding-top: 10px; @@ -346,6 +351,7 @@ .scrolling-tabs-container { position: relative; + overflow: hidden; .nav-links { @include scrolling-links(); @@ -428,7 +434,7 @@ top: ($header-height + 1) * 3; &.affix { - top: 0; + top: $header-height; } } } @@ -483,10 +489,7 @@ .inner-page-scroll-tabs { position: relative; - - .nav-links { - padding-bottom: 1px; - } + overflow: hidden; .fade-right { @include fade(left, $white-light); diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 746c9c25620..018f61ca3a8 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -53,6 +53,7 @@ .right-sidebar-expanded { padding-right: 0; + z-index: 300; @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { &:not(.wiki-sidebar):not(.build-sidebar) .content-wrapper { @@ -80,6 +81,6 @@ &.affix { position: fixed; - top: 0; + top: $header-height; } } diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index d2164a1d333..aa0c512a277 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -3,30 +3,6 @@ margin: 0; padding: 0; - .timeline-entry { - padding: $gl-padding $gl-btn-padding 0; - border-color: $white-normal; - color: $gl-text-color; - border-bottom: 1px solid $border-white-light; - - .timeline-entry-inner { - position: relative; - } - - &:target { - background: $line-target-blue; - } - - .avatar { - margin-right: 15px; - } - - .controls { - padding-top: 10px; - float: right; - } - } - .note-text { p:last-child { margin-bottom: 0; @@ -46,20 +22,45 @@ } } +.timeline-entry { + padding: $gl-padding $gl-btn-padding 0; + border-color: $white-normal; + color: $gl-text-color; + border-bottom: 1px solid $border-white-light; + + .timeline-entry-inner { + position: relative; + } + + &:target, + &.target { + background: $line-target-blue; + } + + .avatar { + margin-right: 15px; + } + + .controls { + padding-top: 10px; + float: right; + } +} + @media (max-width: $screen-xs-max) { .timeline { &::before { background: none; } + } - .timeline-entry .timeline-entry-inner { - .timeline-icon { - display: none; - } + .timeline-entry .timeline-entry-inner { + .timeline-icon { + display: none; + } - .timeline-content { - margin-left: 0; - } + .timeline-content { + margin-left: 0; } } } diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 96d8a812723..a7c6cbaae21 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -289,11 +289,6 @@ pre { } } -.monospace { - font-family: $monospace_font; - font-size: 90%; -} - code { &.key-fingerprint { background: $body-bg; @@ -305,6 +300,24 @@ a > code { color: $link-color; } +.monospace { + font-family: $monospace_font; +} + +.commit-sha, +.ref-name { + @extend .monospace; + font-size: 95%; +} + +.git-revision-dropdown-toggle { + @extend .monospace; +} + +.git-revision-dropdown .dropdown-content ul li a { + @extend .ref-name; +} + /** * Apply Markdown typography * diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 08bcb582613..17a4e8fd83e 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -111,6 +111,7 @@ $gl-link-hover-color: $blue-800; $gl-grayish-blue: #7f8fa4; $gl-gray: $gl-text-color; $gl-gray-dark: #313236; +$gl-gray-light: #5c5c5c; $gl-header-color: #4c4e54; $gl-header-nav-hover-color: #434343; $placeholder-text-color: rgba(0, 0, 0, .42); @@ -162,7 +163,7 @@ $fixed-layout-width: 1280px; $limited-layout-width: 990px; $gl-avatar-size: 40px; $error-exclamation-point: $red-500; -$border-radius-default: 2px; +$border-radius-default: 3px; $settings-icon-size: 18px; $provider-btn-not-active-color: $blue-500; $link-underline-blue: $blue-500; diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss index 32eb750180f..1c1392f8f67 100644 --- a/app/assets/stylesheets/framework/wells.scss +++ b/app/assets/stylesheets/framework/wells.scss @@ -12,10 +12,14 @@ } &.branch-info { - .monospace, + .commit-sha, .commit-info { margin-left: 4px; } + + .ref-name { + font-size: 12px; + } } } diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/dark.scss index 09951fe3d3e..6e3829d994f 100644 --- a/app/assets/stylesheets/highlight/dark.scss +++ b/app/assets/stylesheets/highlight/dark.scss @@ -185,6 +185,11 @@ $dark-il: #de935f; color: $dark-highlight-color !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $dark-na; + } + .hll { background-color: $dark-hll-bg; } .c { color: $dark-c; } /* Comment */ .err { color: $dark-err; } /* Error */ diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/monokai.scss index b6a6d298adf..68eb0c7720f 100644 --- a/app/assets/stylesheets/highlight/monokai.scss +++ b/app/assets/stylesheets/highlight/monokai.scss @@ -185,6 +185,11 @@ $monokai-gi: #a6e22e; color: $black !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $monokai-k; + } + .hll { background-color: $monokai-hll; } .c { color: $monokai-c; } /* Comment */ .err { color: $monokai-err-color; background-color: $monokai-err-bg; } /* Error */ diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/solarized_dark.scss index 4f7a50dcb4f..2cc968c32f2 100644 --- a/app/assets/stylesheets/highlight/solarized_dark.scss +++ b/app/assets/stylesheets/highlight/solarized_dark.scss @@ -188,6 +188,11 @@ $solarized-dark-il: #2aa198; background-color: $solarized-dark-highlight !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $solarized-dark-kd; + } + /* Solarized Dark For use with Jekyll and Pygments diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/solarized_light.scss index 6463fe96c1b..b61b85a2cd1 100644 --- a/app/assets/stylesheets/highlight/solarized_light.scss +++ b/app/assets/stylesheets/highlight/solarized_light.scss @@ -196,6 +196,11 @@ $solarized-light-il: #2aa198; background-color: $solarized-light-highlight !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $solarized-light-kd; + } + /* Solarized Light For use with Jekyll and Pygments diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss index ab2018bfbca..1daa10aef24 100644 --- a/app/assets/stylesheets/highlight/white.scss +++ b/app/assets/stylesheets/highlight/white.scss @@ -203,6 +203,11 @@ $white-gc-bg: #eaf2f5; background-color: $white-highlight !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $white-nb; + } + .hll { background-color: $white-hll-bg; } .c { color: $white-c; font-style: italic; } .err { color: $white-err; background-color: $white-err-bg; } diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 724b4080ee0..14a62b6cbf0 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -378,7 +378,7 @@ background-color: $row-hover; } - .fa-spinner { + .fa-refresh { font-size: 13px; margin-left: 3px; } diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 9e3142c8aa3..bb72f453d1b 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -163,7 +163,6 @@ .avatar-cell { width: 46px; - padding-left: 10px; img { margin-right: 0; @@ -175,7 +174,6 @@ justify-content: space-between; align-items: flex-start; flex-grow: 1; - padding-left: 10px; .merge-request-branches & { flex-direction: column; @@ -208,11 +206,11 @@ margin-left: $gl-padding; } } -} -.commit-short-id { - font-family: $monospace_font; - font-weight: 600; + .commit-sha { + font-size: 14px; + font-weight: 600; + } } .commit, @@ -273,7 +271,7 @@ } } - .commit-id { + .commit-sha { color: $gl-link-color; } diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index d29944207c5..7bec4bd5f56 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -387,7 +387,7 @@ padding: 0 3px 0 0; } - .branch-name { + .ref-name { color: $black; display: inline-block; max-width: 180px; @@ -398,7 +398,7 @@ vertical-align: top; } - .short-sha { + .commit-sha { color: $gl-link-color; line-height: 1.3; vertical-align: top; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 77f2638683a..cfb1df4df84 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -1,38 +1,6 @@ // Common .diff-file { - border: 1px solid $border-color; margin-bottom: $gl-padding; - border-radius: 3px; - - .commit-short-id { - font-family: $regular_font; - font-weight: 400; - } - - .diff-header { - position: relative; - background: $gray-light; - border-bottom: 1px solid $border-color; - padding: 10px 16px; - color: $gl-text-color; - z-index: 10; - border-radius: 3px 3px 0 0; - - .diff-title { - font-family: $monospace_font; - word-break: break-all; - display: block; - - .file-mode { - color: $file-mode-changed; - } - } - - .commit-short-id { - font-family: $monospace_font; - font-size: smaller; - } - } .file-title, .file-title-flex-parent { diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 026d35295d7..a42ae7e55a5 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -5,11 +5,6 @@ } } -.environments-list-loading { - width: 100%; - font-size: 34px; -} - .environments-folder-name { font-weight: normal; padding-top: 20px; @@ -95,7 +90,7 @@ } .build-link, - .branch-name { + .ref-name { color: $gl-text-color; } @@ -140,7 +135,7 @@ } .branch-commit { - .commit-id { + .commit-sha { margin-right: 0; } } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index c4210ffd823..0184208ab82 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -23,16 +23,6 @@ .merge-manually { @extend .fixed-width-container; } - - .merge-request-tabs-holder { - &.affix { - border-bottom: 1px solid $border-color; - - .nav-links { - border: 0; - } - } - } } .merge-request-details { @@ -206,7 +196,7 @@ transition: width .3s; background: $gray-light; padding: 10px 20px; - z-index: 2; + z-index: 200; &.right-sidebar-expanded { width: $gutter_width; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index ad3b6e0344b..bee9b13b375 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -51,6 +51,7 @@ ul.related-merge-requests > li { display: -ms-flexbox; display: -webkit-flex; display: flex; + align-items: center; .merge-request-id { flex-shrink: 0; @@ -59,6 +60,14 @@ ul.related-merge-requests > li { .merge-request-info { margin-left: 5px; } + + .row_title { + vertical-align: bottom; + } + + gl-emoji { + font-size: 1em; + } } .merge-requests-title, @@ -114,7 +123,6 @@ ul.related-merge-requests > li { .related-merge-requests { .ci-status-link { display: block; - margin-top: 3px; margin-right: 5px; } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 72660113e3c..fa9d05ee8fd 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -37,12 +37,6 @@ @include btn-red; } } - - .dropdown-toggle { - .fa { - color: inherit; - } - } } .accept-control { @@ -88,18 +82,13 @@ } } - .ci_widget { - border-bottom: 1px solid $well-inner-border; + .ci-widget { color: $gl-text-color; display: -webkit-flex; display: flex; -webkit-align-items: center; align-items: center; - - i, - svg { - margin-right: 8px; - } + padding: $gl-padding-top $gl-padding 0; svg { position: relative; @@ -115,16 +104,20 @@ flex-wrap: wrap; } - .ci-status-icon > .icon-link > svg { + .icon-link > .ci-status-icon > svg { width: 22px; height: 22px; + margin-right: 8px; + } + + .ci-error { + margin-right: $btn-side-margin; } } .mr-widget-body, - .ci_widget, .mr-widget-footer { - padding: 16px; + margin: 16px; } .mr-widget-pipeline-graph { @@ -132,18 +125,13 @@ .dropdown-menu { margin-top: 11px; + z-index: 200; } .ci-action-icon-wrapper { line-height: 16px; } - @media (min-width: $screen-sm-min) { - .stage-cell { - padding: 0 4px; - } - } - @media (max-width: $screen-xs-max) { order: 1; margin-top: $gl-padding-top; @@ -166,12 +154,68 @@ .normal { color: $gl-text-color; + font-size: 15px; + } + + .capitalize { + text-transform: capitalize; + } + + .label-branch { + @extend .ref-name; + + color: $gl-text-color; + font-weight: bold; + overflow: hidden; + margin: 0 3px; + word-break: break-all; + + &.label-truncated { + position: relative; + display: inline-block; + width: 250px; + margin-bottom: -3px; + white-space: nowrap; + text-overflow: clip; + line-height: 14px; + + &::after { + position: absolute; + content: '...'; + right: 0; + font-family: $regular_font; + background-color: $gray-light; + } + } } .js-deployment-link { display: inline-block; } + .mr-widget-help { + margin: $gl-padding; + color: $ci-skipped-color; + } + + .mr-info-list { + + &.mr-links { + margin-left: 28px; + } + + &.mr-memory-usage { + margin: 5px 0 10px 25px; + } + } + + .mr-widget-heading, + .mr-widget-body { + .btn-default.btn-xs { + margin-left: 5px; + } + } + .mr-widget-body { h4 { font-weight: 600; @@ -182,6 +226,10 @@ &.has-conflicts .fa-exclamation-triangle { color: $gl-warning; } + + time { + font-weight: normal; + } } .btn-grouped { @@ -189,6 +237,80 @@ margin-right: 7px; } + label { + font-weight: normal; + } + + .spacing { + margin: 0 $gl-padding; + } + + .bold { + margin-left: 5px; + font-weight: bold; + color: $gl-gray-light; + } + + .state-label { + font-size: 16px; + font-weight: bold; + padding-right: 10px; + } + + .danger { + color: $gl-danger; + } + + .mr-widget-help { + margin: $gl-padding 0; + } + + .with-button { + position: relative; + top: 6px; + margin-bottom: 24px; + } + + .dropdown-menu { + li a { + padding: 5px; + } + + .merge-opt-icon, + .merge-opt-title { + display: inline-block; + float: left; + } + + .merge-opt-icon svg { + height: 15px; + width: 15px; + } + + .merge-opt-title { + margin-left: 8px; + } + } + + .dropdown-toggle { + .fa { + color: inherit; + } + } + + .has-error-message + .has-custom-error { + margin-left: 0; + } + + .has-custom-error { + display: inline-block; + margin-left: 70px; + } + + .merge-error-text { + margin-left: 70px; + } + @media (max-width: $screen-xs-max) { h4 { font-size: 14px; @@ -220,6 +342,17 @@ margin: 0; } } + + .commit-message-editor { + label { + padding: 0; + } + } + + &.mr-state-locked .mr-info-list { + margin-top: 10px; + margin-left: 12px; + } } .mr-widget-footer { @@ -255,16 +388,6 @@ } } -.label-branch { - color: $gl-text-color; - font-family: $monospace_font; - font-weight: bold; - overflow: hidden; - font-size: 90%; - margin: 0 3px; - word-break: break-all; -} - .commits-empty { text-align: center; @@ -343,61 +466,79 @@ } } -.remove-message-pipes { - ul { - margin: 10px 0 0 12px; - padding: 0; - list-style: none; - border-left: 2px solid $border-color; - display: inline-block; - } +.mr-info-list { + position: relative; + margin: 10px 0 $gl-padding 12px; - li { + p { + margin: 6px 0; position: relative; - margin: 0; - padding: 0; - display: block; + padding-left: 15px; - span { - margin-left: 15px; - max-height: 20px; + &::before { + content: ''; + position: absolute; + border-top: 2px solid $border-color; + height: 1px; + top: 8px; + width: 8px; + left: 0; + } + + &:last-child { + margin-bottom: 0; + + &::before { + top: 14px; + } } } - li::before { - content: ''; + .legend { + height: 100%; + width: 2px; + background: $border-color; position: absolute; - border-top: 2px solid $border-color; - height: 1px; - top: 8px; - width: 8px; + top: -5px; } +} - li:last-child { - &::before { - top: 18px; +.mr-info-list.mr-memory-usage { + .legend { + height: 65%; + top: 0; + + @media (max-width: $screen-xs-max) { + height: 20px; } + } - span { - display: block; - position: relative; - top: 5px; - margin-top: 5px; + p { + float: left; + padding-left: 20px; + + &::before { + top: 13px; } } + + .memory-graph-container { + float: left; + margin-left: 5px; + } } .mr-source-target { background-color: $gray-light; - line-height: 31px; - border-style: solid; - border-width: 1px; - border-color: $border-color; - border-top-right-radius: 3px; - border-top-left-radius: 3px; - border-bottom: none; - padding: 16px; - margin-bottom: -1px; + border-radius: 3px 3px 0 0; + border-bottom: 1px solid $border-color; + padding: 0 $gl-padding; + margin-bottom: 6px; + line-height: 44px; + + .dropdown-toggle .fa { + color: $gl-text-color; + } } .panel-new-merge-request { @@ -553,12 +694,18 @@ } .merge-request-tabs-holder { + top: $header-height; + z-index: 100; background-color: $white-light; + border-bottom: 1px solid $border-color; + + @media(min-width: $screen-sm-min) { + position: sticky; + position: -webkit-sticky; + } &.affix { - top: 0; left: 0; - z-index: 10; transition: right .15s; @media (max-width: $screen-xs-max) { @@ -570,6 +717,16 @@ padding-right: $gl-padding; } } + + .nav-links { + border: 0; + } +} + +.merge-request-tabs { + display: flex; + margin-bottom: 0; + padding: 0; } .limit-container-width { @@ -580,6 +737,15 @@ } } +.merge-request-tabs-container { + display: flex; + justify-content: space-between; + + @media (max-width: $screen-xs-max) { + flex-direction: column-reverse; + } +} + .limit-container-width:not(.container-limited) { .merge-request-tabs-holder:not(.affix) { .merge-request-tabs-container { @@ -587,3 +753,22 @@ } } } + +.mr-memory-usage { + p.usage-info-loading, + p.usage-info-unavailable, + p.usage-info-failed { + margin-bottom: 5px; + } + + p.usage-info-loading .usage-info-load-spinner { + margin-right: 10px; + font-size: 16px; + } + + @media (max-width: $screen-md-min) { + .mr-info-list.mr-memory-usage .legend { + height: 80%; + } + } +} diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 69c328d09ff..5b6aa9d74f6 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -238,11 +238,6 @@ ul.notes { ul { margin: 3px 0 3px 16px !important; - - .gfm-commit { - font-family: $monospace_font; - font-size: 12px; - } } p:first-child { @@ -284,10 +279,6 @@ ul.notes { } } - .diff-header > span { - margin-right: 10px; - } - .line_content { white-space: pre-wrap; } @@ -618,6 +609,15 @@ ul.notes { } .line-resolve-all-container { + @media (min-width: $screen-sm-min) { + margin-right: 0; + padding-left: $gl-padding; + } + + > div { + white-space: nowrap; + } + .btn-group { margin-left: -4px; } diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index eaf3dd49567..292584eba28 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -1,10 +1,4 @@ .pipelines { - .realtime-loading { - font-size: 40px; - text-align: center; - margin: 0 auto; - } - .stage { max-width: 90px; width: 90px; @@ -14,10 +8,6 @@ white-space: nowrap; } - .empty-state { - margin: 5% auto 0; - } - .table-holder { width: 100%; @@ -168,9 +158,13 @@ float: none; } + .api { + @extend .monospace; + } + .branch-commit { - .branch-name { + .ref-name { font-weight: bold; max-width: 120px; overflow: hidden; @@ -192,12 +186,11 @@ color: $gl-text-color; } - .commit-id { + .commit-sha { color: $gl-link-color; } .commit-title { - margin-top: 4px; max-width: 225px; overflow: hidden; white-space: nowrap; @@ -230,7 +223,7 @@ .duration, .finished-at { color: $gl-text-color-secondary; - margin: 4px 0; + margin: 0; white-space: nowrap; .fa { @@ -257,7 +250,7 @@ .stage-cell { font-size: 0; - padding: 10px 4px; + padding: 0 4px; > .stage-container > div > button > span > svg, > .stage-container > button > svg { @@ -384,9 +377,9 @@ content: ''; position: absolute; top: 48%; - left: -48px; + left: -44px; border-top: 2px solid $border-color; - width: 48px; + width: 44px; height: 1px; } } @@ -486,7 +479,7 @@ color: $gl-text-color-secondary; // Action Icons in big pipeline-graph nodes - > div > .ci-action-icon-container .ci-action-icon-wrapper { + .ci-action-icon-container .ci-action-icon-wrapper { height: 30px; width: 30px; background: $white-light; @@ -511,7 +504,7 @@ } } - > div > .ci-action-icon-container { + .ci-action-icon-container { position: absolute; right: 5px; top: 5px; @@ -541,7 +534,7 @@ } } - > div > .build-content { + .build-content { display: inline-block; padding: 8px 10px 9px; width: 100%; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index c119f0c9b22..ed4a5474034 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -657,9 +657,8 @@ pre.light-well { color: $gl-text-color; } - .commit_short_id { + .commit-sha { margin-right: 5px; - color: $gl-link-color; font-weight: 600; } @@ -825,7 +824,8 @@ pre.light-well { } .compare-form-group { - .dropdown-menu { + .dropdown-menu, + .inline-input-group { width: 100%; @media (min-width: $screen-sm-min) { @@ -844,14 +844,6 @@ pre.light-well { width: auto; } } - - .inline-input-group { - width: 100%; - - @media (min-width: $screen-sm-min) { - width: 250px; - } - } } .clearable-input { diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index 03c75ce61f5..ab63225147f 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -138,11 +138,12 @@ .blob-commit-info { list-style: none; - background: $gray-light; - padding: 16px 16px 16px 6px; - border: 1px solid $border-color; - border-bottom: none; margin: 0; + padding: 0; +} + +.blob-content-holder { + margin-top: $gl-padding; } .blob-upload-dropzone-previews { diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index 6cc1cc8e263..136d0c79467 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -28,9 +28,6 @@ nav.navbar-collapse.collapse, .profiler-results, .tree-ref-holder, .tree-holder .breadcrumb, -.blob-commit-info, -.file-title, -.file-holder, .nav, .btn, ul.notes-form, @@ -43,6 +40,11 @@ ul.notes-form, display: none!important; } +pre { + page-break-before: avoid; + page-break-inside: auto; +} + .page-gutter { padding-top: 0; padding-left: 0; diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb index a119934febc..ccfe553c89e 100644 --- a/app/controllers/admin/hooks_controller.rb +++ b/app/controllers/admin/hooks_controller.rb @@ -60,6 +60,7 @@ class Admin::HooksController < Admin::ApplicationController :enable_ssl_verification, :push_events, :tag_push_events, + :repository_update_events, :token, :url ) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 65a1f640a76..8ce9150e4a9 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -100,7 +100,10 @@ class ApplicationController < ActionController::Base end def access_denied! - render "errors/access_denied", layout: "errors", status: 404 + respond_to do |format| + format.json { head :not_found } + format.any { render "errors/access_denied", layout: "errors", status: 404 } + end end def git_not_found! diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index b79ca034c5b..e2f5aa8508e 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -41,7 +41,7 @@ class AutocompleteController < ApplicationController no_project = { id: 0, - name_with_namespace: 'No project', + name_with_namespace: 'No project' } projects.unshift(no_project) unless params[:offset_id].present? diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index b199f18da1e..4cf645d6341 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -60,17 +60,24 @@ module IssuableActions end def bulk_update_params - params.require(:update).permit( + permitted_keys = [ :issuable_ids, :assignee_id, :milestone_id, :state_event, :subscription_event, - assignee_ids: [], label_ids: [], add_label_ids: [], remove_label_ids: [] - ) + ] + + if resource_name == 'issue' + permitted_keys << { assignee_ids: [] } + else + permitted_keys.unshift(:assignee_id) + end + + params.require(:update).permit(permitted_keys) end def resource_name diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 6df2c068745..650ec1e326a 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -47,7 +47,7 @@ module IssuableCollections end def merge_requests_collection - merge_requests_finder.execute.preload(:source_project, :target_project, :author, :assignee, :labels, :milestone, :merge_request_diff, target_project: :namespace) + merge_requests_finder.execute.preload(:source_project, :target_project, :author, :assignee, :labels, :milestone, :merge_request_diff, :head_pipeline, target_project: :namespace) end def issues_finder diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb index ed22b1e5470..ae91e02488a 100644 --- a/app/controllers/concerns/lfs_request.rb +++ b/app/controllers/concerns/lfs_request.rb @@ -23,7 +23,7 @@ module LfsRequest render( json: { message: 'Git LFS is not enabled on this GitLab server, contact your admin.', - documentation_url: help_url, + documentation_url: help_url }, status: 501 ) @@ -48,7 +48,7 @@ module LfsRequest render( json: { message: 'Access forbidden. Check your access level.', - documentation_url: help_url, + documentation_url: help_url }, content_type: "application/vnd.git-lfs+json", status: 403 @@ -59,7 +59,7 @@ module LfsRequest render( json: { message: 'Not found.', - documentation_url: help_url, + documentation_url: help_url }, content_type: "application/vnd.git-lfs+json", status: 404 diff --git a/app/controllers/concerns/renders_blob.rb b/app/controllers/concerns/renders_blob.rb index 9faf68e6d97..4a6630dfd90 100644 --- a/app/controllers/concerns/renders_blob.rb +++ b/app/controllers/concerns/renders_blob.rb @@ -3,8 +3,11 @@ module RendersBlob def render_blob_json(blob) viewer = - if params[:viewer] == 'rich' + case params[:viewer] + when 'rich' blob.rich_viewer + when 'auxiliary' + blob.auxiliary_viewer else blob.simple_viewer end diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb index d4ab6782444..afd110adcad 100644 --- a/app/controllers/concerns/routable_actions.rb +++ b/app/controllers/concerns/routable_actions.rb @@ -4,7 +4,7 @@ module RoutableActions def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil) routable = routable_klass.find_by_full_path(requested_full_path, follow_redirects: request.get?) - if routable_authorized?(routable_klass, routable, extra_authorization_proc) + if routable_authorized?(routable, extra_authorization_proc) ensure_canonical_path(routable, requested_full_path) routable else @@ -13,8 +13,8 @@ module RoutableActions end end - def routable_authorized?(routable_klass, routable, extra_authorization_proc) - action = :"read_#{routable_klass.to_s.underscore}" + def routable_authorized?(routable, extra_authorization_proc) + action = :"read_#{routable.class.to_s.underscore}" return false unless can?(current_user, action, routable) if extra_authorization_proc @@ -30,7 +30,7 @@ module RoutableActions canonical_path = routable.full_path if canonical_path != requested_path if canonical_path.casecmp(requested_path) != 0 - flash[:notice] = "Project '#{requested_path}' was moved to '#{canonical_path}'. Please update any links and bookmarks that may still have the old path." + flash[:notice] = "#{routable.class.to_s.titleize} '#{requested_path}' was moved to '#{canonical_path}'. Please update any links and bookmarks that may still have the old path." end redirect_to request.original_url.sub(requested_path, canonical_path) end diff --git a/app/controllers/dashboard/snippets_controller.rb b/app/controllers/dashboard/snippets_controller.rb index bcfdbe14be9..8dd91264451 100644 --- a/app/controllers/dashboard/snippets_controller.rb +++ b/app/controllers/dashboard/snippets_controller.rb @@ -1,11 +1,10 @@ class Dashboard::SnippetsController < Dashboard::ApplicationController def index - @snippets = SnippetsFinder.new.execute( + @snippets = SnippetsFinder.new( current_user, - filter: :by_user, - user: current_user, + author: current_user, scope: params[:scope] - ) + ).execute @snippets = @snippets.page(params[:page]) end end diff --git a/app/controllers/explore/groups_controller.rb b/app/controllers/explore/groups_controller.rb index 68228c095da..81883c543ba 100644 --- a/app/controllers/explore/groups_controller.rb +++ b/app/controllers/explore/groups_controller.rb @@ -1,6 +1,6 @@ class Explore::GroupsController < Explore::ApplicationController def index - @groups = GroupsFinder.new.execute(current_user) + @groups = GroupsFinder.new(current_user).execute @groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present? @groups = @groups.sort(@sort = params[:sort]) @groups = @groups.page(params[:page]) diff --git a/app/controllers/explore/snippets_controller.rb b/app/controllers/explore/snippets_controller.rb index 28760c3f84b..d3f0e033068 100644 --- a/app/controllers/explore/snippets_controller.rb +++ b/app/controllers/explore/snippets_controller.rb @@ -1,6 +1,6 @@ class Explore::SnippetsController < Explore::ApplicationController def index - @snippets = SnippetsFinder.new.execute(current_user, filter: :all) + @snippets = SnippetsFinder.new(current_user).execute @snippets = @snippets.page(params[:page]) end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 46c3ff10694..1515173d0ac 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -64,7 +64,7 @@ class GroupsController < Groups::ApplicationController end def subgroups - @nested_groups = group.children + @nested_groups = GroupsFinder.new(current_user, parent: group).execute @nested_groups = @nested_groups.search(params[:filter_groups]) if params[:filter_groups].present? end diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index df0fc3132ed..125746d0426 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -5,7 +5,7 @@ class HealthController < ActionController::Base CHECKS = [ Gitlab::HealthChecks::DbCheck, Gitlab::HealthChecks::RedisCheck, - Gitlab::HealthChecks::FsShardsCheck, + Gitlab::HealthChecks::FsShardsCheck ].freeze def readiness diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index 3109439b2ff..1c01be06451 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -4,7 +4,7 @@ class JwtController < ApplicationController before_action :authenticate_project_or_user SERVICES = { - Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService, + Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService }.freeze def auth diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 0d891ef4004..5414142e2df 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -33,7 +33,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController :color_scheme_id, :layout, :dashboard, - :project_view, + :project_view ) end end diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 59247280559..d8ed470e461 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -84,6 +84,7 @@ class Projects::BranchesController < Projects::ApplicationController end format.js { render nothing: true, status: result[:return_code] } + format.json { render json: { message: result[:message] }, status: result[:return_code] } end end diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index 0fd35bcb790..dfaaea71b9c 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -91,7 +91,7 @@ class Projects::BuildsController < Projects::ApplicationController def status render json: BuildSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent_status(@build) end diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 2b5f0383ac1..7c3cce1c241 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -39,7 +39,7 @@ class Projects::CommitController < Projects::ApplicationController Gitlab::PollingInterval.set_header(response, interval: 10_000) render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipelines) end end diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb index c319671456d..6644deb49c9 100644 --- a/app/controllers/projects/deployments_controller.rb +++ b/app/controllers/projects/deployments_controller.rb @@ -6,12 +6,28 @@ class Projects::DeploymentsController < Projects::ApplicationController deployments = environment.deployments.reorder(created_at: :desc) deployments = deployments.where('created_at > ?', params[:after].to_time) if params[:after]&.to_time - render json: { deployments: DeploymentSerializer.new(user: @current_user, project: project) + render json: { deployments: DeploymentSerializer.new(project: project) .represent_concise(deployments) } end + def metrics + return render_404 unless deployment.has_metrics? + @metrics = deployment.metrics + if @metrics&.any? + render json: @metrics, status: :ok + else + head :no_content + end + rescue NotImplementedError + render_404 + end + private + def deployment + @deployment ||= environment.deployments.find_by(iid: params[:id]) + end + def environment @environment ||= project.environments.find(params[:environment_id]) end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index fa37963dfd4..fd57afbd05f 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -17,7 +17,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController format.json do render json: { environments: EnvironmentSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .with_pagination(request, response) .within_folders .represent(@environments), @@ -37,7 +37,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController format.json do render json: { environments: EnvironmentSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .with_pagination(request, response) .represent(@environments), available_count: folder_environments.available.count, @@ -81,10 +81,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController stop_action = @environment.stop_with_action!(current_user) - if stop_action - redirect_to polymorphic_path([project.namespace.becomes(Namespace), project, stop_action]) - else - redirect_to namespace_project_environment_path(project.namespace, project, @environment) + action_or_env_url = + if stop_action + polymorphic_url([project.namespace.becomes(Namespace), project, stop_action]) + else + namespace_project_environment_url(project.namespace, project, @environment) + end + + respond_to do |format| + format.html { redirect_to action_or_env_url } + format.json { render json: { redirect_url: action_or_env_url } } end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index bcd23d61519..46438e68d54 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -11,10 +11,10 @@ class Projects::IssuesController < Projects::ApplicationController before_action :redirect_to_external_issue_tracker, only: [:index, :new] before_action :module_enabled before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests, - :related_branches, :can_create_branch, :rendered_title, :create_merge_request] + :related_branches, :can_create_branch, :realtime_changes, :create_merge_request] # Allow read any issue - before_action :authorize_read_issue!, only: [:show, :rendered_title] + before_action :authorize_read_issue!, only: [:show, :realtime_changes] # Allow write(create) issue before_action :authorize_create_issue!, only: [:new, :create] @@ -199,7 +199,7 @@ class Projects::IssuesController < Projects::ApplicationController end end - def rendered_title + def realtime_changes Gitlab::PollingInterval.set_header(response, interval: 3_000) render json: { @@ -208,8 +208,7 @@ class Projects::IssuesController < Projects::ApplicationController description: view_context.markdown_field(@issue, :description), description_text: @issue.description, task_status: @issue.task_status, - issue_number: @issue.iid, - updated_at: @issue.updated_at, + updated_at: @issue.updated_at } end @@ -227,7 +226,7 @@ class Projects::IssuesController < Projects::ApplicationController def issue # The Sortable default scope causes performance issues when used with find_by - @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take || redirect_old + @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take! end alias_method :subscribable_resource, :issue alias_method :issuable, :issue @@ -266,25 +265,10 @@ class Projects::IssuesController < Projects::ApplicationController end end - # Since iids are implemented only in 6.1 - # user may navigate to issue page using old global ids. - # - # To prevent 404 errors we provide a redirect to correct iids until 7.0 release - # - def redirect_old - issue = @project.issues.find_by(id: params[:id]) - - if issue - redirect_to issue_path(issue) - else - raise ActiveRecord::RecordNotFound.new - end - end - def issue_params params.require(:issue).permit( :title, :assignee_id, :position, :description, :confidential, - :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [], assignee_ids: [], + :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [], assignee_ids: [] ) end diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb index 8a5a645ed0e..1b0d3aab3fa 100644 --- a/app/controllers/projects/lfs_api_controller.rb +++ b/app/controllers/projects/lfs_api_controller.rb @@ -22,7 +22,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController render( json: { message: 'Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.', - documentation_url: "#{Gitlab.config.gitlab.url}/help", + documentation_url: "#{Gitlab.config.gitlab.url}/help" }, status: 501 ) @@ -55,7 +55,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController else object[:error] = { code: 404, - message: "Object does not exist on the server or you don't have permissions to access it", + message: "Object does not exist on the server or you don't have permissions to access it" } end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index a63b7ff0bed..b99ccd453b8 100755..100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -10,11 +10,10 @@ class Projects::MergeRequestsController < Projects::ApplicationController before_action :module_enabled before_action :merge_request, only: [ :edit, :update, :show, :diffs, :commits, :conflicts, :conflict_for_path, :pipelines, :merge, :merge_check, - :ci_status, :pipeline_status, :ci_environments_status, :toggle_subscription, :cancel_merge_when_pipeline_succeeds, :remove_wip, :resolve_conflicts, :assign_related_issues + :pipeline_status, :ci_environments_status, :toggle_subscription, :cancel_merge_when_pipeline_succeeds, :remove_wip, :resolve_conflicts, :assign_related_issues, :commit_change_content ] before_action :validates_merge_request, only: [:show, :diffs, :commits, :pipelines] before_action :define_show_vars, only: [:show, :diffs, :commits, :conflicts, :conflict_for_path, :builds, :pipelines] - before_action :define_widget_vars, only: [:merge, :cancel_merge_when_pipeline_succeeds, :merge_check] before_action :define_commit_vars, only: [:diffs] before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :conflict_for_path, :pipelines] before_action :close_merge_request_without_source_project, only: [:show, :diffs, :commits, :builds, :pipelines] @@ -74,10 +73,12 @@ class Projects::MergeRequestsController < Projects::ApplicationController def show respond_to do |format| - format.html { define_discussion_vars } + format.html do + define_discussion_vars + end format.json do - render json: MergeRequestSerializer.new.represent(@merge_request) + render json: serializer.represent(@merge_request, basic: params[:basic]) end format.patch do @@ -154,8 +155,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController format.html { define_discussion_vars } format.json do - if @merge_request.conflicts_can_be_resolved_in_ui? - render json: @merge_request.conflicts + if @conflicts_list.can_be_resolved_in_ui? + render json: @conflicts_list elsif @merge_request.can_be_merged? render json: { message: 'The merge conflicts for this merge request have already been resolved. Please return to the merge request.', @@ -172,9 +173,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def conflict_for_path - return render_404 unless @merge_request.conflicts_can_be_resolved_in_ui? + return render_404 unless @conflicts_list.can_be_resolved_in_ui? - file = @merge_request.conflicts.file_for_path(params[:old_path], params[:new_path]) + file = @conflicts_list.file_for_path(params[:old_path], params[:new_path]) return render_404 unless file @@ -182,7 +183,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def resolve_conflicts - return render_404 unless @merge_request.conflicts_can_be_resolved_in_ui? + return render_404 unless @conflicts_list.can_be_resolved_in_ui? if @merge_request.can_be_merged? render status: :bad_request, json: { message: 'The merge conflicts for this merge request have already been resolved.' } @@ -190,7 +191,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController end begin - MergeRequests::ResolveService.new(@merge_request.source_project, current_user, params).execute(@merge_request) + MergeRequests::Conflicts::ResolveService. + new(merge_request). + execute(current_user, params) flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.' @@ -214,7 +217,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController Gitlab::PollingInterval.set_header(response, interval: 10_000) render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipelines) end end @@ -230,7 +233,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController render json: { pipelines: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipelines) } end @@ -299,17 +302,21 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def remove_wip - MergeRequests::UpdateService.new(project, current_user, wip_event: 'unwip').execute(@merge_request) + @merge_request = MergeRequests::UpdateService + .new(project, current_user, wip_event: 'unwip') + .execute(@merge_request) - redirect_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), - notice: "The merge request can now be merged." + render json: serializer.represent(@merge_request) end def merge_check @merge_request.check_if_can_be_merged - @pipelines = @merge_request.all_pipelines - render partial: "projects/merge_requests/widget/show.html.haml", layout: false + render json: serializer.represent(@merge_request) + end + + def commit_change_content + render partial: 'projects/merge_requests/widget/commit_change_content', layout: false end def cancel_merge_when_pipeline_succeeds @@ -320,65 +327,22 @@ class Projects::MergeRequestsController < Projects::ApplicationController MergeRequests::MergeWhenPipelineSucceedsService .new(@project, current_user) .cancel(@merge_request) + + render json: serializer.represent(@merge_request) end def merge return access_denied! unless @merge_request.can_be_merged_by?(current_user) - # Disable the CI check if merge_when_pipeline_succeeds is enabled since we have - # to wait until CI completes to know - unless @merge_request.mergeable?(skip_ci_check: merge_when_pipeline_succeeds_active?) - @status = :failed - return - end - - if params[:sha] != @merge_request.diff_head_sha - @status = :sha_mismatch - return - end - - @merge_request.update(merge_error: nil) + status = merge! - if params[:merge_when_pipeline_succeeds].present? - unless @merge_request.head_pipeline - @status = :failed - return - end - - if @merge_request.head_pipeline.active? - MergeRequests::MergeWhenPipelineSucceedsService - .new(@project, current_user, merge_params) - .execute(@merge_request) - - @status = :merge_when_pipeline_succeeds - elsif @merge_request.head_pipeline.success? - # This can be triggered when a user clicks the auto merge button while - # the tests finish at about the same time - MergeWorker.perform_async(@merge_request.id, current_user.id, params) - @status = :success - else - @status = :failed - end + if @merge_request.merge_error + render json: { status: status, merge_error: @merge_request.merge_error } else - MergeWorker.perform_async(@merge_request.id, current_user.id, params) - @status = :success + render json: { status: status } end end - def merge_widget_refresh - @status = - if merge_request.merge_when_pipeline_succeeds - :merge_when_pipeline_succeeds - else - # Only MRs that can be merged end in this action - # MR can be already picked up for merge / merged already or can be waiting for worker to be picked up - # in last case it does not have any special status. Possible error is handled inside widget js function - :success - end - - render 'merge' - end - def branch_from # This is always source @source_project = @merge_request.nil? ? @project : @merge_request.source_project @@ -428,37 +392,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end - def ci_status - pipeline = @merge_request.head_pipeline - @pipelines = @merge_request.all_pipelines - - if pipeline - status = pipeline.status - coverage = pipeline.coverage - - status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings? - - status ||= "preparing" - else - ci_service = @merge_request.source_project.try(:ci_service) - status = ci_service.commit_status(merge_request.diff_head_sha, merge_request.source_branch) if ci_service - end - - response = { - title: merge_request.title, - sha: (merge_request.diff_head_commit.short_id if merge_request.diff_head_sha), - status: status, - coverage: coverage, - pipeline: pipeline.try(:id), - has_ci: @merge_request.has_ci? - } - - render json: response - end - def pipeline_status render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent_status(@merge_request.head_pipeline) end @@ -474,10 +410,19 @@ class Projects::MergeRequestsController < Projects::ApplicationController stop_namespace_project_environment_path(project.namespace, project, environment) end + metrics_url = + if can?(current_user, :read_environment, environment) && environment.has_metrics? + metrics_namespace_project_environment_deployment_path(environment.project.namespace, + environment.project, + environment, + deployment) + end + { id: environment.id, name: environment.name, url: namespace_project_environment_path(project.namespace, project, environment), + metrics_url: metrics_url, stop_url: stop_url, external_url: environment.external_url, external_url_formatted: environment.formatted_external_url, @@ -516,7 +461,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def authorize_can_resolve_conflicts! - return render_404 unless @merge_request.conflicts_can_be_resolved_by?(current_user) + @conflicts_list = MergeRequests::Conflicts::ListService.new(@merge_request) + + return render_404 unless @conflicts_list.can_be_resolved_by?(current_user) end def module_enabled @@ -555,10 +502,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) end - def define_widget_vars - @pipeline = @merge_request.head_pipeline - end - def define_commit_vars @commit = @merge_request.diff_head_commit @base_commit = @merge_request.diff_base_commit || @merge_request.likely_diff_base_commit @@ -694,4 +637,46 @@ class Projects::MergeRequestsController < Projects::ApplicationController @merge_request.close end end + + private + + def merge! + # Disable the CI check if merge_when_pipeline_succeeds is enabled since we have + # to wait until CI completes to know + unless @merge_request.mergeable?(skip_ci_check: merge_when_pipeline_succeeds_active?) + return :failed + end + + return :sha_mismatch if params[:sha] != @merge_request.diff_head_sha + + @merge_request.update(merge_error: nil) + + if params[:merge_when_pipeline_succeeds].present? + return :failed unless @merge_request.head_pipeline + + if @merge_request.head_pipeline.active? + MergeRequests::MergeWhenPipelineSucceedsService + .new(@project, current_user, merge_params) + .execute(@merge_request) + + :merge_when_pipeline_succeeds + elsif @merge_request.head_pipeline.success? + # This can be triggered when a user clicks the auto merge button while + # the tests finish at about the same time + MergeWorker.perform_async(@merge_request.id, current_user.id, params) + + :success + else + :failed + end + else + MergeWorker.perform_async(@merge_request.id, current_user.id, params) + + :success + end + end + + def serializer + MergeRequestSerializer.new(current_user: current_user, project: merge_request.project) + end end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 5cb2e428201..602d3dd8c1c 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -37,14 +37,14 @@ class Projects::PipelinesController < Projects::ApplicationController render json: { pipelines: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .with_pagination(request, response) .represent(@pipelines), count: { all: @pipelines_count, running: @running_count, pending: @pending_count, - finished: @finished_count, + finished: @finished_count } } end @@ -74,7 +74,7 @@ class Projects::PipelinesController < Projects::ApplicationController Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipeline, grouped: true) end end @@ -94,7 +94,7 @@ class Projects::PipelinesController < Projects::ApplicationController def status render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent_status(@pipeline) end diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index 66f913f8f9d..3b2b0d9e502 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -23,12 +23,11 @@ class Projects::SnippetsController < Projects::ApplicationController respond_to :html def index - @snippets = SnippetsFinder.new.execute( + @snippets = SnippetsFinder.new( current_user, - filter: :by_project, project: @project, scope: params[:scope] - ) + ).execute @snippets = @snippets.page(params[:page]) if @snippets.out_of_range? && @snippets.total_pages != 0 redirect_to namespace_project_snippets_path(page: @snippets.total_pages) diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index 750c3ec486a..afbea3e2b40 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -38,6 +38,8 @@ class Projects::TagsController < Projects::ApplicationController redirect_to namespace_project_tag_path(@project.namespace, @project, @tag.name) else @error = result[:message] + @message = params[:message] + @release_description = params[:release_description] render action: 'new' end end diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index 5e2182c883e..3ce65b29b3c 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -48,7 +48,7 @@ class Projects::TreeController < Projects::ApplicationController @dir_name = File.join(@path, params[:dir_name]) @commit_params = { file_path: @dir_name, - commit_message: params[:commit_message], + commit_message: params[:commit_message] } end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 69310b26e76..63d018c8cbf 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -220,7 +220,7 @@ class ProjectsController < Projects::ApplicationController branches = BranchesFinder.new(@repository, params).execute.map(&:name) options = { - 'Branches' => branches.take(100), + 'Branches' => branches.take(100) } unless @repository.tag_count.zero? diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index 19e07e3ab86..7445f61195d 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -27,12 +27,8 @@ class SnippetsController < ApplicationController return render_404 unless @user - @snippets = SnippetsFinder.new.execute(current_user, { - filter: :by_user, - user: @user, - scope: params[:scope] - }) - .page(params[:page]) + @snippets = SnippetsFinder.new(current_user, author: @user, scope: params[:scope]) + .execute.page(params[:page]) render 'index' else @@ -103,20 +99,20 @@ class SnippetsController < ApplicationController protected def snippet - @snippet ||= if current_user - PersonalSnippet.where("author_id = ? OR visibility_level IN (?)", - current_user.id, - [Snippet::PUBLIC, Snippet::INTERNAL]). - find(params[:id]) - else - PersonalSnippet.find(params[:id]) - end + @snippet ||= PersonalSnippet.find_by(id: params[:id]) end + alias_method :awardable, :snippet alias_method :spammable, :snippet def authorize_read_snippet! - authenticate_user! unless can?(current_user, :read_personal_snippet, @snippet) + return if can?(current_user, :read_personal_snippet, @snippet) + + if current_user + render_404 + else + authenticate_user! + end end def authorize_update_snippet! diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index ca89ed221c6..ba22b2f9d29 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -128,12 +128,11 @@ class UsersController < ApplicationController end def load_snippets - @snippets = SnippetsFinder.new.execute( + @snippets = SnippetsFinder.new( current_user, - filter: :by_user, - user: user, + author: user, scope: params[:scope] - ).page(params[:page]) + ).execute.page(params[:page]) end def projects_for_current_user diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb index d932a17883f..f68610e197c 100644 --- a/app/finders/groups_finder.rb +++ b/app/finders/groups_finder.rb @@ -1,13 +1,19 @@ class GroupsFinder < UnionFinder - def execute(current_user = nil) - segments = all_groups(current_user) + def initialize(current_user = nil, params = {}) + @current_user = current_user + @params = params + end - find_union(segments, Group).with_route.order_id_desc + def execute + groups = find_union(all_groups, Group).with_route.order_id_desc + by_parent(groups) end private - def all_groups(current_user) + attr_reader :current_user, :params + + def all_groups groups = [] groups << current_user.authorized_groups if current_user @@ -15,4 +21,10 @@ class GroupsFinder < UnionFinder groups end + + def by_parent(groups) + return groups unless params[:parent] + + groups.where(parent: params[:parent]) + end end diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index dc6a8ad1f66..02eb983bf55 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -67,7 +67,7 @@ class NotesFinder when "merge_request" MergeRequestsFinder.new(@current_user, project_id: @project.id).execute when "snippet", "project_snippet" - SnippetsFinder.new.execute(@current_user, filter: :by_project, project: @project) + SnippetsFinder.new(@current_user, project: @project).execute when "personal_snippet" PersonalSnippet.all else diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb index da6e6e87a6f..c04f61de79c 100644 --- a/app/finders/snippets_finder.rb +++ b/app/finders/snippets_finder.rb @@ -1,66 +1,74 @@ -class SnippetsFinder - def execute(current_user, params = {}) - filter = params[:filter] - user = params.fetch(:user, current_user) - - case filter - when :all then - snippets(current_user).fresh - when :public then - Snippet.are_public.fresh - when :by_user then - by_user(current_user, user, params[:scope]) - when :by_project - by_project(current_user, params[:project], params[:scope]) - end +class SnippetsFinder < UnionFinder + attr_accessor :current_user, :params + + def initialize(current_user, params = {}) + @current_user = current_user + @params = params + end + + def execute + items = init_collection + items = by_project(items) + items = by_author(items) + items = by_visibility(items) + + items.fresh end private - def snippets(current_user) - if current_user - Snippet.public_and_internal - else - # Not authenticated - # - # Return only: - # public snippets - Snippet.are_public - end + def init_collection + items = Snippet.all + + accessible(items) end - def by_user(current_user, user, scope) - snippets = user.snippets.fresh + def accessible(items) + segments = [] + segments << items.public_to_user(current_user) + segments << authorized_to_user(items) if current_user - if current_user - include_private = user == current_user - by_scope(snippets, scope, include_private) - else - snippets.are_public - end + find_union(segments, Snippet) end - def by_project(current_user, project, scope) - snippets = project.snippets.fresh + def authorized_to_user(items) + items.where( + 'author_id = :author_id + OR project_id IN (:project_ids)', + author_id: current_user.id, + project_ids: current_user.authorized_projects.select(:id)) + end - if current_user - include_private = project.team.member?(current_user) || current_user.admin? - by_scope(snippets, scope, include_private) - else - snippets.are_public - end + def by_visibility(items) + visibility = params[:visibility] || visibility_from_scope + + return items unless visibility + + items.where(visibility_level: visibility) + end + + def by_author(items) + return items unless params[:author] + + items.where(author_id: params[:author].id) + end + + def by_project(items) + return items unless params[:project] + + items.where(project_id: params[:project].id) end - def by_scope(snippets, scope = nil, include_private = false) - case scope.to_s + def visibility_from_scope + case params[:scope].to_s when 'are_private' - include_private ? snippets.are_private : Snippet.none + Snippet::PRIVATE when 'are_internal' - snippets.are_internal + Snippet::INTERNAL when 'are_public' - snippets.are_public + Snippet::PUBLIC else - include_private ? snippets : snippets.public_and_internal + nil end end end diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb new file mode 100644 index 00000000000..dbd50d1db7c --- /dev/null +++ b/app/finders/users_finder.rb @@ -0,0 +1,74 @@ +# UsersFinder +# +# Used to filter users by set of params +# +# Arguments: +# current_user - which user use +# params: +# username: string +# extern_uid: string +# provider: string +# search: string +# active: boolean +# blocked: boolean +# external: boolean +# +class UsersFinder + attr_accessor :current_user, :params + + def initialize(current_user, params = {}) + @current_user = current_user + @params = params + end + + def execute + users = User.all + users = by_username(users) + users = by_search(users) + users = by_blocked(users) + users = by_active(users) + users = by_external_identity(users) + users = by_external(users) + + users + end + + private + + def by_username(users) + return users unless params[:username] + + users.where(username: params[:username]) + end + + def by_search(users) + return users unless params[:search].present? + + users.search(params[:search]) + end + + def by_blocked(users) + return users unless params[:blocked] + + users.blocked + end + + def by_active(users) + return users unless params[:active] + + users.active + end + + def by_external_identity(users) + return users unless current_user.admin? && params[:extern_uid] && params[:provider] + + users.joins(:identities).merge(Identity.with_extern_uid(params[:provider], params[:extern_uid])) + end + + def by_external(users) + return users = users.where.not(external: true) unless current_user.admin? + return users unless params[:external] + + users.external + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 1bca588fdc9..e5e64650708 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -77,7 +77,7 @@ module ApplicationHelper end if user - user.avatar_url(size) || default_avatar + user.avatar_url(size: size) || default_avatar else gravatar_icon(user_or_email, size, scale) end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index af430270ae4..eb37f2e0267 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -18,7 +18,7 @@ module BlobHelper blob = options.delete(:blob) blob ||= project.repository.blob_at(ref, path) rescue nil - return unless blob + return unless blob && blob.readable_text? common_classes = "btn js-edit-blob #{options[:extra_class]}" diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index c85e96cf78d..206d0753f08 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -42,7 +42,10 @@ module ButtonHelper class: "btn #{css_class}", data: data, type: :button, - title: title + title: title, + aria: { + label: title + } end def http_clone_button(project, placement = 'right', append_link: true) diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index cef624430da..6d6f1361bf9 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -74,12 +74,8 @@ module CommitsHelper # Returns the sorted alphabetically links to branches, separated by a comma def commit_branches_links(project, branches) branches.sort.map do |branch| - link_to( - namespace_project_tree_path(project.namespace, project, branch) - ) do - content_tag :span, class: 'label label-gray' do - icon('code-fork') + ' ' + branch - end + link_to(project_ref_path(project, branch), class: "label label-gray ref-name") do + icon('code-fork') + " #{branch}" end end.join(" ").html_safe end @@ -88,29 +84,22 @@ module CommitsHelper def commit_tags_links(project, tags) sorted = VersionSorter.rsort(tags) sorted.map do |tag| - link_to( - namespace_project_commits_path(project.namespace, project, - project.repository.find_tag(tag).name) - ) do - content_tag :span, class: 'label label-gray' do - icon('tag') + ' ' + tag - end + link_to(project_ref_path(project, tag), class: "label label-gray ref-name") do + icon('tag') + " #{tag}" end end.join(" ").html_safe end def link_to_browse_code(project, commit) + return unless current_controller?(:projects, :commits) + if @path.blank? return link_to( "Browse Files", namespace_project_tree_path(project.namespace, project, commit), class: "btn btn-default" ) - end - - return unless current_controller?(:projects, :commits) - - if @repo.blob_at(commit.id, @path) + elsif @repo.blob_at(commit.id, @path) return link_to( "Browse File", namespace_project_blob_path(project.namespace, project, @@ -200,8 +189,8 @@ module CommitsHelper tree_join(commit_sha, diff_new_path)), class: 'btn view-file js-view-file' ) do - raw('View file @') + content_tag(:span, commit_sha[0..6], - class: 'commit-short-id') + raw('View file @ ') + content_tag(:span, Commit.truncate_sha(commit_sha), + class: 'commit-sha') end end diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index dc144906548..4a06ee653ee 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -63,7 +63,7 @@ module DiffHelper def parallel_diff_discussions(left, right, diff_file) return unless @grouped_diff_discussions - + discussions_left = discussions_right = nil if left && (left.unchanged? || left.removed?) @@ -98,7 +98,7 @@ module DiffHelper [ content_tag(:span, link_to(truncate(blob.name, length: 40), tree)), '@', - content_tag(:span, commit_id, class: 'monospace'), + content_tag(:span, commit_id, class: 'commit-sha') ].join(' ').html_safe end diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index f927cfc998f..3b24f183785 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -12,7 +12,7 @@ module EmailsHelper "action" => { "@type" => "ViewAction", "name" => name, - "url" => url, + "url" => url } } diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 960111ca045..751d61955b7 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -41,7 +41,7 @@ module EventsHelper link_opts = { class: "event-filter-link", id: "#{key}_event_filter", - title: "Filter by #{tooltip.downcase}", + title: "Filter by #{tooltip.downcase}" } content_tag :li, class: active do @@ -164,9 +164,14 @@ module EventsHelper def event_note_title_html(event) if event.note_target - link_to(event_note_target_path(event), title: event.target_title, class: 'has-tooltip') do - "#{event.note_target_type} #{event.note_target_reference}" - end + text = raw("#{event.note_target_type} ") + + if event.commit_note? + content_tag(:span, event.note_target_reference, class: 'commit-sha') + else + event.note_target_reference + end + + link_to(text, event_note_target_path(event), title: event.target_title, class: 'has-tooltip') else content_tag(:strong, '(deleted)') end diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb index 7bd212a3ef9..b981a1e8242 100644 --- a/app/helpers/explore_helper.rb +++ b/app/helpers/explore_helper.rb @@ -10,7 +10,7 @@ module ExploreHelper personal: params[:personal], archived: params[:archived], shared: params[:shared], - namespace_id: params[:namespace_id], + namespace_id: params[:namespace_id] } options = exist_opts.merge(options).delete_if { |key, value| value.blank? } diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index bcf71bc347b..fc308b3960e 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -54,6 +54,10 @@ module GitlabRoutingHelper namespace_project_builds_path(project.namespace, project, *args) end + def project_ref_path(project, ref_name, *args) + namespace_project_commits_path(project.namespace, project, ref_name, *args) + end + def project_container_registry_path(project, *args) namespace_project_container_registry_index_path(project.namespace, project, *args) end diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index 55fa81e95ef..ef96a554b7e 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -19,6 +19,8 @@ module IconsHelper case names when "standard" names = "key" + when "two-factor" + names = "key" end options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 7656929efe7..9290e4ec133 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -37,7 +37,10 @@ module IssuablesHelper when Issue IssueSerializer.new.represent(issuable).to_json when MergeRequest - MergeRequestSerializer.new.represent(issuable).to_json + MergeRequestSerializer + .new(current_user: current_user, project: issuable.project) + .represent(issuable) + .to_json end end @@ -64,9 +67,10 @@ module IssuablesHelper end def users_dropdown_label(selected_users) - if selected_users.length == 0 + case selected_users.length + when 0 "Unassigned" - elsif selected_users.length == 1 + when 1 selected_users[0].name else "#{selected_users[0].name} + #{selected_users.length - 1} more" @@ -133,11 +137,9 @@ module IssuablesHelper author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "hidden-sm hidden-md hidden-lg") end - if issuable.tasks? - output << " ".html_safe - output << content_tag(:span, issuable.task_status, id: "task_status", class: "hidden-xs hidden-sm") - output << content_tag(:span, issuable.task_status_short, id: "task_status_short", class: "hidden-md hidden-lg") - end + output << " ".html_safe + output << content_tag(:span, issuable.task_status, id: "task_status", class: "hidden-xs hidden-sm") + output << content_tag(:span, issuable.task_status_short, id: "task_status_short", class: "hidden-md hidden-lg") output end diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index b241a14740b..0009cad86c4 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -32,7 +32,7 @@ module MarkupHelper context = { project: @project, current_user: (current_user if defined?(current_user)), - pipeline: :single_line, + pipeline: :single_line } gfm_body = Banzai.render(body, context) @@ -116,13 +116,13 @@ module MarkupHelper if gitlab_markdown?(file_name) markdown_unsafe(text, context) elsif asciidoc?(file_name) - asciidoc_unsafe(text) + asciidoc_unsafe(text, context) elsif plain?(file_name) content_tag :pre, class: 'plain-readme' do text end else - other_markup_unsafe(file_name, text) + other_markup_unsafe(file_name, text, context) end rescue RuntimeError simple_format(text) @@ -217,12 +217,12 @@ module MarkupHelper Banzai.render(text, context) end - def asciidoc_unsafe(text) - Gitlab::Asciidoc.render(text) + def asciidoc_unsafe(text, context = {}) + Gitlab::Asciidoc.render(text, context) end - def other_markup_unsafe(file_name, text) - Gitlab::OtherMarkup.render(file_name, text) + def other_markup_unsafe(file_name, text, context = {}) + Gitlab::OtherMarkup.render(file_name, text, context) end def prepare_for_rendering(html, context = {}) diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 2614cdfe90e..39d30631646 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -19,14 +19,6 @@ module MergeRequestsHelper } end - def mr_widget_refresh_url(mr) - if mr && mr.target_project - merge_widget_refresh_namespace_project_merge_request_url(mr.target_project.namespace, mr.target_project, mr) - else - '' - end - end - def mr_css_classes(mr) classes = "merge-request" classes << " closed" if mr.closed? @@ -55,23 +47,6 @@ module MergeRequestsHelper end end - def issues_sentence(issues) - # Issuable sorter will sort local issues, then issues from the same - # namespace, then all other issues. - issues = Gitlab::IssuableSorter.sort(@project, issues).map do |issue| - issue.to_reference(@project) - end - issues.to_sentence - end - - def mr_closes_issues - @mr_closes_issues ||= @merge_request.closes_issues(current_user) - end - - def mr_issues_mentioned_but_not_closing - @mr_issues_mentioned_but_not_closing ||= @merge_request.issues_mentioned_but_not_closing(current_user) - end - def mr_change_branches_path(merge_request) new_namespace_project_merge_request_path( @project.namespace, @project, @@ -79,41 +54,12 @@ module MergeRequestsHelper source_project_id: merge_request.source_project_id, target_project_id: merge_request.target_project_id, source_branch: merge_request.source_branch, - target_branch: merge_request.target_branch, + target_branch: merge_request.target_branch }, change_branches: true ) end - def mr_assign_issues_link - issues = MergeRequests::AssignIssuesService.new(@project, - current_user, - merge_request: @merge_request, - closes_issues: mr_closes_issues - ).assignable_issues - path = assign_related_issues_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) - if issues.present? - pluralize_this_issue = issues.count > 1 ? "these issues" : "this issue" - link_to "Assign yourself to #{pluralize_this_issue}", path, method: :post - end - end - - def source_branch_with_namespace(merge_request) - namespace = merge_request.source_project_namespace - branch = merge_request.source_branch - - if merge_request.source_branch_exists? - namespace = link_to(namespace, project_path(merge_request.source_project)) - branch = link_to(branch, namespace_project_commits_path(merge_request.source_project.namespace, merge_request.source_project, merge_request.source_branch)) - end - - if merge_request.for_fork? - namespace + ":" + branch - else - branch - end - end - def format_mr_branch_names(merge_request) source_path = merge_request.source_project_path target_path = merge_request.target_project_path diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 52403640c05..375110b77e2 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -19,7 +19,7 @@ module NotesHelper id: noteable.id, class: noteable.class.name, resources: noteable.class.table_name, - project_id: noteable.project.id, + project_id: noteable.project.id }.to_json end @@ -34,7 +34,7 @@ module NotesHelper data = { line_code: line_code, - line_type: line_type, + line_type: line_type } if @use_legacy_diff_notes diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 8c26348a975..98bbcfaaba5 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -110,11 +110,8 @@ module ProjectsHelper end def license_short_name(project) - return 'LICENSE' if project.repository.license_key.nil? - - license = Licensee::License.new(project.repository.license_key) - - license.nickname || license.name + license = project.repository.license + license&.nickname || license&.name || 'LICENSE' end def last_push_event @@ -160,7 +157,15 @@ module ProjectsHelper end def project_list_cache_key(project) - key = [project.namespace.cache_key, project.cache_key, controller.controller_name, controller.action_name, current_application_settings.cache_key, 'v2.4'] + key = [ + project.route.cache_key, + project.cache_key, + controller.controller_name, + controller.action_name, + current_application_settings.cache_key, + 'v2.4' + ] + key << pipeline_status_cache_key(project.pipeline_status) if project.pipeline_status.has_status? key diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 8ff8db16514..9c46035057f 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -42,7 +42,7 @@ module SearchHelper { category: "Settings", label: "User settings", url: profile_path }, { category: "Settings", label: "SSH Keys", url: profile_keys_path }, { category: "Settings", label: "Dashboard", url: root_path }, - { category: "Settings", label: "Admin Section", url: admin_root_path }, + { category: "Settings", label: "Admin Section", url: admin_root_path } ] end @@ -57,7 +57,7 @@ module SearchHelper { category: "Help", label: "SSH Keys Help", url: help_page_path("ssh/README") }, { category: "Help", label: "System Hooks Help", url: help_page_path("system_hooks/system_hooks") }, { category: "Help", label: "Webhooks Help", url: help_page_path("user/project/integrations/webhooks") }, - { category: "Help", label: "Workflow Help", url: help_page_path("workflow/README") }, + { category: "Help", label: "Workflow Help", url: help_page_path("workflow/README") } ] end @@ -76,7 +76,7 @@ module SearchHelper { category: "Current Project", label: "Milestones", url: namespace_project_milestones_path(@project.namespace, @project) }, { category: "Current Project", label: "Snippets", url: namespace_project_snippets_path(@project.namespace, @project) }, { category: "Current Project", label: "Members", url: namespace_project_settings_members_path(@project.namespace, @project) }, - { category: "Current Project", label: "Wiki", url: namespace_project_wikis_path(@project.namespace, @project) }, + { category: "Current Project", label: "Wiki", url: namespace_project_wikis_path(@project.namespace, @project) } ] else [] diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb index 8706876ae4a..a7d1fe4aa47 100644 --- a/app/helpers/selects_helper.rb +++ b/app/helpers/selects_helper.rb @@ -67,7 +67,7 @@ module SelectsHelper current_user: opts[:current_user] || false, "push-code-to-protected-branches" => opts[:push_code_to_protected_branches], author_id: opts[:author_id] || '', - skip_users: opts[:skip_users] ? opts[:skip_users].map(&:id) : nil, + skip_users: opts[:skip_users] ? opts[:skip_users].map(&:id) : nil } end end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 4882d9b71d2..b408ec0c6a4 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -58,7 +58,7 @@ module SortingHelper sort_value_due_date_soon => sort_title_due_date_soon, sort_value_due_date_later => sort_title_due_date_later, sort_value_start_date_soon => sort_title_start_date_soon, - sort_value_start_date_later => sort_title_start_date_later, + sort_value_start_date_later => sort_title_start_date_later } end diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb index a762b320d56..09b73eee8cf 100644 --- a/app/helpers/submodule_helper.rb +++ b/app/helpers/submodule_helper.rb @@ -1,28 +1,34 @@ module SubmoduleHelper include Gitlab::ShellAdapter + VALID_SUBMODULE_PROTOCOLS = %w[http https git ssh].freeze + # links to files listing for submodule if submodule is a project on this server def submodule_links(submodule_item, ref = nil, repository = @repository) url = repository.submodule_url_for(ref, submodule_item.path) - return url, nil unless url =~ /([^\/:]+)\/([^\/]+(?:\.git)?)\Z/ - - namespace = $1 - project = $2 - project.chomp!('.git') - - if self_url?(url, namespace, project) - return namespace_project_path(namespace, project), - namespace_project_tree_path(namespace, project, - submodule_item.id) - elsif relative_self_url?(url) - relative_self_links(url, submodule_item.id) - elsif github_dot_com_url?(url) - standard_links('github.com', namespace, project, submodule_item.id) - elsif gitlab_dot_com_url?(url) - standard_links('gitlab.com', namespace, project, submodule_item.id) + if url == '.' || url == './' + url = File.join(Gitlab.config.gitlab.url, @project.full_path) + end + + if url =~ /([^\/:]+)\/([^\/]+(?:\.git)?)\Z/ + namespace, project = $1, $2 + project.sub!(/\.git\z/, '') + + if self_url?(url, namespace, project) + [namespace_project_path(namespace, project), + namespace_project_tree_path(namespace, project, submodule_item.id)] + elsif relative_self_url?(url) + relative_self_links(url, submodule_item.id) + elsif github_dot_com_url?(url) + standard_links('github.com', namespace, project, submodule_item.id) + elsif gitlab_dot_com_url?(url) + standard_links('gitlab.com', namespace, project, submodule_item.id) + else + [sanitize_submodule_url(url), nil] + end else - return url, nil + [sanitize_submodule_url(url), nil] end end @@ -73,4 +79,16 @@ module SubmoduleHelper namespace_project_tree_path(namespace, base, commit) ] end + + def sanitize_submodule_url(url) + uri = URI.parse(url) + + if uri.scheme.in?(VALID_SUBMODULE_PROTOCOLS) + uri.to_s + else + nil + end + rescue URI::InvalidURIError + nil + end end diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index f19e2f9db9c..19286fadb19 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -24,10 +24,13 @@ module TodosHelper end def todo_target_link(todo) - target = todo.target_type.titleize.downcase - link_to "#{target} #{todo.target_reference}", todo_target_path(todo), - class: 'has-tooltip', - title: todo.target.title + text = raw("#{todo.target_type.titleize.downcase} ") + + if todo.for_commit? + content_tag(:span, todo.target_reference, class: 'commit-sha') + else + todo.target_reference + end + link_to text, todo_target_path(todo), class: 'has-tooltip', title: todo.target.title end def todo_target_path(todo) @@ -63,7 +66,7 @@ module TodosHelper project_id: params[:project_id], author_id: params[:author_id], type: params[:type], - action_id: params[:action_id], + action_id: params[:action_id] } end diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index a91e3da309c..e0d3e9b88f3 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -81,7 +81,7 @@ module TreeHelper part_path = "" parts = @path.split('/') - yield('..', nil) if parts.count > max_links + yield('..', File.join(*parts.first(parts.count - 2))) if parts.count > max_links parts.each do |part| part_path = File.join(part_path, part) unless part_path.empty? diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 54f01f8637e..043f57241a3 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -246,7 +246,7 @@ class ApplicationSetting < ActiveRecord::Base two_factor_grace_period: 48, user_default_external: false, polling_interval_multiplier: 1, - usage_ping_enabled: true + usage_ping_enabled: Settings.gitlab['usage_ping_enabled'] } end @@ -349,6 +349,14 @@ class ApplicationSetting < ActiveRecord::Base sidekiq_throttling_enabled end + def usage_ping_can_be_configured? + Settings.gitlab.usage_ping_enabled + end + + def usage_ping_enabled + usage_ping_can_be_configured? && super + end + private def ensure_uuid! diff --git a/app/models/blob.rb b/app/models/blob.rb index eaf0b713122..63a81c0e3bd 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -33,11 +33,14 @@ class Blob < SimpleDelegator BlobViewer::PDF, BlobViewer::BinarySTL, - BlobViewer::TextSTL, - ].freeze + BlobViewer::TextSTL + ].sort_by { |v| v.binary? ? 0 : 1 }.freeze - BINARY_VIEWERS = RICH_VIEWERS.select(&:binary?).freeze - TEXT_VIEWERS = RICH_VIEWERS.select(&:text?).freeze + AUXILIARY_VIEWERS = [ + BlobViewer::GitlabCiYml, + BlobViewer::RouteMap, + BlobViewer::License + ].freeze attr_reader :project @@ -154,6 +157,12 @@ class Blob < SimpleDelegator @rich_viewer = rich_viewer_class&.new(self) end + def auxiliary_viewer + return @auxiliary_viewer if defined?(@auxiliary_viewer) + + @auxiliary_viewer = auxiliary_viewer_class&.new(self) + end + def rendered_as_text?(ignore_errors: true) simple_viewer.text? && (ignore_errors || simple_viewer.render_error.nil?) end @@ -180,17 +189,18 @@ class Blob < SimpleDelegator end def rich_viewer_class + viewer_class_from(RICH_VIEWERS) + end + + def auxiliary_viewer_class + viewer_class_from(AUXILIARY_VIEWERS) + end + + def viewer_class_from(classes) return if empty? || external_storage_error? - classes = - if stored_externally? - BINARY_VIEWERS + TEXT_VIEWERS - elsif binary? - BINARY_VIEWERS - else # text - TEXT_VIEWERS - end + verify_binary = !stored_externally? - classes.find { |viewer_class| viewer_class.can_render?(self) } + classes.find { |viewer_class| viewer_class.can_render?(self, verify_binary: verify_binary) } end end diff --git a/app/models/blob_viewer/auxiliary.rb b/app/models/blob_viewer/auxiliary.rb new file mode 100644 index 00000000000..db124397b27 --- /dev/null +++ b/app/models/blob_viewer/auxiliary.rb @@ -0,0 +1,12 @@ +module BlobViewer + module Auxiliary + extend ActiveSupport::Concern + + included do + self.loading_partial_name = 'loading_auxiliary' + self.type = :auxiliary + self.max_size = 100.kilobytes + self.absolute_max_size = 100.kilobytes + end + end +end diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb index a8b91d8d6bc..4f38c31714b 100644 --- a/app/models/blob_viewer/base.rb +++ b/app/models/blob_viewer/base.rb @@ -1,8 +1,12 @@ module BlobViewer class Base - class_attribute :partial_name, :type, :extensions, :client_side, :binary, :switcher_icon, :switcher_title, :max_size, :absolute_max_size + PARTIAL_PATH_PREFIX = 'projects/blob/viewers'.freeze - delegate :partial_path, :rich?, :simple?, :client_side?, :server_side?, :text?, :binary?, to: :class + class_attribute :partial_name, :loading_partial_name, :type, :extensions, :file_type, :client_side, :binary, :switcher_icon, :switcher_title, :max_size, :absolute_max_size + + self.loading_partial_name = 'loading' + + delegate :partial_path, :loading_partial_path, :rich?, :simple?, :client_side?, :server_side?, :text?, :binary?, to: :class attr_reader :blob attr_accessor :override_max_size @@ -12,7 +16,11 @@ module BlobViewer end def self.partial_path - "projects/blob/viewers/#{partial_name}" + File.join(PARTIAL_PATH_PREFIX, partial_name) + end + + def self.loading_partial_path + File.join(PARTIAL_PATH_PREFIX, loading_partial_name) end def self.rich? @@ -23,6 +31,10 @@ module BlobViewer type == :simple end + def self.auxiliary? + type == :auxiliary + end + def self.client_side? client_side end @@ -39,8 +51,12 @@ module BlobViewer !binary? end - def self.can_render?(blob) - !extensions || extensions.include?(blob.extension) + def self.can_render?(blob, verify_binary: true) + return false if verify_binary && binary? != blob.binary? + return true if extensions&.include?(blob.extension) + return true if file_type && Gitlab::FileDetector.type_of(blob.path) == file_type + + false end def too_large? @@ -83,9 +99,7 @@ module BlobViewer end def prepare! - if server_side? && blob.project - blob.load_all_data!(blob.project.repository) - end + # To be overridden by subclasses end private diff --git a/app/models/blob_viewer/gitlab_ci_yml.rb b/app/models/blob_viewer/gitlab_ci_yml.rb new file mode 100644 index 00000000000..81afab2f49b --- /dev/null +++ b/app/models/blob_viewer/gitlab_ci_yml.rb @@ -0,0 +1,23 @@ +module BlobViewer + class GitlabCiYml < Base + include ServerSide + include Auxiliary + + self.partial_name = 'gitlab_ci_yml' + self.loading_partial_name = 'gitlab_ci_yml_loading' + self.file_type = :gitlab_ci + self.binary = false + + def validation_message + return @validation_message if defined?(@validation_message) + + prepare! + + @validation_message = Ci::GitlabCiYamlProcessor.validation_message(blob.data) + end + + def valid? + validation_message.blank? + end + end +end diff --git a/app/models/blob_viewer/license.rb b/app/models/blob_viewer/license.rb new file mode 100644 index 00000000000..3ad49570c88 --- /dev/null +++ b/app/models/blob_viewer/license.rb @@ -0,0 +1,23 @@ +module BlobViewer + class License < Base + # We treat the License viewer as if it renders the content client-side, + # so that it doesn't attempt to load the entire blob contents and is + # rendered synchronously instead of loaded asynchronously. + include ClientSide + include Auxiliary + + self.partial_name = 'license' + self.file_type = :license + self.binary = false + + def license + blob.project.repository.license + end + + def render_error + return if license + + :unknown_license + end + end +end diff --git a/app/models/blob_viewer/route_map.rb b/app/models/blob_viewer/route_map.rb new file mode 100644 index 00000000000..1ca730c1ea0 --- /dev/null +++ b/app/models/blob_viewer/route_map.rb @@ -0,0 +1,30 @@ +module BlobViewer + class RouteMap < Base + include ServerSide + include Auxiliary + + self.partial_name = 'route_map' + self.loading_partial_name = 'route_map_loading' + self.file_type = :route_map + self.binary = false + + def validation_message + return @validation_message if defined?(@validation_message) + + prepare! + + @validation_message = + begin + Gitlab::RouteMap.new(blob.data) + + nil + rescue Gitlab::RouteMap::FormatError => e + e.message + end + end + + def valid? + validation_message.blank? + end + end +end diff --git a/app/models/blob_viewer/server_side.rb b/app/models/blob_viewer/server_side.rb index 899107d02ea..e8c5c17b824 100644 --- a/app/models/blob_viewer/server_side.rb +++ b/app/models/blob_viewer/server_side.rb @@ -7,5 +7,11 @@ module BlobViewer self.max_size = 2.megabytes self.absolute_max_size = 5.megabytes end + + def prepare! + if blob.project + blob.load_all_data!(blob.project.repository) + end + end end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 971ab7cb0ee..3c4a4d93349 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -124,8 +124,8 @@ module Ci success? || failed? || canceled? end - def retried? - !self.pipeline.statuses.latest.include?(self) + def latest? + !retried? end def expanded_environment_name diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index db994b861e5..81c30b0e077 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -18,6 +18,10 @@ module Ci has_many :builds, foreign_key: :commit_id has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id + # Merge requests for which the current pipeline is running against + # the merge request's latest commit. + has_many :merge_requests, foreign_key: "head_pipeline_id" + has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build' has_many :retryable_builds, -> { latest.failed_or_canceled }, foreign_key: :commit_id, class_name: 'Ci::Build' has_many :cancelable_statuses, -> { cancelable }, foreign_key: :commit_id, class_name: 'CommitStatus' @@ -381,14 +385,6 @@ module Ci project.execute_services(data, :pipeline_hooks) end - # Merge requests for which the current pipeline is running against - # the merge request's latest commit. - def merge_requests - @merge_requests ||= project.merge_requests - .where(source_branch: self.ref) - .select { |merge_request| merge_request.head_pipeline.try(:id) == self.id } - end - # All the merge requests for which the current pipeline runs/ran against def all_merge_requests @all_merge_requests ||= project.merge_requests.where(source_branch: ref) diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index 6d7cc83971e..cf6e53c4ca4 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -28,10 +28,18 @@ module Ci !active? end + def deactivate! + update_attribute(:active, false) + end + def importing_or_inactive? importing? || inactive? end + def runnable_by_owner? + Ability.allowed?(owner, :create_pipeline, project) + end + def set_next_run_at self.next_run_at = Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now) end diff --git a/app/models/commit.rb b/app/models/commit.rb index 9359b323ed4..dbc0a22829e 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -49,7 +49,7 @@ class Commit def max_diff_options { max_files: DIFF_HARD_LIMIT_FILES, - max_lines: DIFF_HARD_LIMIT_LINES, + max_lines: DIFF_HARD_LIMIT_LINES } end @@ -326,16 +326,23 @@ class Commit end def raw_diffs(*args) - use_gitaly = Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs) - deltas_only = args.last.is_a?(Hash) && args.last[:deltas_only] - - if use_gitaly && !deltas_only - Gitlab::GitalyClient::Commit.diff_from_parent(self, *args) + if Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs) + Gitlab::GitalyClient::Commit.new(project.repository).diff_from_parent(self, *args) else raw.diffs(*args) end end + def raw_deltas + @deltas ||= Gitlab::GitalyClient.migrate(:commit_deltas) do |is_enabled| + if is_enabled + Gitlab::GitalyClient::Commit.new(project.repository).commit_deltas(self) + else + raw.deltas + end + end + end + def diffs(diff_options = nil) Gitlab::Diff::FileCollection::Commit.new(self, diff_options: diff_options) end @@ -373,7 +380,7 @@ class Commit def repo_changes changes = { added: [], modified: [], removed: [] } - raw_diffs(deltas_only: true).each do |diff| + raw_deltas.each do |diff| if diff.deleted_file changes[:removed] << diff.old_path elsif diff.renamed_file || diff.new_file diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 75d04fd2b08..ffafc678968 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -18,13 +18,7 @@ class CommitStatus < ActiveRecord::Base validates :name, presence: true alias_attribute :author, :user - - scope :latest, -> do - max_id = unscope(:select).select("max(#{quoted_table_name}.id)") - - where(id: max_id.group(:name, :commit_id)) - end - + scope :failed_but_allowed, -> do where(allow_failure: true, status: [:failed, :canceled]) end @@ -37,7 +31,8 @@ class CommitStatus < ActiveRecord::Base false, all_state_names - [:failed, :canceled, :manual]) end - scope :retried, -> { where.not(id: latest) } + scope :latest, -> { where(retried: [false, nil]) } + scope :retried, -> { where(retried: true) } scope :ordered, -> { order(:name) } scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) } scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) } diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb new file mode 100644 index 00000000000..8fbfed11bdf --- /dev/null +++ b/app/models/concerns/avatarable.rb @@ -0,0 +1,18 @@ +module Avatarable + extend ActiveSupport::Concern + + def avatar_path(only_path: true) + return unless self[:avatar].present? + + # If only_path is true then use the relative path of avatar. + # Otherwise use full path (including host). + asset_host = ActionController::Base.asset_host + gitlab_host = only_path ? gitlab_config.relative_url_root : gitlab_config.url + + # If asset_host is set then it is expected that assets are handled by a standalone host. + # That means we do not want to get GitLab's relative_url_root option anymore. + host = asset_host.present? ? asset_host : gitlab_host + + [host, avatar.url].join + end +end diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 7e56e371b27..c034bf9cbc0 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -44,14 +44,15 @@ module Mentionable end def all_references(current_user = nil, extractor: nil) + @extractors ||= {} + # Use custom extractor if it's passed in the function parameters. if extractor - @extractor = extractor + @extractors[current_user] = extractor else - @extractor ||= Gitlab::ReferenceExtractor. - new(project, current_user) + extractor = @extractors[current_user] ||= Gitlab::ReferenceExtractor.new(project, current_user) - @extractor.reset_memoized_values + extractor.reset_memoized_values end self.class.mentionable_attrs.each do |attr, options| @@ -62,10 +63,10 @@ module Mentionable skip_project_check: skip_project_check? ) - @extractor.analyze(text, options) + extractor.analyze(text, options) end - @extractor + extractor end def mentioned_users(current_user = nil) @@ -78,6 +79,8 @@ module Mentionable # Extract GFM references to other Mentionables from this Mentionable. Always excludes its #local_reference. def referenced_mentionables(current_user = self.author) + return [] unless matches_cross_reference_regex? + refs = all_references(current_user) refs = (refs.issues + refs.merge_requests + refs.commits) @@ -87,6 +90,20 @@ module Mentionable refs.reject { |ref| ref == local_reference } end + # Uses regex to quickly determine if mentionables might be referenced + # Allows heavy processing to be skipped + def matches_cross_reference_regex? + reference_pattern = if !project || project.default_issues_tracker? + ReferenceRegexes::DEFAULT_PATTERN + else + ReferenceRegexes::EXTERNAL_PATTERN + end + + self.class.mentionable_attrs.any? do |attr, _| + __send__(attr) =~ reference_pattern + end + end + # Create a cross-reference Note for each GFM reference to another Mentionable found in the +mentionable_attrs+. def create_cross_references!(author = self.author, without = []) refs = referenced_mentionables(author) diff --git a/app/models/concerns/mentionable/reference_regexes.rb b/app/models/concerns/mentionable/reference_regexes.rb new file mode 100644 index 00000000000..1848230ec7e --- /dev/null +++ b/app/models/concerns/mentionable/reference_regexes.rb @@ -0,0 +1,22 @@ +module Mentionable + module ReferenceRegexes + def self.reference_pattern(link_patterns, issue_pattern) + Regexp.union(link_patterns, + issue_pattern, + Commit.reference_pattern, + MergeRequest.reference_pattern) + end + + DEFAULT_PATTERN = begin + issue_pattern = Issue.reference_pattern + link_patterns = Regexp.union([Issue, Commit, MergeRequest].map(&:link_reference_pattern)) + reference_pattern(link_patterns, issue_pattern) + end + + EXTERNAL_PATTERN = begin + issue_pattern = ExternalIssue.reference_pattern + link_patterns = URI.regexp(%w(http https)) + reference_pattern(link_patterns, issue_pattern) + end + end +end diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb index c41b807df8a..a40148a4394 100644 --- a/app/models/concerns/protected_branch_access.rb +++ b/app/models/concerns/protected_branch_access.rb @@ -7,5 +7,27 @@ module ProtectedBranchAccess belongs_to :protected_branch delegate :project, to: :protected_branch + + validates :access_level, presence: true, inclusion: { + in: [ + Gitlab::Access::MASTER, + Gitlab::Access::DEVELOPER, + Gitlab::Access::NO_ACCESS + ] + } + + def self.human_access_levels + { + Gitlab::Access::MASTER => "Masters", + Gitlab::Access::DEVELOPER => "Developers + Masters", + Gitlab::Access::NO_ACCESS => "No one" + }.with_indifferent_access + end + + def check_access(user) + return false if access_level == Gitlab::Access::NO_ACCESS + + super + end end end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 37adfb4de73..216cec751e3 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -99,6 +99,16 @@ class Deployment < ActiveRecord::Base created_at.to_time.in_time_zone.to_s(:medium) end + def has_metrics? + project.monitoring_service.present? + end + + def metrics + return {} unless has_metrics? + + project.monitoring_service.deployment_metrics(self) + end + private def ref_path diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb index d627fbe327f..14ddd2fcc88 100644 --- a/app/models/diff_discussion.rb +++ b/app/models/diff_discussion.rb @@ -39,7 +39,7 @@ class DiffDiscussion < Discussion def reply_attributes super.merge( original_position: original_position.to_json, - position: position.to_json, + position: position.to_json ) end end diff --git a/app/models/environment.rb b/app/models/environment.rb index bf33010fd21..61572d8d69a 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -62,7 +62,7 @@ class Environment < ActiveRecord::Base def predefined_variables [ { key: 'CI_ENVIRONMENT_NAME', value: name, public: true }, - { key: 'CI_ENVIRONMENT_SLUG', value: slug, public: true }, + { key: 'CI_ENVIRONMENT_SLUG', value: slug, public: true } ] end @@ -150,7 +150,7 @@ class Environment < ActiveRecord::Base end def metrics - project.monitoring_service.metrics(self) if has_metrics? + project.monitoring_service.environment_metrics(self) if has_metrics? end # An environment name is not necessarily suitable for use in URLs, DNS diff --git a/app/models/group.rb b/app/models/group.rb index cbc10b00cf5..6aab477f431 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -4,6 +4,7 @@ class Group < Namespace include Gitlab::ConfigHelper include Gitlab::VisibilityLevel include AccessRequestable + include Avatarable include Referable include SelectForProjectAuthorization @@ -111,10 +112,10 @@ class Group < Namespace allowed_by_projects end - def avatar_url(size = nil) - if self[:avatar].present? - [gitlab_config.url, avatar.url].join - end + def avatar_url(**args) + # We use avatar_path instead of overriding avatar_url because of carrierwave. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864 + avatar_path(args) end def lfs_enabled? diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb index 777bad1e724..c645805c6da 100644 --- a/app/models/hooks/system_hook.rb +++ b/app/models/hooks/system_hook.rb @@ -1,4 +1,9 @@ class SystemHook < WebHook + scope :repository_update_hooks, -> { where(repository_update_events: true) } + + default_value_for :push_events, false + default_value_for :repository_update_events, true + def async_execute(data, hook_name) Sidekiq::Client.enqueue(SystemHookWorker, id, data, hook_name) end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 595602e80fe..7cf03aabd6f 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -10,6 +10,7 @@ class WebHook < ActiveRecord::Base default_value_for :tag_push_events, false default_value_for :build_events, false default_value_for :pipeline_events, false + default_value_for :repository_update_events, false default_value_for :enable_ssl_verification, true scope :push_hooks, -> { where(push_events: true) } @@ -31,7 +32,7 @@ class WebHook < ActiveRecord::Base post_url = url.gsub("#{parsed_url.userinfo}@", '') auth = { username: CGI.unescape(parsed_url.user), - password: CGI.unescape(parsed_url.password), + password: CGI.unescape(parsed_url.password) } response = WebHook.post(post_url, body: data.to_json, diff --git a/app/models/issue.rb b/app/models/issue.rb index 27e3ed9bc7f..a88dbb3e065 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -174,7 +174,7 @@ class Issue < ActiveRecord::Base # Returns boolean if a related branch exists for the current issue # ignores merge requests branchs - def has_related_branch? + def has_related_branch? project.repository.branch_names.any? do |branch| /\A#{iid}-(?!\d+-stable)/i =~ branch end @@ -292,7 +292,7 @@ class Issue < ActiveRecord::Base end def expire_etag_cache - key = Gitlab::Routing.url_helpers.rendered_title_namespace_project_issue_path( + key = Gitlab::Routing.url_helpers.realtime_changes_namespace_project_issue_path( project.namespace, project, self diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb index 0663d3aaef8..06d760b6a89 100644 --- a/app/models/issue_assignee.rb +++ b/app/models/issue_assignee.rb @@ -3,11 +3,4 @@ class IssueAssignee < ActiveRecord::Base belongs_to :issue belongs_to :assignee, class_name: "User", foreign_key: :user_id - - after_create :update_assignee_cache_counts - after_destroy :update_assignee_cache_counts - - def update_assignee_cache_counts - assignee&.update_cache_counts - end end diff --git a/app/models/key.rb b/app/models/key.rb index 9c74ca84753..b7956052c3f 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -74,7 +74,7 @@ class Key < ActiveRecord::Base GitlabShellWorker.perform_async( :remove_key, shell_id, - key, + key ) end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 35231bab12e..d7e7ae7a25f 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -13,6 +13,8 @@ class MergeRequest < ActiveRecord::Base has_one :merge_request_diff, -> { order('merge_request_diffs.id DESC') } + belongs_to :head_pipeline, foreign_key: "head_pipeline_id", class_name: "Ci::Pipeline" + has_many :events, as: :target, dependent: :destroy has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all @@ -123,7 +125,6 @@ class MergeRequest < ActiveRecord::Base participant :assignee after_save :keep_around_commit - after_save :update_assignee_cache_counts, if: :assignee_id_changed? def self.reference_prefix '!' @@ -185,13 +186,6 @@ class MergeRequest < ActiveRecord::Base work_in_progress?(title) ? title : "WIP: #{title}" end - def update_assignee_cache_counts - # make sure we flush the cache for both the old *and* new assignees(if they exist) - previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was - previous_assignee&.update_cache_counts - assignee&.update_cache_counts - end - # Returns a Hash of attributes to be used for Twitter card metadata def card_attributes { @@ -829,12 +823,6 @@ class MergeRequest < ActiveRecord::Base diverged_commits_count > 0 end - def head_pipeline - return unless diff_head_sha && source_project - - @head_pipeline ||= source_project.pipeline_for(source_branch, diff_head_sha) - end - def all_pipelines return Ci::Pipeline.none unless source_project @@ -864,7 +852,7 @@ class MergeRequest < ActiveRecord::Base end def can_be_cherry_picked? - merge_commit + merge_commit.present? end def has_complete_diff_refs? @@ -903,32 +891,6 @@ class MergeRequest < ActiveRecord::Base project.repository.keep_around(self.merge_commit_sha) end - def conflicts - @conflicts ||= Gitlab::Conflict::FileCollection.new(self) - end - - def conflicts_can_be_resolved_by?(user) - access = ::Gitlab::UserAccess.new(user, project: source_project) - access.can_push_to_branch?(source_branch) - end - - def conflicts_can_be_resolved_in_ui? - return @conflicts_can_be_resolved_in_ui if defined?(@conflicts_can_be_resolved_in_ui) - - return @conflicts_can_be_resolved_in_ui = false unless cannot_be_merged? - return @conflicts_can_be_resolved_in_ui = false unless has_complete_diff_refs? - - begin - # Try to parse each conflict. If the MR's mergeable status hasn't been updated, - # ensure that we don't say there are conflicts to resolve when there are no conflict - # files. - conflicts.files.each(&:lines) - @conflicts_can_be_resolved_in_ui = conflicts.files.length > 0 - rescue Rugged::OdbError, Gitlab::Conflict::Parser::UnresolvableError, Gitlab::Conflict::FileCollection::ConflictSideMissing - @conflicts_can_be_resolved_in_ui = false - end - end - def has_commits? merge_request_diff && commits_count > 0 end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 397dc7a25ab..a7ede5e3b9e 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -56,7 +56,7 @@ class Namespace < ActiveRecord::Base 'COALESCE(SUM(ps.storage_size), 0) AS storage_size', 'COALESCE(SUM(ps.repository_size), 0) AS repository_size', 'COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size', - 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size', + 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size' ) end diff --git a/app/models/project.rb b/app/models/project.rb index a0413b4e651..65745fd6d37 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -6,6 +6,7 @@ class Project < ActiveRecord::Base include Gitlab::VisibilityLevel include Gitlab::CurrentSettings include AccessRequestable + include Avatarable include CacheMarkdownField include Referable include Sortable @@ -174,7 +175,7 @@ class Project < ActiveRecord::Base has_many :builds, class_name: 'Ci::Build' # the builds are created from the commit_statuses has_many :runner_projects, dependent: :destroy, class_name: 'Ci::RunnerProject' has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' - has_many :variables, dependent: :destroy, class_name: 'Ci::Variable' + has_many :variables, class_name: 'Ci::Variable' has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger' has_many :environments, dependent: :destroy has_many :deployments, dependent: :destroy @@ -798,12 +799,10 @@ class Project < ActiveRecord::Base repository.avatar end - def avatar_url - if self[:avatar].present? - [gitlab_config.url, avatar.url].join - elsif avatar_in_git - Gitlab::Routing.url_helpers.namespace_project_avatar_url(namespace, self) - end + def avatar_url(**args) + # We use avatar_path instead of overriding avatar_url because of carrierwave. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864 + avatar_path(args) || (Gitlab::Routing.url_helpers.namespace_project_avatar_url(namespace, self) if avatar_in_git) end # For compatibility with old code @@ -968,7 +967,7 @@ class Project < ActiveRecord::Base namespace: namespace.name, visibility_level: visibility_level, path_with_namespace: path_with_namespace, - default_branch: default_branch, + default_branch: default_branch } # Backward compatibility diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb index 400020ee04a..3f5b3eb159b 100644 --- a/app/models/project_services/bamboo_service.rb +++ b/app/models/project_services/bamboo_service.rb @@ -52,7 +52,7 @@ class BambooService < CiService placeholder: 'Bamboo build plan key like KEY' }, { type: 'text', name: 'username', placeholder: 'A user with API access, if applicable' }, - { type: 'password', name: 'password' }, + { type: 'password', name: 'password' } ] end diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb index 47b68f00cff..3edc395033c 100644 --- a/app/models/project_services/chat_message/pipeline_message.rb +++ b/app/models/project_services/chat_message/pipeline_message.rb @@ -35,7 +35,7 @@ module ChatMessage def activity { - title: "Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status}", + title: "Pipeline #{pipeline_link} of #{ref_type} #{branch_link} by #{user_name} #{humanized_status}", subtitle: "in #{project_link}", text: "in #{pretty_duration(duration)}", image: user_avatar || '' @@ -45,7 +45,7 @@ module ChatMessage private def message - "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{pretty_duration(duration)}" + "#{project_link}: Pipeline #{pipeline_link} of #{ref_type} #{branch_link} by #{user_name} #{humanized_status} in #{pretty_duration(duration)}" end def humanized_status @@ -70,7 +70,7 @@ module ChatMessage end def branch_link - "[#{ref}](#{branch_url})" + "`[#{ref}](#{branch_url})`" end def project_link diff --git a/app/models/project_services/chat_message/push_message.rb b/app/models/project_services/chat_message/push_message.rb index c52dd6ef8ef..04a59d559ca 100644 --- a/app/models/project_services/chat_message/push_message.rb +++ b/app/models/project_services/chat_message/push_message.rb @@ -61,7 +61,7 @@ module ChatMessage end def removed_branch_message - "#{user_name} removed #{ref_type} #{ref} from #{project_link}" + "#{user_name} removed #{ref_type} `#{ref}` from #{project_link}" end def push_message @@ -102,7 +102,7 @@ module ChatMessage end def branch_link - "[#{ref}](#{branch_url})" + "`[#{ref}](#{branch_url})`" end def project_link diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index fa782c6fbb7..779ef54cfcb 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -39,7 +39,7 @@ class ChatNotificationService < Service { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, { type: 'text', name: 'username', placeholder: 'e.g. GitLab' }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'checkbox', name: 'notify_only_default_branch' }, + { type: 'checkbox', name: 'notify_only_default_branch' } ] end @@ -150,7 +150,7 @@ class ChatNotificationService < Service def notify_for_ref?(data) return true if data[:object_attributes][:tag] - return true unless notify_only_default_branch + return true unless notify_only_default_branch? data[:object_attributes][:ref] == project.default_branch end diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb index f4f913ee0b6..1a236e232f9 100644 --- a/app/models/project_services/emails_on_push_service.rb +++ b/app/models/project_services/emails_on_push_service.rb @@ -47,7 +47,7 @@ class EmailsOnPushService < Service help: "Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. #{domains})." }, { type: 'checkbox', name: 'disable_diffs', title: "Disable code diffs", help: "Don't include possibly sensitive code diffs in notification body." }, - { type: 'textarea', name: 'recipients', placeholder: 'Emails separated by whitespace' }, + { type: 'textarea', name: 'recipients', placeholder: 'Emails separated by whitespace' } ] end end diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb index bdf6fa6a586..b4d7c977ce4 100644 --- a/app/models/project_services/external_wiki_service.rb +++ b/app/models/project_services/external_wiki_service.rb @@ -19,7 +19,7 @@ class ExternalWikiService < Service def fields [ - { type: 'text', name: 'external_wiki_url', placeholder: 'The URL of the external Wiki' }, + { type: 'text', name: 'external_wiki_url', placeholder: 'The URL of the external Wiki' } ] end diff --git a/app/models/project_services/flowdock_service.rb b/app/models/project_services/flowdock_service.rb index 10a13c3fbdc..2a05d757eb4 100644 --- a/app/models/project_services/flowdock_service.rb +++ b/app/models/project_services/flowdock_service.rb @@ -37,7 +37,7 @@ class FlowdockService < Service repo: project.repository.path_to_repo, repo_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}", commit_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/commit/%s", - diff_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/compare/%s...%s", + diff_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/compare/%s...%s" ) end end diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb index 8b181221bb0..c19fed339ba 100644 --- a/app/models/project_services/hipchat_service.rb +++ b/app/models/project_services/hipchat_service.rb @@ -41,7 +41,7 @@ class HipchatService < Service placeholder: 'Leave blank for default (v2)' }, { type: 'text', name: 'server', placeholder: 'Leave blank for default. https://hipchat.example.com' }, - { type: 'checkbox', name: 'notify_only_broken_pipelines' }, + { type: 'checkbox', name: 'notify_only_broken_pipelines' } ] end diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb index c62bb4fa120..a51d43adcb9 100644 --- a/app/models/project_services/irker_service.rb +++ b/app/models/project_services/irker_service.rb @@ -58,7 +58,7 @@ class IrkerService < Service ' want to use a password, you have to omit the "#" on the channel). If you ' \ ' specify a default IRC URI to prepend before each recipient, you can just ' \ ' give a channel name.' }, - { type: 'checkbox', name: 'colorize_messages' }, + { type: 'checkbox', name: 'colorize_messages' } ] end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 97e997d3899..f388773efee 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -149,7 +149,7 @@ class JiraService < IssueTrackerService data = { user: { name: author.name, - url: resource_url(user_path(author)), + url: resource_url(user_path(author)) }, project: { name: self.project.path_with_namespace, diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index 9c56518c991..b2494a0be6e 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -73,7 +73,7 @@ class KubernetesService < DeploymentService { type: 'textarea', name: 'ca_pem', title: 'Custom CA bundle', - placeholder: 'Certificate Authority bundle (PEM format)' }, + placeholder: 'Certificate Authority bundle (PEM format)' } ] end diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb index 9b218fd81b4..2facff53e26 100644 --- a/app/models/project_services/microsoft_teams_service.rb +++ b/app/models/project_services/microsoft_teams_service.rb @@ -35,7 +35,7 @@ class MicrosoftTeamsService < ChatNotificationService [ { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'checkbox', name: 'notify_only_default_branch' }, + { type: 'checkbox', name: 'notify_only_default_branch' } ] end diff --git a/app/models/project_services/mock_ci_service.rb b/app/models/project_services/mock_ci_service.rb index a8d581a1f67..546b6e0a498 100644 --- a/app/models/project_services/mock_ci_service.rb +++ b/app/models/project_services/mock_ci_service.rb @@ -21,7 +21,7 @@ class MockCiService < CiService [ { type: 'text', name: 'mock_service_url', - placeholder: 'http://localhost:4004' }, + placeholder: 'http://localhost:4004' } ] end diff --git a/app/models/project_services/monitoring_service.rb b/app/models/project_services/monitoring_service.rb index ea585721e8f..ee9cd78327a 100644 --- a/app/models/project_services/monitoring_service.rb +++ b/app/models/project_services/monitoring_service.rb @@ -9,8 +9,11 @@ class MonitoringService < Service %w() end - # Environments have a number of metrics - def metrics(environment) + def environment_metrics(environment) + raise NotImplementedError + end + + def deployment_metrics(deployment) raise NotImplementedError end end diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb index ac617f409d9..f824171ad09 100644 --- a/app/models/project_services/pipelines_email_service.rb +++ b/app/models/project_services/pipelines_email_service.rb @@ -55,7 +55,7 @@ class PipelinesEmailService < Service name: 'recipients', placeholder: 'Emails separated by comma' }, { type: 'checkbox', - name: 'notify_only_broken_pipelines' }, + name: 'notify_only_broken_pipelines' } ] end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 6854d2243d7..ec72cb6856d 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -1,7 +1,6 @@ class PrometheusService < MonitoringService - include ReactiveCaching + include ReactiveService - self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] } self.reactive_cache_lease_timeout = 30.seconds self.reactive_cache_refresh_interval = 30.seconds self.reactive_cache_lifetime = 1.minute @@ -64,37 +63,31 @@ class PrometheusService < MonitoringService { success: false, result: err } end - def metrics(environment) - with_reactive_cache(environment.slug) do |data| - data - end + def environment_metrics(environment) + with_reactive_cache(Gitlab::Prometheus::Queries::EnvironmentQuery.name, environment.id, &:itself) + end + + def deployment_metrics(deployment) + metrics = with_reactive_cache(Gitlab::Prometheus::Queries::DeploymentQuery.name, deployment.id, &:itself) + metrics&.merge(deployment_time: created_at.to_i) || {} end # Cache metrics for specific environment - def calculate_reactive_cache(environment_slug) + def calculate_reactive_cache(query_class_name, *args) return unless active? && project && !project.pending_delete? - memory_query = %{(sum(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}) / count(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"})) /1024/1024} - cpu_query = %{sum(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) / count(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}) * 100} + metrics = Kernel.const_get(query_class_name).new(client).query(*args) { success: true, - metrics: { - # Average Memory used in MB - memory_values: client.query_range(memory_query, start: 8.hours.ago), - memory_current: client.query(memory_query), - # Average CPU Utilization - cpu_values: client.query_range(cpu_query, start: 8.hours.ago), - cpu_current: client.query(cpu_query) - }, + metrics: metrics, last_update: Time.now.utc } - rescue Gitlab::PrometheusError => err { success: false, result: err.message } end def client - @prometheus ||= Gitlab::Prometheus.new(api_url: api_url) + @prometheus ||= Gitlab::PrometheusClient.new(api_url: api_url) end end diff --git a/app/models/project_services/pushover_service.rb b/app/models/project_services/pushover_service.rb index 3e618a8dbf1..fc29a5277bb 100644 --- a/app/models/project_services/pushover_service.rb +++ b/app/models/project_services/pushover_service.rb @@ -55,7 +55,7 @@ class PushoverService < Service ['Pushover Echo (long)', 'echo'], ['Up Down (long)', 'updown'], ['None (silent)', 'none'] - ] }, + ] } ] end diff --git a/app/models/project_services/teamcity_service.rb b/app/models/project_services/teamcity_service.rb index cbaffb8ce48..b16beb406b9 100644 --- a/app/models/project_services/teamcity_service.rb +++ b/app/models/project_services/teamcity_service.rb @@ -55,7 +55,7 @@ class TeamcityService < CiService placeholder: 'Build configuration ID' }, { type: 'text', name: 'username', placeholder: 'A user with permissions to trigger a manual build' }, - { type: 'password', name: 'password' }, + { type: 'password', name: 'password' } ] end @@ -78,7 +78,7 @@ class TeamcityService < CiService auth = { username: username, - password: password, + password: password } branch = Gitlab::Git.ref_name(data[:ref]) diff --git a/app/models/protected_branch/merge_access_level.rb b/app/models/protected_branch/merge_access_level.rb index 771e3376613..e8d35ac326f 100644 --- a/app/models/protected_branch/merge_access_level.rb +++ b/app/models/protected_branch/merge_access_level.rb @@ -1,13 +1,3 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base include ProtectedBranchAccess - - validates :access_level, presence: true, inclusion: { in: [Gitlab::Access::MASTER, - Gitlab::Access::DEVELOPER] } - - def self.human_access_levels - { - Gitlab::Access::MASTER => "Masters", - Gitlab::Access::DEVELOPER => "Developers + Masters" - }.with_indifferent_access - end end diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb index 14610cb42b7..7a2e9e5ec5d 100644 --- a/app/models/protected_branch/push_access_level.rb +++ b/app/models/protected_branch/push_access_level.rb @@ -1,21 +1,3 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base include ProtectedBranchAccess - - validates :access_level, presence: true, inclusion: { in: [Gitlab::Access::MASTER, - Gitlab::Access::DEVELOPER, - Gitlab::Access::NO_ACCESS] } - - def self.human_access_levels - { - Gitlab::Access::MASTER => "Masters", - Gitlab::Access::DEVELOPER => "Developers + Masters", - Gitlab::Access::NO_ACCESS => "No one" - }.with_indifferent_access - end - - def check_access(user) - return false if access_level == Gitlab::Access::NO_ACCESS - - super - end end diff --git a/app/models/readme_blob.rb b/app/models/readme_blob.rb new file mode 100644 index 00000000000..1863a08f1de --- /dev/null +++ b/app/models/readme_blob.rb @@ -0,0 +1,13 @@ +class ReadmeBlob < SimpleDelegator + attr_reader :repository + + def initialize(blob, repository) + @repository = repository + + super(blob) + end + + def rendered_markup + repository.rendered_readme + end +end diff --git a/app/models/repository.rb b/app/models/repository.rb index 0c797dd5814..9153e5ae5a8 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -30,7 +30,7 @@ class Repository METHOD_CACHES_FOR_FILE_TYPES = { readme: :rendered_readme, changelog: :changelog, - license: %i(license_blob license_key), + license: %i(license_blob license_key license), contributing: :contribution_guide, gitignore: :gitignore, koding: :koding_yml, @@ -42,13 +42,13 @@ class Repository # variable. # # This only works for methods that do not take any arguments. - def self.cache_method(name, fallback: nil) + def self.cache_method(name, fallback: nil, memoize_only: false) original = :"_uncached_#{name}" alias_method(original, name) define_method(name) do - cache_method_output(name, fallback: fallback) { __send__(original) } + cache_method_output(name, fallback: fallback, memoize_only: memoize_only) { __send__(original) } end end @@ -517,8 +517,8 @@ class Repository cache_method :avatar def readme - if head = tree(:head) - head.readme + if readme = tree(:head)&.readme + ReadmeBlob.new(readme, self) end end @@ -549,6 +549,13 @@ class Repository end cache_method :license_key + def license + return unless license_key + + Licensee::License.new(license_key) + end + cache_method :license, memoize_only: true + def gitignore file_on_head(:gitignore) end @@ -833,7 +840,7 @@ class Repository actual_options = options.merge( parents: [our_commit, their_commit], - tree: merge_index.write_tree(rugged), + tree: merge_index.write_tree(rugged) ) commit_id = create_commit(actual_options) @@ -1061,14 +1068,20 @@ class Repository # # key - The name of the key to cache the data in. # fallback - A value to fall back to in the event of a Git error. - def cache_method_output(key, fallback: nil, &block) + def cache_method_output(key, fallback: nil, memoize_only: false, &block) ivar = cache_instance_variable_name(key) if instance_variable_defined?(ivar) instance_variable_get(ivar) else begin - instance_variable_set(ivar, cache.fetch(key, &block)) + value = + if memoize_only + yield + else + cache.fetch(key, &block) + end + instance_variable_set(ivar, value) rescue Rugged::ReferenceError, Gitlab::Git::Repository::NoRepository # if e.g. HEAD or the entire repository doesn't exist we want to # gracefully handle this and not cache anything. @@ -1083,8 +1096,8 @@ class Repository def file_on_head(type) if head = tree(:head) - head.blobs.find do |file| - Gitlab::FileDetector.type_of(file.name) == type + head.blobs.find do |blob| + Gitlab::FileDetector.type_of(blob.path) == type end end end @@ -1150,8 +1163,6 @@ class Repository @project.repository_storage_path end - delegate :gitaly_channel, :gitaly_repository, to: :raw_repository - def initialize_raw_repository Gitlab::Git::Repository.new(project.repository_storage, path_with_namespace + '.git') end diff --git a/app/models/route.rb b/app/models/route.rb index 12a7fa3d01b..be77b8b51a5 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -35,7 +35,7 @@ class Route < ActiveRecord::Base old_path = route.path # Callbacks must be run manually - route.update_columns(attributes) + route.update_columns(attributes.merge(updated_at: Time.now)) # We are not calling route.delete_conflicting_redirects here, in hopes # of avoiding deadlocks. The parent (self, in this method) already diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index bfaf0eb2fae..0ae5864615a 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -39,7 +39,7 @@ class SentNotification < ActiveRecord::Base noteable_type: noteable.class.name, noteable_id: noteable_id, - commit_id: commit_id, + commit_id: commit_id ) create(attrs) diff --git a/app/models/snippet.rb b/app/models/snippet.rb index abfbefdf9a0..882e2fa0594 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -152,18 +152,5 @@ class Snippet < ActiveRecord::Base where(table[:content].matches(pattern)) end - - def accessible_to(user) - return are_public unless user.present? - return all if user.admin? - - where( - 'visibility_level IN (:visibility_levels) - OR author_id = :author_id - OR project_id IN (:project_ids)', - visibility_levels: [Snippet::PUBLIC, Snippet::INTERNAL], - author_id: user.id, - project_ids: user.authorized_projects.select(:id)) - end end end diff --git a/app/models/tree.rb b/app/models/tree.rb index fe148b0ec65..c89b8eca9be 100644 --- a/app/models/tree.rb +++ b/app/models/tree.rb @@ -40,10 +40,7 @@ class Tree readme_path = path == '/' ? readme_tree.name : File.join(path, readme_tree.name) - git_repo = repository.raw_repository - @readme = Gitlab::Git::Blob.find(git_repo, sha, readme_path) - @readme.load_all_data!(git_repo) - @readme + @readme = repository.blob_at(sha, readme_path) end def trees diff --git a/app/models/user.rb b/app/models/user.rb index accaa91b805..c7160a6af14 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -5,6 +5,7 @@ class User < ActiveRecord::Base include Gitlab::ConfigHelper include Gitlab::CurrentSettings + include Avatarable include Referable include Sortable include CaseSensitivity @@ -40,6 +41,17 @@ class User < ActiveRecord::Base devise :lockable, :recoverable, :rememberable, :trackable, :validatable, :omniauthable, :confirmable, :registerable + # Override Devise::Models::Trackable#update_tracked_fields! + # to limit database writes to at most once every hour + def update_tracked_fields!(request) + update_tracked_fields(request) + + lease = Gitlab::ExclusiveLease.new("user_update_tracked_fields:#{id}", timeout: 1.hour.to_i) + return unless lease.try_obtain + + save(validate: false) + end + attr_accessor :force_random_password # Virtual attribute for authenticating by either username or email @@ -773,12 +785,10 @@ class User < ActiveRecord::Base email.start_with?('temp-email-for-oauth') end - def avatar_url(size = nil, scale = 2) - if self[:avatar].present? - [gitlab_config.url, avatar.url].join - else - GravatarService.new.execute(email, size, scale) - end + def avatar_url(size: nil, scale: 2, **args) + # We use avatar_path instead of overriding avatar_url because of carrierwave. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864 + avatar_path(args) || GravatarService.new.execute(email, size, scale) end def all_emails @@ -919,6 +929,11 @@ class User < ActiveRecord::Base assigned_open_issues_count(force: true) end + def invalidate_cache_counts + Rails.cache.delete(['users', id, 'assigned_open_merge_requests_count']) + Rails.cache.delete(['users', id, 'assigned_open_issues_count']) + end + def todos_done_count(force: false) Rails.cache.fetch(['users', id, 'todos_done_count'], force: force) do TodosFinder.new(self, state: :done).execute.count @@ -1000,6 +1015,15 @@ class User < ActiveRecord::Base devise_mailer.send(notification, self, *args).deliver_later end + # This works around a bug in Devise 4.2.0 that erroneously causes a user to + # be considered active in MySQL specs due to a sub-second comparison + # issue. For more details, see: https://gitlab.com/gitlab-org/gitlab-ee/issues/2362#note_29004709 + def confirmation_period_valid? + return false if self.class.allow_unconfirmed_access_for == 0.days + + super + end + def ensure_external_user_rights return unless external? diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb index 3a96836917e..cf8ff92617f 100644 --- a/app/policies/project_snippet_policy.rb +++ b/app/policies/project_snippet_policy.rb @@ -13,7 +13,7 @@ class ProjectSnippetPolicy < BasePolicy can! :read_project_snippet end - if @subject.private? && @subject.project.team.member?(@user) + if @subject.project.team.member?(@user) can! :read_project_snippet end end diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb new file mode 100644 index 00000000000..0db9e31031c --- /dev/null +++ b/app/presenters/merge_request_presenter.rb @@ -0,0 +1,172 @@ +class MergeRequestPresenter < Gitlab::View::Presenter::Delegated + include ActionView::Helpers::UrlHelper + include GitlabRoutingHelper + include MarkupHelper + include TreeHelper + + presents :merge_request + + def ci_status + if pipeline + status = pipeline.status + status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings? + + status || "preparing" + else + ci_service = source_project.try(:ci_service) + ci_service&.commit_status(diff_head_sha, source_branch) + end + end + + def cancel_merge_when_pipeline_succeeds_path + if can_cancel_merge_when_pipeline_succeeds?(current_user) + cancel_merge_when_pipeline_succeeds_namespace_project_merge_request_path( + project.namespace, + project, + merge_request) + end + end + + def create_issue_to_resolve_discussions_path + if can?(current_user, :create_issue, project) && project.issues_enabled? + new_namespace_project_issue_path(project.namespace, + project, + merge_request_to_resolve_discussions_of: iid) + end + end + + def remove_wip_path + if can?(current_user, :update_merge_request, merge_request.project) + remove_wip_namespace_project_merge_request_path(project.namespace, project, merge_request) + end + end + + def merge_path + if can_be_merged_by?(current_user) + merge_namespace_project_merge_request_path(project.namespace, project, merge_request) + end + end + + def revert_in_fork_path + if user_can_fork_project? && can_be_reverted?(current_user) + continue_params = { + to: merge_request_path(merge_request), + notice: "#{edit_in_new_fork_notice} Try to cherry-pick this commit again.", + notice_now: edit_in_new_fork_notice_now + } + + namespace_project_forks_path(merge_request.project.namespace, merge_request.project, + namespace_key: current_user.namespace.id, + continue: continue_params) + end + end + + def cherry_pick_in_fork_path + if user_can_fork_project? && can_be_cherry_picked? + continue_params = { + to: merge_request_path(merge_request), + notice: "#{edit_in_new_fork_notice} Try to revert this commit again.", + notice_now: edit_in_new_fork_notice_now + } + + namespace_project_forks_path(project.namespace, project, + namespace_key: current_user.namespace.id, + continue: continue_params) + end + end + + def conflict_resolution_path + if conflicts.can_be_resolved_in_ui? && conflicts.can_be_resolved_by?(current_user) + conflicts_namespace_project_merge_request_path(project.namespace, project, merge_request) + end + end + + def target_branch_commits_path + if target_branch_exists? + namespace_project_commits_path(project.namespace, project, target_branch) + end + end + + def source_branch_path + if source_branch_exists? + namespace_project_branch_path(source_project.namespace, source_project, source_branch) + end + end + + def source_branch_with_namespace_link + namespace = source_project_namespace + branch = source_branch + + if source_branch_exists? + namespace = link_to(namespace, project_path(source_project)) + branch = link_to(branch, namespace_project_commits_path(source_project.namespace, source_project, source_branch)) + end + + if for_fork? + namespace + ":" + branch + else + branch + end + end + + def closing_issues_links + markdown issues_sentence(project, closing_issues), pipeline: :gfm, author: author, project: project + end + + def mentioned_issues_links + mentioned_issues = issues_mentioned_but_not_closing(current_user) + markdown issues_sentence(project, mentioned_issues), pipeline: :gfm, author: author, project: project + end + + def assign_to_closing_issues_link + issues = MergeRequests::AssignIssuesService.new(project, + current_user, + merge_request: merge_request, + closes_issues: closing_issues + ).assignable_issues + path = assign_related_issues_namespace_project_merge_request_path(project.namespace, project, merge_request) + if issues.present? + pluralize_this_issue = issues.count > 1 ? "these issues" : "this issue" + link_to "Assign yourself to #{pluralize_this_issue}", path, method: :post + end + end + + def can_revert_on_current_merge_request? + user_can_collaborate_with_project? && can_be_reverted?(current_user) + end + + def can_cherry_pick_on_current_merge_request? + user_can_collaborate_with_project? && can_be_cherry_picked? + end + + private + + def conflicts + @conflicts ||= MergeRequests::Conflicts::ListService.new(merge_request) + end + + def closing_issues + @closing_issues ||= closes_issues(current_user) + end + + def pipeline + @pipeline ||= head_pipeline + end + + def issues_sentence(project, issues) + # Sorting based on the `#123` or `group/project#123` reference will sort + # local issues first. + issues.map do |issue| + issue.to_reference(project) + end.sort.to_sentence + end + + def user_can_collaborate_with_project? + can?(current_user, :push_code, project) || + (current_user && current_user.already_forked?(project)) + end + + def user_can_fork_project? + can?(current_user, :fork_project, project) + end +end diff --git a/app/serializers/base_serializer.rb b/app/serializers/base_serializer.rb index 311ee9c96be..4e6c15f673b 100644 --- a/app/serializers/base_serializer.rb +++ b/app/serializers/base_serializer.rb @@ -3,8 +3,10 @@ class BaseSerializer @request = EntityRequest.new(parameters) end - def represent(resource, opts = {}) - self.class.entity_class + def represent(resource, opts = {}, entity_class = nil) + entity_class = entity_class || self.class.entity_class + + entity_class .represent(resource, opts.merge(request: @request)) .as_json end diff --git a/app/serializers/build_action_entity.rb b/app/serializers/build_action_entity.rb index 75dda1af709..5e99204c658 100644 --- a/app/serializers/build_action_entity.rb +++ b/app/serializers/build_action_entity.rb @@ -19,6 +19,6 @@ class BuildActionEntity < Grape::Entity alias_method :build, :object def playable? - build.playable? && can?(request.user, :update_build, build) + build.playable? && can?(request.current_user, :update_build, build) end end diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb index 1380b347d8e..e2276808b90 100644 --- a/app/serializers/build_entity.rb +++ b/app/serializers/build_entity.rb @@ -26,11 +26,11 @@ class BuildEntity < Grape::Entity alias_method :build, :object def playable? - build.playable? && can?(request.user, :update_build, build) + build.playable? && can?(request.current_user, :update_build, build) end def detailed_status - build.detailed_status(request.user) + build.detailed_status(request.current_user) end def path_to(route, build) diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index 4ff15a78115..4e8a3c67b21 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -31,7 +31,7 @@ class EnvironmentEntity < Grape::Entity end expose :terminal_path, if: ->(environment, _) { environment.has_terminals? } do |environment| - can?(request.user, :admin_environment, environment.project) && + can?(request.current_user, :admin_environment, environment.project) && terminal_namespace_project_environment_path( environment.project.namespace, environment.project, diff --git a/app/serializers/event_entity.rb b/app/serializers/event_entity.rb new file mode 100644 index 00000000000..935d67a4f37 --- /dev/null +++ b/app/serializers/event_entity.rb @@ -0,0 +1,4 @@ +class EventEntity < Grape::Entity + expose :author, using: UserEntity + expose :updated_at +end diff --git a/app/serializers/job_group_entity.rb b/app/serializers/job_group_entity.rb index a4d3737429c..04487e59009 100644 --- a/app/serializers/job_group_entity.rb +++ b/app/serializers/job_group_entity.rb @@ -11,6 +11,6 @@ class JobGroupEntity < Grape::Entity alias_method :group, :object def detailed_status - group.detailed_status(request.user) + group.detailed_status(request.current_user) end end diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb new file mode 100644 index 00000000000..8771345c135 --- /dev/null +++ b/app/serializers/merge_request_basic_entity.rb @@ -0,0 +1,10 @@ +class MergeRequestBasicEntity < Grape::Entity + 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_basic_serializer.rb b/app/serializers/merge_request_basic_serializer.rb new file mode 100644 index 00000000000..cc5c664c8fa --- /dev/null +++ b/app/serializers/merge_request_basic_serializer.rb @@ -0,0 +1,3 @@ +class MergeRequestBasicSerializer < BaseSerializer + entity MergeRequestBasicEntity +end diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb index 453ba52b892..a2542c54f7a 100644 --- a/app/serializers/merge_request_entity.rb +++ b/app/serializers/merge_request_entity.rb @@ -1,4 +1,6 @@ class MergeRequestEntity < IssuableEntity + include RequestAwareEntity + expose :assignee_id expose :in_progress_merge_commit_sha expose :locked_at @@ -12,4 +14,174 @@ class MergeRequestEntity < IssuableEntity expose :source_project_id expose :target_branch expose :target_project_id + + # Events + expose :merge_event, using: EventEntity + expose :closed_event, using: EventEntity + + # User entities + expose :author, using: UserEntity + expose :merge_user, using: UserEntity + + # Diff sha's + expose :diff_head_sha do |merge_request| + merge_request.diff_head_sha if merge_request.diff_head_commit + end + + expose :merge_commit_sha + expose :merge_commit_message + expose :head_pipeline, with: PipelineEntity, as: :pipeline + + # Booleans + expose :work_in_progress?, as: :work_in_progress + expose :source_branch_exists?, as: :source_branch_exists + expose :mergeable_discussions_state?, as: :mergeable_discussions_state + expose :branch_missing?, as: :branch_missing + expose :commits_count + expose :cannot_be_merged?, as: :has_conflicts + expose :can_be_merged?, as: :can_be_merged + + expose :project_archived do |merge_request| + merge_request.project.archived? + end + + expose :only_allow_merge_if_pipeline_succeeds do |merge_request| + merge_request.project.only_allow_merge_if_pipeline_succeeds? + end + + # CI related + expose :has_ci?, as: :has_ci + expose :ci_status do |merge_request| + presenter(merge_request).ci_status + end + + expose :issues_links do + expose :assign_to_closing do |merge_request| + presenter(merge_request).assign_to_closing_issues_link + end + + expose :closing do |merge_request| + presenter(merge_request).closing_issues_links + end + + expose :mentioned_but_not_closing do |merge_request| + presenter(merge_request).mentioned_issues_links + end + end + + expose :source_branch_with_namespace_link do |merge_request| + presenter(merge_request).source_branch_with_namespace_link + end + + expose :source_branch_path do |merge_request| + presenter(merge_request).source_branch_path + end + + expose :current_user do + expose :can_remove_source_branch do |merge_request| + merge_request.source_branch_exists? && merge_request.can_remove_source_branch?(current_user) + end + + expose :can_revert_on_current_merge_request do |merge_request| + presenter(merge_request).can_revert_on_current_merge_request? + end + + expose :can_cherry_pick_on_current_merge_request do |merge_request| + presenter(merge_request).can_cherry_pick_on_current_merge_request? + end + end + + # Paths + # + expose :target_branch_commits_path do |merge_request| + presenter(merge_request).target_branch_commits_path + end + + expose :conflict_resolution_path do |merge_request| + presenter(merge_request).conflict_resolution_path + end + + expose :remove_wip_path do |merge_request| + presenter(merge_request).remove_wip_path + end + + expose :cancel_merge_when_pipeline_succeeds_path do |merge_request| + presenter(merge_request).cancel_merge_when_pipeline_succeeds_path + end + + expose :create_issue_to_resolve_discussions_path do |merge_request| + presenter(merge_request).create_issue_to_resolve_discussions_path + end + + expose :merge_path do |merge_request| + presenter(merge_request).merge_path + end + + expose :cherry_pick_in_fork_path do |merge_request| + presenter(merge_request).cherry_pick_in_fork_path + end + + expose :revert_in_fork_path do |merge_request| + presenter(merge_request).revert_in_fork_path + end + + expose :email_patches_path do |merge_request| + namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request, + format: :patch) + end + + expose :plain_diff_path do |merge_request| + namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request, + format: :diff) + end + + expose :status_path do |merge_request| + namespace_project_merge_request_path(merge_request.target_project.namespace, + merge_request.target_project, + merge_request, + format: :json) + end + + expose :merge_check_path do |merge_request| + merge_check_namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request) + end + + expose :ci_environments_status_path do |merge_request| + ci_environments_status_namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request) + end + + expose :merge_commit_message_with_description do |merge_request| + merge_request.merge_commit_message(include_description: true) + end + + expose :diverged_commits_count do |merge_request| + if merge_request.open? && merge_request.diverged_from_target_branch? + merge_request.diverged_commits_count + else + 0 + end + end + + expose :commit_change_content_path do |merge_request| + commit_change_content_namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request) + end + + private + + delegate :current_user, to: :request + + def presenter(merge_request) + @presenters ||= {} + @presenters[merge_request] ||= MergeRequestPresenter.new(merge_request, current_user: current_user) + end end diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index aa6e00dfcb4..f67034ce47a 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -1,3 +1,9 @@ class MergeRequestSerializer < BaseSerializer - entity MergeRequestEntity + # This overrided method takes care of which entity should be used + # 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 + super(merge_request, opts, entity) + end end diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index 7eb7aac72eb..ea57cc97a7e 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -3,6 +3,8 @@ class PipelineEntity < Grape::Entity expose :id expose :user, using: UserEntity + expose :active?, as: :active + expose :coverage expose :path do |pipeline| namespace_project_pipeline_path( @@ -36,10 +38,7 @@ class PipelineEntity < Grape::Entity expose :path do |pipeline| if pipeline.ref - namespace_project_tree_path( - pipeline.project.namespace, - pipeline.project, - id: pipeline.ref) + project_ref_path(pipeline.project, pipeline.ref) end end @@ -69,16 +68,16 @@ class PipelineEntity < Grape::Entity alias_method :pipeline, :object def can_retry? - can?(request.user, :update_pipeline, pipeline) && + can?(request.current_user, :update_pipeline, pipeline) && pipeline.retryable? end def can_cancel? - can?(request.user, :update_pipeline, pipeline) && + can?(request.current_user, :update_pipeline, pipeline) && pipeline.cancelable? end def detailed_status - pipeline.detailed_status(request.user) + pipeline.detailed_status(request.current_user) end end diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index e7a9df8ac4e..e37af63774c 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -37,4 +37,11 @@ class PipelineSerializer < BaseSerializer data = represent(resource, { only: [{ details: [:status] }] }) data.dig(:details, :status) || {} end + + def represent_stages(resource) + return {} unless resource.present? + + data = represent(resource, { only: [{ details: [:stages] }] }) + data.dig(:details, :stages) || [] + end end diff --git a/app/serializers/request_aware_entity.rb b/app/serializers/request_aware_entity.rb index 3039014aaaa..d53fcfb8c1b 100644 --- a/app/serializers/request_aware_entity.rb +++ b/app/serializers/request_aware_entity.rb @@ -3,6 +3,7 @@ module RequestAwareEntity included do include Gitlab::Routing + include GitlabRoutingHelper include Gitlab::Allowable end diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb index 97ced8730ed..cee0089056f 100644 --- a/app/serializers/stage_entity.rb +++ b/app/serializers/stage_entity.rb @@ -35,6 +35,6 @@ class StageEntity < Grape::Entity alias_method :stage, :object def detailed_status - stage.detailed_status(request.user) + stage.detailed_status(request.current_user) end end diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb index 76b9f1feda7..8e11a2a36a7 100644 --- a/app/services/akismet_service.rb +++ b/app/services/akismet_service.rb @@ -16,7 +16,7 @@ class AkismetService created_at: DateTime.now, author: owner.name, author_email: owner.email, - referrer: options[:referrer], + referrer: options[:referrer] } begin diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb index 8a000585e89..5ad9a50687c 100644 --- a/app/services/audit_event_service.rb +++ b/app/services/audit_event_service.rb @@ -8,7 +8,7 @@ class AuditEventService with: @details[:with], target_id: @author.id, target_type: 'User', - target_details: @author.name, + target_details: @author.name } self diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb index e73b1a4361a..ecabb2a48e4 100644 --- a/app/services/boards/issues/move_service.rb +++ b/app/services/boards/issues/move_service.rb @@ -38,7 +38,7 @@ module Boards attrs.merge!( add_label_ids: add_label_ids, remove_label_ids: remove_label_ids, - state_event: issue_state, + state_event: issue_state ) end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index ccdda08d885..1f6c1f4a7f6 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -47,7 +47,7 @@ module Ci end Ci::Pipeline.transaction do - pipeline.save + update_merge_requests_head_pipeline if pipeline.save Ci::CreatePipelineBuildsService .new(project, current_user) @@ -118,6 +118,11 @@ module Ci origin_sha && origin_sha != Gitlab::Git::BLANK_SHA end + def update_merge_requests_head_pipeline + MergeRequest.where(source_branch: @pipeline.ref, source_project: @pipeline.project). + update_all(head_pipeline_id: @pipeline.id) + end + def error(message, save: false) pipeline.errors.add(:base, message) pipeline.drop if save diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index 33edcd60944..55af193d717 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -5,6 +5,8 @@ module Ci def execute(pipeline) @pipeline = pipeline + update_retried + new_builds = stage_indexes_of_created_builds.map do |index| process_stage(index) @@ -50,7 +52,7 @@ module Ci when 'always' %w[success failed skipped] when 'manual' - %w[success] + %w[success skipped] else [] end @@ -71,5 +73,23 @@ module Ci def created_builds pipeline.builds.created end + + # This method is for compatibility and data consistency and should be removed with 9.3 version of GitLab + # This replicates what is db/post_migrate/20170416103934_upate_retried_for_ci_build.rb + # and ensures that functionality will not be broken before migration is run + # this updates only when there are data that needs to be updated, there are two groups with no retried flag + def update_retried + # find the latest builds for each name + latest_statuses = pipeline.statuses.latest + .group(:name) + .having('count(*) > 1') + .pluck('max(id)', 'name') + + # mark builds that are retried + pipeline.statuses.latest + .where(name: latest_statuses.map(&:second)) + .where.not(id: latest_statuses.map(&:first)) + .update_all(retried: true) if latest_statuses.any? + end end end diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index 89da05b72bb..f51e9fd1d54 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -6,7 +6,7 @@ module Ci description tag_list].freeze def execute(build) - reprocess(build).tap do |new_build| + reprocess!(build).tap do |new_build| build.pipeline.mark_as_processable_after_stage(build.stage_idx) new_build.enqueue! @@ -17,7 +17,7 @@ module Ci end end - def reprocess(build) + def reprocess!(build) unless can?(current_user, :update_build, build) raise Gitlab::Access::AccessDeniedError end @@ -28,7 +28,14 @@ module Ci attributes.push([:user, current_user]) - project.builds.create(Hash[attributes]) + Ci::Build.transaction do + # mark all other builds of that name as retried + build.pipeline.builds.latest + .where(name: build.name) + .update_all(retried: true) + + project.builds.create!(Hash[attributes]) + end end end end diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb index 5b207157345..c5a43869990 100644 --- a/app/services/ci/retry_pipeline_service.rb +++ b/app/services/ci/retry_pipeline_service.rb @@ -11,7 +11,7 @@ module Ci next unless can?(current_user, :update_build, build) Ci::RetryBuildService.new(project, current_user) - .reprocess(build) + .reprocess!(build) end pipeline.builds.latest.skipped.find_each do |skipped| diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index 45411c779cc..d22236b961b 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -67,7 +67,7 @@ class GitPushService < BaseService paths = Set.new @push_commits.each do |commit| - commit.raw_diffs(deltas_only: true).each do |diff| + commit.raw_deltas.each do |diff| paths << diff.new_path end end @@ -85,8 +85,10 @@ class GitPushService < BaseService default = is_default_branch? push_commits.last(PROCESS_COMMIT_LIMIT).each do |commit| - ProcessCommitWorker. - perform_async(project.id, current_user.id, commit.to_hash, default) + if commit.matches_cross_reference_regex? + ProcessCommitWorker. + perform_async(project.id, current_user.id, commit.to_hash, default) + end end end diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb index 40ff9b8b867..5d42a89fced 100644 --- a/app/services/issuable/bulk_update_service.rb +++ b/app/services/issuable/bulk_update_service.rb @@ -7,7 +7,7 @@ module Issuable ids = params.delete(:issuable_ids).split(",") items = model_class.where(id: ids) - %i(state_event milestone_id assignee_id assignee_ids add_label_ids remove_label_ids subscription_event).each do |key| + permitted_attrs(type).each do |key| params.delete(key) unless params[key].present? end @@ -26,5 +26,17 @@ module Issuable success: !items.count.zero? } end + + private + + def permitted_attrs(type) + attrs = %i(state_event milestone_id assignee_id assignee_ids add_label_ids remove_label_ids subscription_event) + + if type == 'issue' + attrs.push(:assignee_ids) + else + attrs.push(:assignee_id) + end + end end end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index c1e532b504a..dc2ab99b982 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -178,6 +178,7 @@ class IssuableBaseService < BaseService after_create(issuable) issuable.create_cross_references!(current_user) execute_hooks(issuable) + issuable.assignees.each(&:invalidate_cache_counts) end issuable @@ -234,6 +235,11 @@ class IssuableBaseService < BaseService old_assignees: old_assignees ) + if old_assignees != issuable.assignees + assignees = old_assignees + issuable.assignees.to_a + assignees.compact.each(&:invalidate_cache_counts) + end + after_update(issuable) issuable.create_new_cross_references!(current_user) execute_hooks(issuable, 'update') diff --git a/app/services/members/authorized_destroy_service.rb b/app/services/members/authorized_destroy_service.rb index a85b9465c84..7912cac65d3 100644 --- a/app/services/members/authorized_destroy_service.rb +++ b/app/services/members/authorized_destroy_service.rb @@ -43,8 +43,9 @@ module Members ) project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil) - member.user.update_cache_counts end + + member.user.invalidate_cache_counts end end end diff --git a/app/services/merge_requests/conflicts/base_service.rb b/app/services/merge_requests/conflicts/base_service.rb new file mode 100644 index 00000000000..b50875347d9 --- /dev/null +++ b/app/services/merge_requests/conflicts/base_service.rb @@ -0,0 +1,11 @@ +module MergeRequests + module Conflicts + class BaseService + attr_reader :merge_request + + def initialize(merge_request) + @merge_request = merge_request + end + end + end +end diff --git a/app/services/merge_requests/conflicts/list_service.rb b/app/services/merge_requests/conflicts/list_service.rb new file mode 100644 index 00000000000..9bf82518643 --- /dev/null +++ b/app/services/merge_requests/conflicts/list_service.rb @@ -0,0 +1,35 @@ +module MergeRequests + module Conflicts + class ListService < MergeRequests::Conflicts::BaseService + delegate :file_for_path, :to_json, to: :conflicts + + def can_be_resolved_by?(user) + return false unless merge_request.source_project + + access = ::Gitlab::UserAccess.new(user, project: merge_request.source_project) + access.can_push_to_branch?(merge_request.source_branch) + end + + def can_be_resolved_in_ui? + return @conflicts_can_be_resolved_in_ui if defined?(@conflicts_can_be_resolved_in_ui) + + return @conflicts_can_be_resolved_in_ui = false unless merge_request.cannot_be_merged? + return @conflicts_can_be_resolved_in_ui = false unless merge_request.has_complete_diff_refs? + + begin + # Try to parse each conflict. If the MR's mergeable status hasn't been + # updated, ensure that we don't say there are conflicts to resolve + # when there are no conflict files. + conflicts.files.each(&:lines) + @conflicts_can_be_resolved_in_ui = conflicts.files.length > 0 + rescue Rugged::OdbError, Gitlab::Conflict::Parser::UnresolvableError, Gitlab::Conflict::FileCollection::ConflictSideMissing + @conflicts_can_be_resolved_in_ui = false + end + end + + def conflicts + @conflicts ||= Gitlab::Conflict::FileCollection.read_only(merge_request) + end + end + end +end diff --git a/app/services/merge_requests/conflicts/resolve_service.rb b/app/services/merge_requests/conflicts/resolve_service.rb new file mode 100644 index 00000000000..d74a82effd6 --- /dev/null +++ b/app/services/merge_requests/conflicts/resolve_service.rb @@ -0,0 +1,53 @@ +module MergeRequests + module Conflicts + class ResolveService < MergeRequests::Conflicts::BaseService + MissingFiles = Class.new(Gitlab::Conflict::ResolutionError) + + def execute(current_user, params) + rugged = merge_request.source_project.repository.rugged + + Gitlab::Conflict::FileCollection.for_resolution(merge_request) do |conflicts_for_resolution| + merge_index = conflicts_for_resolution.merge_index + + params[:files].each do |file_params| + conflict_file = conflicts_for_resolution.file_for_path(file_params[:old_path], file_params[:new_path]) + + write_resolved_file_to_index(merge_index, rugged, conflict_file, file_params) + end + + unless merge_index.conflicts.empty? + missing_files = merge_index.conflicts.map { |file| file[:ours][:path] } + + raise MissingFiles, "Missing resolutions for the following files: #{missing_files.join(', ')}" + end + + commit_params = { + message: params[:commit_message] || conflicts_for_resolution.default_commit_message, + parents: [conflicts_for_resolution.our_commit, conflicts_for_resolution.their_commit].map(&:oid), + tree: merge_index.write_tree(rugged) + } + + conflicts_for_resolution. + project. + repository. + resolve_conflicts(current_user, merge_request.source_branch, commit_params) + end + end + + private + + def write_resolved_file_to_index(merge_index, rugged, file, params) + new_file = if params[:sections] + file.resolve_lines(params[:sections]).map(&:text).join("\n") + elsif params[:content] + file.resolve_content(params[:content]) + end + + our_path = file.our_path + + merge_index.add(path: our_path, oid: rugged.write(new_file, :blob), mode: file.our_mode) + merge_index.conflict_remove(our_path) + end + end + end +end diff --git a/app/services/merge_requests/resolve_service.rb b/app/services/merge_requests/resolve_service.rb deleted file mode 100644 index 82cd89d9a0b..00000000000 --- a/app/services/merge_requests/resolve_service.rb +++ /dev/null @@ -1,65 +0,0 @@ -module MergeRequests - class ResolveService < MergeRequests::BaseService - MissingFiles = Class.new(Gitlab::Conflict::ResolutionError) - - attr_accessor :conflicts, :rugged, :merge_index, :merge_request - - def execute(merge_request) - @conflicts = merge_request.conflicts - @rugged = project.repository.rugged - @merge_index = conflicts.merge_index - @merge_request = merge_request - - fetch_their_commit! - - params[:files].each do |file_params| - conflict_file = merge_request.conflicts.file_for_path(file_params[:old_path], file_params[:new_path]) - - write_resolved_file_to_index(conflict_file, file_params) - end - - unless merge_index.conflicts.empty? - missing_files = merge_index.conflicts.map { |file| file[:ours][:path] } - - raise MissingFiles, "Missing resolutions for the following files: #{missing_files.join(', ')}" - end - - commit_params = { - message: params[:commit_message] || conflicts.default_commit_message, - parents: [conflicts.our_commit, conflicts.their_commit].map(&:oid), - tree: merge_index.write_tree(rugged) - } - - project.repository.resolve_conflicts(current_user, merge_request.source_branch, commit_params) - end - - def write_resolved_file_to_index(file, params) - new_file = if params[:sections] - file.resolve_lines(params[:sections]).map(&:text).join("\n") - elsif params[:content] - file.resolve_content(params[:content]) - end - - our_path = file.our_path - - merge_index.add(path: our_path, oid: rugged.write(new_file, :blob), mode: file.our_mode) - merge_index.conflict_remove(our_path) - end - - # If their commit (in the target project) doesn't exist in the source project, it - # can't be a parent for the merge commit we're about to create. If that's the case, - # fetch the target branch ref into the source project so the commit exists in both. - # - def fetch_their_commit! - return if rugged.include?(conflicts.their_commit.oid) - - random_string = SecureRandom.hex - - project.repository.fetch_ref( - merge_request.target_project.repository.path_to_repo, - "refs/heads/#{merge_request.target_branch}", - "refs/tmp/#{random_string}/head" - ) - end - end -end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index c65c66d7150..646ccbdb2bf 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -298,7 +298,7 @@ class NotificationService recipients ||= NotificationRecipientService.new(pipeline.project).build_pipeline_recipients( pipeline, pipeline.user, - action: pipeline.status, + action: pipeline.status ).map(&:notification_email) if recipients.any? diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb index eb4809afa85..cacb74b1205 100644 --- a/app/services/projects/update_pages_configuration_service.rb +++ b/app/services/projects/update_pages_configuration_service.rb @@ -27,7 +27,7 @@ module Projects { domain: domain.domain, certificate: domain.certificate, - key: domain.key, + key: domain.key } end end diff --git a/app/services/search/snippet_service.rb b/app/services/search/snippet_service.rb index 4f161beea4d..85da0be6fff 100644 --- a/app/services/search/snippet_service.rb +++ b/app/services/search/snippet_service.rb @@ -7,7 +7,7 @@ module Search end def execute - snippets = Snippet.accessible_to(current_user) + snippets = SnippetsFinder.new(current_user).execute Gitlab::SnippetSearchResults.new(snippets, params[:search]) end diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb index af0ddbe5934..ed476fc9d0c 100644 --- a/app/services/system_hooks_service.rb +++ b/app/services/system_hooks_service.rb @@ -51,7 +51,7 @@ class SystemHooksService 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, + owner_email: owner.respond_to?(:email) ? owner.email : nil ) when GroupMember data.merge!(group_member_data(model)) @@ -113,7 +113,7 @@ class SystemHooksService user_name: model.user.name, user_email: model.user.email, user_id: model.user.id, - group_access: model.human_access, + group_access: model.human_access } end end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 174e7c6e95b..93bf1fb1615 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -79,7 +79,7 @@ module SystemNoteService text_parts.join(' and ') elsif old_assignees.any? - "removed all assignees" + "removed assignee" elsif issue.assignees.any? "assigned to #{issue.assignees.map(&:to_reference).to_sentence}" end @@ -291,8 +291,8 @@ module SystemNoteService old_diffs, new_diffs = Gitlab::Diff::InlineDiff.new(old_title, new_title).inline_diffs - marked_old_title = Gitlab::Diff::InlineDiffMarker.new(old_title).mark(old_diffs, mode: :deletion, markdown: true) - marked_new_title = Gitlab::Diff::InlineDiffMarker.new(new_title).mark(new_diffs, mode: :addition, markdown: true) + marked_old_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(old_title).mark(old_diffs, mode: :deletion) + marked_new_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(new_title).mark(new_diffs, mode: :addition) body = "changed title from **#{marked_old_title}** to **#{marked_new_title}**" diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 4b6628169ef..e1b4e34cd2b 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -502,17 +502,24 @@ Let GitLab inform you when an update is available. .form-group .col-sm-offset-2.col-sm-10 + - can_be_configured = @application_setting.usage_ping_can_be_configured? .checkbox = f.label :usage_ping_enabled do - = f.check_box :usage_ping_enabled + = f.check_box :usage_ping_enabled, disabled: !can_be_configured Usage ping enabled - = link_to icon('question-circle'), help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-data") + = link_to icon('question-circle'), help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-ping") .help-block - Every week GitLab will report license usage back to GitLab, Inc. - Disable this option if you do not want this to occur. To see the - JSON payload that will be sent, visit the - = succeed '.' do - = link_to "Cohorts page", admin_cohorts_path(anchor: 'usage-ping') + - if can_be_configured + Every week GitLab will report license usage back to GitLab, Inc. + Disable this option if you do not want this to occur. To see the + JSON payload that will be sent, visit the + = succeed '.' do + = link_to "Cohorts page", admin_cohorts_path(anchor: 'usage-ping') + - else + The usage ping is disabled, and cannot be configured through this + form. For more information, see the documentation on + = succeed '.' do + = link_to 'deactivating the usage ping', help_page_path('user/admin_area/settings/usage_statistics', anchor: 'deactivate-the-usage-ping') %fieldset %legend Email diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml index 6217d5fb135..645005c6deb 100644 --- a/app/views/admin/hooks/_form.html.haml +++ b/app/views/admin/hooks/_form.html.haml @@ -18,19 +18,26 @@ or adding ssh key. But you can also enable extra triggers like Push events. .prepend-top-default + = form.check_box :repository_update_events, class: 'pull-left' + .prepend-left-20 + = form.label :repository_update_events, class: 'list-label' do + %strong Repository update events + %p.light + This URL will be triggered when repository is updated + %div = form.check_box :push_events, class: 'pull-left' .prepend-left-20 = form.label :push_events, class: 'list-label' do %strong Push events %p.light - This url will be triggered by a push to the repository + This URL will be triggered for each branch updated to the repository %div = form.check_box :tag_push_events, class: 'pull-left' .prepend-left-20 = form.label :tag_push_events, class: 'list-label' do %strong Tag push events %p.light - This url will be triggered when a new tag is pushed to the repository + This URL will be triggered when a new tag is pushed to the repository .form-group = form.label :enable_ssl_verification, 'SSL verification', class: 'control-label checkbox' .col-sm-10 diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml index 71117758921..3338b677bf5 100644 --- a/app/views/admin/hooks/index.html.haml +++ b/app/views/admin/hooks/index.html.haml @@ -27,7 +27,7 @@ = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm' .monospace= hook.url %div - - %w(push_events tag_push_events issues_events note_events merge_requests_events build_events).each do |trigger| + - %w(repository_update_events push_events tag_push_events issues_events note_events merge_requests_events build_events).each do |trigger| - if hook.send(trigger) %span.label.label-gray= trigger.titleize %span.label.label-gray SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'} diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml index 5e189e6dc54..eb0e6701627 100644 --- a/app/views/devise/passwords/edit.html.haml +++ b/app/views/devise/passwords/edit.html.haml @@ -6,10 +6,10 @@ = devise_error_messages! = f.hidden_field :reset_password_token .form-group - = f.label 'New password', for: :password + = f.label 'New password', for: "user_password" = f.password_field :password, class: "form-control top", required: true, title: 'This field is required' .form-group - = f.label 'Confirm new password', for: :password_confirmation + = f.label 'Confirm new password', for: "user_password_confirmation" = f.password_field :password_confirmation, class: "form-control bottom", title: 'This field is required', required: true .clearfix = f.submit "Change your password", class: "btn btn-primary" diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index 21c751a23f8..4095f30c369 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -1,6 +1,6 @@ = form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive'}) do |f| .form-group - = f.label "Username or email", for: :login + = f.label "Username or email", for: "user_login" = f.text_field :login, class: "form-control top", autofocus: "autofocus", autocapitalize: "off", autocorrect: "off", required: true, title: "This field is required." .form-group = f.label :password diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index 78c5b0c1dda..c3f55ff821f 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -3,7 +3,7 @@ .diff-file.file-holder .js-file-title.file-title - = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_file.content_commit, project: discussion.project, url: discussion_path(discussion) + = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_file.content_commit, project: discussion.project, url: discussion_path(discussion), show_toggle: false .diff-content.code.js-syntax-highlight %table diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 38e85168f40..74992e439f3 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -26,7 +26,7 @@ - commit = discussion.noteable - if commit commit - = link_to commit.short_id, url, class: 'monospace' + = link_to commit.short_id, url, class: 'commit-sha' - else a deleted commit - elsif discussion.diff_discussion? diff --git a/app/views/events/_commit.html.haml b/app/views/events/_commit.html.haml index 1bc9f604438..3c64f1be5ff 100644 --- a/app/views/events/_commit.html.haml +++ b/app/views/events/_commit.html.haml @@ -1,5 +1,5 @@ %li.commit .commit-row-title - = link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit_short_id", alt: '', title: truncate_sha(commit[:id]) + = link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit-sha", alt: '', title: truncate_sha(commit[:id]) · = markdown event_commit_title(commit[:message]), project: project, pipeline: :single_line, author: event.author diff --git a/app/views/import/base/create.js.haml b/app/views/import/base/create.js.haml index 8e929538351..57e8c3ca1e1 100644 --- a/app/views/import/base/create.js.haml +++ b/app/views/import/base/create.js.haml @@ -10,4 +10,4 @@ - else :plain job = $("tr#repo_#{@repo_id}") - job.find(".import-actions").html("<i class='fa fa-exclamation-circle'></i> Error saving project: #{escape_javascript(@project.errors.full_messages.join(','))}") + job.find(".import-actions").html("<i class='fa fa-exclamation-circle'></i> Error saving project: #{escape_javascript(h(@project.errors.full_messages.join(',')))}") diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml index 9999a4362c6..c52a515226e 100644 --- a/app/views/import/fogbugz/new_user_map.html.haml +++ b/app/views/import/fogbugz/new_user_map.html.haml @@ -46,6 +46,3 @@ .form-actions = submit_tag 'Continue to the next step', class: 'btn btn-create' - -:javascript - new UsersSelect(); diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 0e64ebd71b8..b689991bb6d 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -13,7 +13,7 @@ .location-badge= label .search-input-wrap .dropdown{ data: { url: search_autocomplete_path } } - = search_field_tag 'search', nil, placeholder: 'Search', class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', spellcheck: false, tabindex: '1', autocomplete: 'off', data: { toggle: 'dropdown', issues_path: issues_dashboard_url, mr_path: merge_requests_dashboard_url } + = search_field_tag 'search', nil, placeholder: 'Search', class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', spellcheck: false, tabindex: '1', autocomplete: 'off', data: { toggle: 'dropdown', issues_path: issues_dashboard_url, mr_path: merge_requests_dashboard_url }, aria: { label: 'Search' } .dropdown-menu.dropdown-select = dropdown_content do %ul diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 659d548df18..9db98451f1d 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -1,4 +1,5 @@ %header.navbar.navbar-gitlab{ class: nav_header_class } + .navbar-border %a.sr-only.gl-accessibility{ href: "#content-body", tabindex: "1" } Skip to content .container-fluid .header-content diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml index e06301bda14..ae1e1361f0f 100644 --- a/app/views/layouts/nav/_profile.html.haml +++ b/app/views/layouts/nav/_profile.html.haml @@ -48,6 +48,6 @@ %span Preferences = nav_link(path: 'profiles#audit_log') do - = link_to audit_log_profile_path, title: 'Audit Log' do + = link_to audit_log_profile_path, title: 'Authentication log' do %span - Audit Log + Authentication log diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index cdcac7e4264..e4dfe0c8c08 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -35,7 +35,7 @@ = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests', class: 'shortcuts-merge_requests' do %span Merge Requests - %span.badge.count.merge_counter= number_with_delimiter(MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.count) + %span.badge.count.merge_counter.js-merge-counter= number_with_delimiter(MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.count) - if project_nav_tab? :pipelines = nav_link(controller: [:pipelines, :builds, :environments, :artifacts]) do diff --git a/app/views/notify/_reassigned_issuable_email.html.haml b/app/views/notify/_reassigned_issuable_email.html.haml deleted file mode 100644 index fd35713f79c..00000000000 --- a/app/views/notify/_reassigned_issuable_email.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -%p - Assignee changed - - if @previous_assignee - from - %strong= @previous_assignee.name - to - - if issuable.assignee_id - %strong= issuable.assignee_name - - else - %strong Unassigned diff --git a/app/views/notify/reassigned_merge_request_email.html.haml b/app/views/notify/reassigned_merge_request_email.html.haml index 841df872857..24c2b08810b 100644 --- a/app/views/notify/reassigned_merge_request_email.html.haml +++ b/app/views/notify/reassigned_merge_request_email.html.haml @@ -1,9 +1,10 @@ -Reassigned Merge Request #{ @merge_request.iid } - -= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) - -Assignee changed -- if @previous_assignee - from #{@previous_assignee.name} -to -= @merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned' +%p + Assignee changed + - if @previous_assignee + from + %strong= @previous_assignee.name + to + - if @merge_request.assignee_id + %strong= @merge_request.assignee_name + - else + %strong Unassigned diff --git a/app/views/profiles/_event_table.html.haml b/app/views/profiles/_event_table.html.haml index 879fc170f92..d0ad90ac6cc 100644 --- a/app/views/profiles/_event_table.html.haml +++ b/app/views/profiles/_event_table.html.haml @@ -9,7 +9,6 @@ Signed in with = event.details[:with] authentication - %span.pull-right - #{time_ago_in_words event.created_at} ago + %span.pull-right= time_ago_with_tooltip(event.created_at) = paginate events, theme: "gitlab" diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml index 9fe86e6b291..a24b7fd101d 100644 --- a/app/views/profiles/audit_log.html.haml +++ b/app/views/profiles/audit_log.html.haml @@ -1,4 +1,4 @@ -- page_title "Audit Log" +- page_title "Authentication log" = render 'profiles/head' .row.prepend-top-default diff --git a/app/views/projects/_last_commit.html.haml b/app/views/projects/_last_commit.html.haml index df3b1c75508..d104cc7c1a3 100644 --- a/app/views/projects/_last_commit.html.haml +++ b/app/views/projects/_last_commit.html.haml @@ -5,7 +5,7 @@ = ci_icon_for_status(status) = ci_text_for_status(status) -= link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit_short_id" += link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-sha" = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit), class: "commit-row-message" · #{time_ago_with_tooltip(commit.committed_date)} by diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml index 768bc1fb323..f8a6e98d280 100644 --- a/app/views/projects/_last_push.html.haml +++ b/app/views/projects/_last_push.html.haml @@ -5,7 +5,7 @@ .event-last-push .event-last-push-text %span You pushed to - = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name) do + = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name, class: 'commit-sha') do %strong= event.ref_name - if @project && event.project != @project %span at diff --git a/app/views/projects/_readme.html.haml b/app/views/projects/_readme.html.haml index c0d12cbc66e..cf09d9db6b7 100644 --- a/app/views/projects/_readme.html.haml +++ b/app/views/projects/_readme.html.haml @@ -2,9 +2,9 @@ %article.readme-holder .pull-right - if can?(current_user, :push_code, @project) - = link_to icon('pencil'), namespace_project_edit_blob_path(@project.namespace, @project, tree_join(@repository.root_ref, readme.name)), class: 'light edit-project-readme' - .file-content.wiki - = markup(readme.name, readme.data, rendered: @repository.rendered_readme) + = link_to icon('pencil'), namespace_project_edit_blob_path(@project.namespace, @project, tree_join(@repository.root_ref, readme.path)), class: 'light edit-project-readme' + + = render 'projects/blob/viewer', viewer: readme.rich_viewer, viewer_url: namespace_project_blob_path(@project.namespace, @project, tree_join(@repository.root_ref, readme.path), viewer: :rich, format: :json) - else .row-content-block.second-block.center %h3.page-title diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml index 0c8241053e7..3b3d08ddd3c 100644 --- a/app/views/projects/_zen.html.haml +++ b/app/views/projects/_zen.html.haml @@ -1,10 +1,11 @@ - @gfm_form = true +- current_text ||= nil - supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false) .zen-backdrop - classes << ' js-gfm-input js-autosize markdown-area' - if defined?(f) && f = f.text_area attr, class: classes, placeholder: placeholder, data: { supports_slash_commands: supports_slash_commands } - else - = text_area_tag attr, nil, class: classes, placeholder: placeholder + = text_area_tag attr, current_text, class: classes, placeholder: placeholder %a.zen-control.zen-control-leave.js-zen-leave{ href: "#" } = icon('compress') diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index 35885b2c7b4..a2ec3d44185 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -3,9 +3,9 @@ = render "projects/commits/head" %div{ class: container_class } - %h3.page-title Blame view - #blob-content-holder.tree-holder + = render "projects/blob/breadcrumb", blob: @blob, blame: true + .file-holder = render "projects/blob/header", blob: @blob, blame: true @@ -22,7 +22,7 @@ %strong = link_to_gfm truncate(commit.title, length: 35), namespace_project_commit_path(@project.namespace, @project, commit.id), class: "cdark" .pull-right - = link_to commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit), class: "monospace" + = link_to commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit), class: "commit-sha" .light = commit_author_link(commit, avatar: false) diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index f04df441ccb..8af945ddb2c 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -1,23 +1,15 @@ -.nav-block - .tree-ref-holder - = render 'shared/ref_switcher', destination: 'blob', path: @path += render "projects/blob/breadcrumb", blob: blob - %ul.breadcrumb.repo-breadcrumb - %li - = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do - = @project.path - - path_breadcrumbs do |title, path| - - title = truncate(title, length: 40) - %li - - if path == @path - = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, path)) do - %strong= title - - else - = link_to title, namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path)) +.info-well.hidden-xs + .well-segment + %ul.blob-commit-info + - blob_commit = @repository.last_commit_for_path(@commit.id, blob.path) + = render blob_commit, project: @project, ref: @ref -%ul.blob-commit-info.hidden-xs - - blob_commit = @repository.last_commit_for_path(@commit.id, blob.path) - = render blob_commit, project: @project, ref: @ref + - auxiliary_viewer = blob.auxiliary_viewer + - if auxiliary_viewer && !auxiliary_viewer.render_error + .well-segment.blob-auxiliary-viewer + = render 'projects/blob/viewer', viewer: auxiliary_viewer #blob-content-holder.blob-content-holder %article.file-holder diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml new file mode 100644 index 00000000000..3f58e8d232f --- /dev/null +++ b/app/views/projects/blob/_breadcrumb.html.haml @@ -0,0 +1,36 @@ +- blame = local_assigns.fetch(:blame, false) +.nav-block + .tree-controls + = render 'projects/find_file_link' + + .btn-group.prepend-left-10{ role: "group" }< + -# only show normal/blame view links for text files + - if blob.readable_text? + - if blame + = link_to 'Normal view', namespace_project_blob_path(@project.namespace, @project, @id), + class: 'btn' + - else + = link_to 'Blame', namespace_project_blame_path(@project.namespace, @project, @id), + class: 'btn js-blob-blame-link' unless blob.empty? + + = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), + class: 'btn' + + = link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project, + tree_join(@commit.sha, @path)), class: 'btn js-data-file-blob-permalink-url' + + .tree-ref-holder + = render 'shared/ref_switcher', destination: 'blob', path: @path + + %ul.breadcrumb.repo-breadcrumb + %li + = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do + = @project.path + - path_breadcrumbs do |title, path| + - title = truncate(title, length: 40) + %li + - if path == @path + = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, path)) do + %strong= title + - else + = link_to title, namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path)) diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index cd098acda81..0be15cc179f 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -11,23 +11,7 @@ = view_on_environment_button(@commit.sha, @path, @environment) if @environment .btn-group{ role: "group" }< - -# only show normal/blame view links for text files - - if blob.readable_text? - - if blame - = link_to 'Normal view', namespace_project_blob_path(@project.namespace, @project, @id), - class: 'btn btn-sm' - - else - = link_to 'Blame', namespace_project_blame_path(@project.namespace, @project, @id), - class: 'btn btn-sm js-blob-blame-link' unless blob.empty? - - = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), - class: 'btn btn-sm' - - = link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project, - tree_join(@commit.sha, @path)), class: 'btn btn-sm js-data-file-blob-permalink-url' - - .btn-group{ role: "group" }< - = edit_blob_link if blob.readable_text? + = edit_blob_link - if current_user = replace_blob_link = delete_blob_link diff --git a/app/views/projects/blob/_markup.html.haml b/app/views/projects/blob/_markup.html.haml deleted file mode 100644 index 0090f7a11df..00000000000 --- a/app/views/projects/blob/_markup.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -- blob.load_all_data!(@repository) - -.file-content.wiki - = markup(blob.name, blob.data) diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml index 5326bb3e0cf..3d9c3a59980 100644 --- a/app/views/projects/blob/_viewer.html.haml +++ b/app/views/projects/blob/_viewer.html.haml @@ -2,11 +2,10 @@ - render_error = viewer.render_error - load_asynchronously = local_assigns.fetch(:load_asynchronously, viewer.server_side?) && render_error.nil? -- url = url_for(params.merge(viewer: viewer.type, format: :json)) if load_asynchronously -.blob-viewer{ data: { type: viewer.type, url: url }, class: ('hidden' if hidden) } +- viewer_url = local_assigns.fetch(:viewer_url) { url_for(params.merge(viewer: viewer.type, format: :json)) } if load_asynchronously +.blob-viewer{ data: { type: viewer.type, url: viewer_url }, class: ('hidden' if hidden) } - if load_asynchronously - .text-center.prepend-top-default.append-bottom-default - = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content') + = render viewer.loading_partial_path, viewer: viewer - elsif render_error = render 'projects/blob/render_error', viewer: viewer - else diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml index e87b73c9a34..da2cef17e8a 100644 --- a/app/views/projects/blob/preview.html.haml +++ b/app/views/projects/blob/preview.html.haml @@ -1,4 +1,4 @@ -.diff-file +.diff-file.file-holder .diff-content - if markup?(@blob.name) .file-content.wiki diff --git a/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml b/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml new file mode 100644 index 00000000000..28c5be6ebf3 --- /dev/null +++ b/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml @@ -0,0 +1,9 @@ +- if viewer.valid? + = icon('check fw') + This GitLab CI configuration is valid. +- else + = icon('warning fw') + This GitLab CI configuration is invalid: + = viewer.validation_message + += link_to 'Learn more', help_page_path('ci/yaml/README') diff --git a/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml b/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml new file mode 100644 index 00000000000..10cbf6a2f7a --- /dev/null +++ b/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml @@ -0,0 +1,4 @@ += icon('spinner spin fw') +Validating GitLab CI configuration… + += link_to 'Learn more', help_page_path('ci/yaml/README') diff --git a/app/views/projects/blob/viewers/_license.html.haml b/app/views/projects/blob/viewers/_license.html.haml new file mode 100644 index 00000000000..9a79d164692 --- /dev/null +++ b/app/views/projects/blob/viewers/_license.html.haml @@ -0,0 +1,8 @@ +- license = viewer.license + += icon('balance-scale fw') +This project is licensed under the += succeed '.' do + %strong= license.name + += link_to 'Learn more about this license', license.url, target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/blob/viewers/_loading.html.haml b/app/views/projects/blob/viewers/_loading.html.haml new file mode 100644 index 00000000000..120c0540335 --- /dev/null +++ b/app/views/projects/blob/viewers/_loading.html.haml @@ -0,0 +1,2 @@ +.text-center.prepend-top-default.append-bottom-default + = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content…') diff --git a/app/views/projects/blob/viewers/_loading_auxiliary.html.haml b/app/views/projects/blob/viewers/_loading_auxiliary.html.haml new file mode 100644 index 00000000000..058c74bce0d --- /dev/null +++ b/app/views/projects/blob/viewers/_loading_auxiliary.html.haml @@ -0,0 +1,2 @@ += icon('spinner spin fw') +Loading… diff --git a/app/views/projects/blob/viewers/_route_map.html.haml b/app/views/projects/blob/viewers/_route_map.html.haml new file mode 100644 index 00000000000..d0fcd55f6c1 --- /dev/null +++ b/app/views/projects/blob/viewers/_route_map.html.haml @@ -0,0 +1,9 @@ +- if viewer.valid? + = icon('check fw') + This Route Map is valid. +- else + = icon('warning fw') + This Route Map is invalid: + = viewer.validation_message + += link_to 'Learn more', help_page_path('ci/environments', anchor: 'route-map') diff --git a/app/views/projects/blob/viewers/_route_map_loading.html.haml b/app/views/projects/blob/viewers/_route_map_loading.html.haml new file mode 100644 index 00000000000..2318cf82f58 --- /dev/null +++ b/app/views/projects/blob/viewers/_route_map_loading.html.haml @@ -0,0 +1,4 @@ += icon('spinner spin fw') +Validating Route Map… + += link_to 'Learn more', help_page_path('ci/environments', anchor: 'route-map') diff --git a/app/views/projects/boards/_show.html.haml b/app/views/projects/boards/_show.html.haml index 7ca0ec8ed2b..efec69662f3 100644 --- a/app/views/projects/boards/_show.html.haml +++ b/app/views/projects/boards/_show.html.haml @@ -3,9 +3,9 @@ - page_title "Boards" - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('common_vue') - = page_specific_javascript_bundle_tag('filtered_search') - = page_specific_javascript_bundle_tag('boards') + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'filtered_search' + = webpack_bundle_tag 'boards' %script#js-board-template{ type: "text/x-template" }= render "projects/boards/components/board" %script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal diff --git a/app/views/projects/boards/components/sidebar/_labels.html.haml b/app/views/projects/boards/components/sidebar/_labels.html.haml index 0f0a84c156d..bee0f3dd065 100644 --- a/app/views/projects/boards/components/sidebar/_labels.html.haml +++ b/app/views/projects/boards/components/sidebar/_labels.html.haml @@ -19,7 +19,7 @@ ":value" => "label.id" } .dropdown %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button", - data: { toggle: "dropdown", field_name: "issue[label_names][]", show_no: "true", show_any: "true", project_id: @project.id, labels: namespace_project_labels_path(@project.namespace, @project, :json), namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path) }, + data: { toggle: "dropdown", field_name: "issue[label_names][]", show_no: "true", show_any: "true", project_id: @project.id, labels: namespace_project_labels_path(@project.namespace, @project, :json), namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path) }, ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" } %span.dropdown-toggle-text Label diff --git a/app/views/projects/boards/components/sidebar/_milestone.html.haml b/app/views/projects/boards/components/sidebar/_milestone.html.haml index 190e7290303..4e46351bf8a 100644 --- a/app/views/projects/boards/components/sidebar/_milestone.html.haml +++ b/app/views/projects/boards/components/sidebar/_milestone.html.haml @@ -16,7 +16,8 @@ name: "issue[milestone_id]", "v-if" => "issue.milestone" } .dropdown - %button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: "issue", use_id: "true" }, + %button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: "issue", use_id: "true", default_no: "true" }, + ":data-selected" => "milestoneTitle", ":data-issuable-id" => "issue.id", ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" } Milestone diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 8b35a037c55..304c512e1b5 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -6,7 +6,8 @@ - merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project)) %li{ class: "js-branch-#{branch.name}" } %div - = link_to namespace_project_tree_path(@project.namespace, @project, branch.name), class: 'item-title str-truncated' do + = link_to namespace_project_tree_path(@project.namespace, @project, branch.name), class: 'item-title str-truncated ref-name' do + = icon('code-fork') = branch.name - if branch.name == @repository.root_ref diff --git a/app/views/projects/branches/_commit.html.haml b/app/views/projects/branches/_commit.html.haml index de607772df6..ad8f9da0621 100644 --- a/app/views/projects/branches/_commit.html.haml +++ b/app/views/projects/branches/_commit.html.haml @@ -1,7 +1,7 @@ .branch-commit .icon-container.commit-icon = custom_icon("icon_commit") - = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-id monospace" + = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-sha" · %span.str-truncated = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message" diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml index 796ecdfd014..5a0eba3551f 100644 --- a/app/views/projects/branches/new.html.haml +++ b/app/views/projects/branches/new.html.haml @@ -17,11 +17,13 @@ .help-block.text-danger.js-branch-name-error .form-group = label_tag :ref, 'Create from', class: 'control-label' - .col-sm-10.dropdown.create-from - = hidden_field_tag :ref, default_ref - = button_tag type: 'button', title: default_ref, class: 'dropdown-toggle form-control js-branch-select', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do - .text-left.dropdown-toggle-text= default_ref - = render 'shared/ref_dropdown', dropdown_class: 'wide' + .col-sm-10.create-from + .dropdown + = hidden_field_tag :ref, default_ref + = button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide form-control js-branch-select git-revision-dropdown-toggle', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do + .text-left.dropdown-toggle-text= default_ref + = icon('chevron-down') + = render 'shared/ref_dropdown', dropdown_class: 'wide' .help-block Existing branch name, tag, or commit SHA .form-actions = button_tag 'Create branch', class: 'btn btn-create', tabindex: 3 diff --git a/app/views/projects/builds/_header.html.haml b/app/views/projects/builds/_header.html.haml index a0f8f105d9a..d4cdb709b97 100644 --- a/app/views/projects/builds/_header.html.haml +++ b/app/views/projects/builds/_header.html.haml @@ -6,18 +6,16 @@ = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false, title: @build.status_title %strong Job - = link_to namespace_project_build_path(@project.namespace, @project, @build), class: 'js-build-id' do - \##{@build.id} + = link_to "##{@build.id}", namespace_project_build_path(@project.namespace, @project, @build), class: 'js-build-id' in pipeline - = link_to pipeline_path(pipeline) do - %strong ##{pipeline.id} - for commit - = link_to namespace_project_commit_path(@project.namespace, @project, pipeline.sha) do - %strong= pipeline.short_sha + %strong + = link_to "##{pipeline.id}", pipeline_path(pipeline) + for + %strong + = link_to pipeline.short_sha, namespace_project_commit_path(@project.namespace, @project, pipeline.sha), class: 'commit-sha' from - = link_to namespace_project_commits_path(@project.namespace, @project, @build.ref) do - %code - = @build.ref + %strong + = link_to @build.ref, project_ref_path(@project, @build.ref), class: 'ref-name' = render "projects/builds/user" if @build.user diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index 43191fae9e6..8032d81cd91 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -1,6 +1,6 @@ - builds = @build.pipeline.builds.to_a -%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "153", "spy" => "affix" } } +%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } } .block.build-sidebar-header.visible-xs-block.visible-sm-block.append-bottom-default Job %strong ##{@build.id} @@ -136,7 +136,7 @@ - else = build.id - if build.retried? - %i.fa.fa-spinner.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' } + %i.fa.fa-refresh.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' } :javascript new Sidebar(); diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index c0019996176..e796920ac82 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -23,14 +23,14 @@ - if job.ref .icon-container = job.tag? ? icon('tag') : icon('code-fork') - = link_to job.ref, namespace_project_commits_path(job.project.namespace, job.project, job.ref), class: "monospace branch-name" + = link_to job.ref, project_ref_path(job.project, job.ref), class: "ref-name" - else .light none .icon-container.commit-icon = custom_icon("icon_commit") - if commit_sha - = link_to job.short_sha, namespace_project_commit_path(job.project.namespace, job.project, job.sha), class: "commit-id monospace" + = link_to job.short_sha, namespace_project_commit_path(job.project.namespace, job.project, job.sha), class: "commit-sha" - if job.stuck? = icon('warning', class: 'text-warning has-tooltip', title: 'Job is stuck. Check runners.') @@ -58,7 +58,7 @@ - if pipeline.user = user_avatar(user: pipeline.user, size: 20) - else - %span.monospace API + %span.api API - if admin %td diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 64adb70cb81..0aef5822f81 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -1,6 +1,8 @@ .page-content-header .header-main-content - %strong Commit #{@commit.short_id} + %strong + Commit + %span.commit-sha= @commit.short_id = clipboard_button(text: @commit.id, title: "Copy commit SHA to clipboard") %span.hidden-xs authored #{time_ago_with_tooltip(@commit.authored_date)} @@ -57,7 +59,7 @@ = custom_icon("icon_commit") %span.cgray= pluralize(@commit.parents.count, "parent") - @commit.parents.each do |parent| - = link_to parent.short_id, namespace_project_commit_path(@project.namespace, @project, parent), class: "monospace" + = link_to parent.short_id, namespace_project_commit_path(@project.namespace, @project, parent), class: "commit-sha" %span.commit-info.branches %i.fa.fa-spinner.fa-spin @@ -68,9 +70,10 @@ = link_to namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id) do = ci_icon_for_status(last_pipeline.status) Pipeline - = link_to "##{last_pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id), class: "monospace" + = link_to "##{last_pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id) = ci_label_for_status(last_pipeline.status) - if last_pipeline.stages.any? + with #{"stage".pluralize(last_pipeline.stages.count)} .mr-widget-pipeline-graph = render 'shared/mini_pipeline_graph', pipeline: last_pipeline, klass: 'js-commit-pipeline-graph' in diff --git a/app/views/projects/commit/_pipeline.html.haml b/app/views/projects/commit/_pipeline.html.haml deleted file mode 100644 index 3ee85723ebe..00000000000 --- a/app/views/projects/commit/_pipeline.html.haml +++ /dev/null @@ -1,52 +0,0 @@ -.pipeline-graph-container - .row-content-block.build-content.middle-block.pipeline-actions - .pull-right - - if can?(current_user, :update_pipeline, pipeline.project) - - if pipeline.builds.latest.failed.any?(&:retryable?) - = link_to "Retry", retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'js-retry-button btn btn-grouped btn-primary', method: :post - - - if pipeline.builds.running_or_pending.any? - = link_to "Cancel running", cancel_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), data: { confirm: 'Are you sure?' }, class: 'btn btn-grouped btn-danger', method: :post - - .oneline.clearfix - - if defined?(pipeline_details) && pipeline_details - Pipeline - = link_to "##{pipeline.id}", namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: "monospace" - with - = pluralize pipeline.statuses.count(:id), "job" - - if pipeline.ref - for - = link_to pipeline.ref, namespace_project_commits_path(pipeline.project.namespace, pipeline.project, pipeline.ref), class: "monospace" - - if defined?(link_to_commit) && link_to_commit - for commit - = link_to pipeline.short_sha, namespace_project_commit_path(pipeline.project.namespace, pipeline.project, pipeline.sha), class: "monospace" - - if pipeline.duration - in - = time_interval_in_words pipeline.duration - - .row-content-block.build-content.middle-block.js-pipeline-graph.hidden - = render "projects/pipelines/graph", pipeline: pipeline - -- if pipeline.yaml_errors.present? - .bs-callout.bs-callout-danger - %h4 Found errors in your .gitlab-ci.yml: - %ul - - pipeline.yaml_errors.split(",").each do |error| - %li= error - You can also test your .gitlab-ci.yml in the #{link_to "Lint", ci_lint_path} - -- if pipeline.project.builds_enabled? && !pipeline.ci_yaml_file - .bs-callout.bs-callout-warning - \.gitlab-ci.yml not found in this commit - -.table-holder.pipeline-holder - %table.table.ci-table.pipeline - %thead - %tr - %th Status - %th Job ID - %th Name - %th - %th Coverage - %th - = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage diff --git a/app/views/projects/commit/branches.html.haml b/app/views/projects/commit/branches.html.haml index 2b0c9a4b4de..911c9ddce06 100644 --- a/app/views/projects/commit/branches.html.haml +++ b/app/views/projects/commit/branches.html.haml @@ -1,15 +1,15 @@ -- if @branches.any? - %span - - branch = commit_default_branch(@project, @branches) - = link_to(namespace_project_tree_path(@project.namespace, @project, branch)) do - %span.label.label-gray - = branch - - if @branches.any? || @tags.any? - = link_to("#", class: "js-details-expand") do - %span.label.label-gray - \... +- if @branches.any? || @tags.any? + - branch = commit_default_branch(@project, @branches) + = link_to(project_ref_path(@project, branch), class: "label label-gray ref-name") do + = icon('code-fork') + = branch + + -# `commit_default_branch` deletes the default branch from `@branches`, + -# so only render this if we have more branches left + - if @branches.any? || @tags.any? + %span + = link_to "…", "#", class: "js-details-expand label label-gray" + %span.js-details-content.hide - - if @branches.any? - = commit_branches_links(@project, @branches) - - if @tags.any? - = commit_tags_links(@project, @tags) + = commit_branches_links(@project, @branches) if @branches.any? + = commit_tags_links(@project, @tags) if @tags.any? diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 8f32d2b72e5..3350a0ec152 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -37,6 +37,6 @@ .commit-actions.flex-row.hidden-xs - if commit.status(ref) = render_commit_status(commit, ref: ref) + = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-sha btn btn-transparent" = clipboard_button(text: commit.id, title: "Copy commit SHA to clipboard") - = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent" = link_to_browse_code(project, commit) diff --git a/app/views/projects/commits/_inline_commit.html.haml b/app/views/projects/commits/_inline_commit.html.haml index c03bc3f9df9..5fb89935467 100644 --- a/app/views/projects/commits/_inline_commit.html.haml +++ b/app/views/projects/commits/_inline_commit.html.haml @@ -1,6 +1,6 @@ %li.commit.inline-commit .commit-row-title - = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit_short_id" + = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-sha" %span.str-truncated = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message" diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml index 1f4c9fac54c..adb724c1b8d 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -7,7 +7,7 @@ .input-group.inline-input-group %span.input-group-addon from = hidden_field_tag :from, params[:from] - = button_tag type: 'button', title: params[:from], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do + = button_tag type: 'button', title: params[:from], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip git-revision-dropdown-toggle", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do .dropdown-toggle-text.str-truncated= params[:from] || 'Select branch/tag' = render 'shared/ref_dropdown' .compare-ellipsis.inline ... @@ -15,7 +15,7 @@ .input-group.inline-input-group %span.input-group-addon to = hidden_field_tag :to, params[:to] - = button_tag type: 'button', title: params[:to], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do + = button_tag type: 'button', title: params[:to], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip git-revision-dropdown-toggle", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do .dropdown-toggle-text.str-truncated= params[:to] || 'Select branch/tag' = render 'shared/ref_dropdown' diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml index 45be6581cfc..2cf14859f30 100644 --- a/app/views/projects/compare/index.html.haml +++ b/app/views/projects/compare/index.html.haml @@ -6,10 +6,10 @@ .sub-header-block Compare Git revisions. %br - Fill input field with commit id like - %code.label-branch 4eedf23 + Fill input field with commit SHA like + %code.ref-name 4eedf23 or branch/tag name like - %code.label-branch master + %code.ref-name master and press compare button for the commits list and a code diff. %br Changes are shown <b>from</b> the version in the first field <b>to</b> the version in the second field. diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml index 0dfc9fe20ed..a1bca2cf83a 100644 --- a/app/views/projects/compare/show.html.haml +++ b/app/views/projects/compare/show.html.haml @@ -16,9 +16,9 @@ There isn't anything to compare. %p.slead - if params[:to] == params[:from] - %span.label-branch= params[:from] + %span.ref-name= params[:from] and - %span.label-branch= params[:to] + %span.ref-name= params[:to] are the same. - else You'll need to use different branch names to get a valid comparison. diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index b158a81471c..74255167352 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -51,7 +51,7 @@ %ul %li.stage-header %span.stage-name - {{ __('ProjectLifecycle|Stage') }} + {{ s__('ProjectLifecycle|Stage') }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The phase of the development lifecycle."), "aria-hidden" => "true" } %li.median-header %span.stage-name diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml index 170d786ecbf..31fd982c522 100644 --- a/app/views/projects/deployments/_commit.html.haml +++ b/app/views/projects/deployments/_commit.html.haml @@ -2,10 +2,10 @@ - if deployment.ref .icon-container = deployment.tag? ? icon('tag') : icon('code-fork') - = link_to deployment.ref, namespace_project_commits_path(@project.namespace, @project, deployment.ref), class: "monospace branch-name" + = link_to deployment.ref, project_ref_path(@project, deployment.ref), class: "ref-name" .icon-container.commit-icon = custom_icon("icon_commit") - = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-id monospace" + = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-sha" %p.commit-title %span diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml index 7d6b3701f95..4e4fdb73ae3 100644 --- a/app/views/projects/diffs/_file_header.html.haml +++ b/app/views/projects/diffs/_file_header.html.haml @@ -1,4 +1,8 @@ -%i.fa.diff-toggle-caret.fa-fw +- show_toggle = local_assigns.fetch(:show_toggle, true) + +- if show_toggle + %i.fa.diff-toggle-caret.fa-fw + - if defined?(blob) && blob && diff_file.submodule? %span = icon('archive fw') diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 160345cfaa5..d9643dc7957 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -40,8 +40,8 @@ .form_group.prepend-top-20.sharing-and-permissions .row.js-visibility-select .col-md-9 - %label.label-light - = label_tag :project_visibility, 'Project Visibility', class: 'label-light' + .label-light + = label_tag :project_visibility, 'Project Visibility', class: 'label-light', for: :project_visibility_level = link_to "(?)", help_page_path("public_access/public_access") %span.help-block .col-md-3.visibility-select-container diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index 4cdb44325b3..be0462f91cd 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -1,4 +1,5 @@ - page_title "Find File", @ref += render "projects/commits/head" .file-finder-holder.tree-holder.clearfix .nav-block diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml index f458646522c..b23bbadbdb4 100644 --- a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml @@ -27,7 +27,7 @@ = custom_icon("icon_commit") - if commit_sha - = link_to generic_commit_status.short_sha, namespace_project_commit_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.sha), class: "commit-id monospace" + = link_to generic_commit_status.short_sha, namespace_project_commit_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.sha), class: "commit-sha" - if retried = icon('warning', class: 'text-warning has-tooltip', title: 'Status was retried.') @@ -48,7 +48,7 @@ - if generic_commit_status.pipeline.user = user_avatar(user: generic_commit_status.pipeline.user, size: 20) - else - %span.monospace API + %span.api API - if admin %td diff --git a/app/views/projects/imports/new.html.haml b/app/views/projects/imports/new.html.haml index 2cd8d03e30e..25a87411cac 100644 --- a/app/views/projects/imports/new.html.haml +++ b/app/views/projects/imports/new.html.haml @@ -10,7 +10,7 @@ .panel-body %pre :preserve - #{sanitize_repo_path(@project, @project.import_error)} + #{h(sanitize_repo_path(@project, @project.import_error))} = form_for @project, url: namespace_project_import_path(@project.namespace, @project), method: :post, html: { class: 'form-horizontal' } do |f| = render "shared/import_form", f: f diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 6bc6bf76e18..dba092c8844 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -17,7 +17,7 @@ .description %strong Create a merge request %span - Creates a branch named after this issue and a merge request. The source branch is '#{@project.default_branch}' by default. + Creates a merge request named after this issue, with source branch created from '#{@project.default_branch}'. %li.divider.droplab-item-ignore %li{ role: 'button', data: { value: 'create-branch', 'text' => 'Create a branch' } } .menu-item @@ -26,4 +26,4 @@ .description %strong Create a branch %span - Creates a branch named after this issue. The source branch is '#{@project.default_branch}' by default. + Creates a branch named after this issue, from '#{@project.default_branch}'. diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index 1892ebb512f..8c9f6f3b4df 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -11,5 +11,4 @@ = render_pipeline_status(pipeline) %span.related-branch-info %strong - = link_to namespace_project_compare_path(@project.namespace, @project, from: @project.default_branch, to: branch), class: "label-branch" do - = branch + = link_to branch, namespace_project_compare_path(@project.namespace, @project, from: @project.default_branch, to: branch), class: "ref-name" diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index 4ac0bc1d028..60900e9d660 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -7,7 +7,8 @@ = render "projects/issues/head" - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('filtered_search') + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'filtered_search' = content_for :meta_tags do = auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@project.name} issues") diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 9084883eb3e..b2401442620 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -51,12 +51,17 @@ .issue-details.issuable-details .detail-page-description.content-block - .issue-title-data.hidden{ "data" => { "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue), - "can-update-tasks-class" => can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '', + #js-issuable-app{ "data" => { "endpoint" => realtime_changes_namespace_project_issue_path(@project.namespace, @project, @issue), + "can-update" => can?(current_user, :update_issue, @issue).to_s, + "issuable-ref" => @issue.to_reference, } } - .issue-title-entrypoint + %h2.title= markdown_field(@issue, :title) + - if @issue.description.present? + .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' } + .wiki= markdown_field(@issue, :description) + %textarea.hidden.js-task-list-field= @issue.description - = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue_edited_ago') + = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago') #merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } } // This element is filled in using JavaScript. diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 11b7aaec704..94b9577e9eb 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -37,7 +37,7 @@ by #{link_to_member(@project, merge_request.author, avatar: false)} - if merge_request.target_project.default_branch != merge_request.target_branch - = link_to namespace_project_commits_path(merge_request.project.namespace, merge_request.project, merge_request.target_branch) do + = link_to project_ref_path(merge_request.project, merge_request.target_branch), class: 'ref-name' do = icon('code-fork') = merge_request.target_branch diff --git a/app/views/projects/merge_requests/_new_compare.html.haml b/app/views/projects/merge_requests/_new_compare.html.haml index 9cf24e10842..0f37abb579c 100644 --- a/app/views/projects/merge_requests/_new_compare.html.haml +++ b/app/views/projects/merge_requests/_new_compare.html.haml @@ -21,8 +21,8 @@ selected: f.object.source_project_id .merge-request-select.dropdown = f.hidden_field :source_branch - = dropdown_toggle f.object.source_branch || "Select source branch", { toggle: "dropdown", field_name: "#{f.object_name}[source_branch]" }, { toggle_class: "js-compare-dropdown js-source-branch" } - .dropdown-menu.dropdown-menu-selectable.dropdown-source-branch + = dropdown_toggle f.object.source_branch || "Select source branch", { toggle: "dropdown", field_name: "#{f.object_name}[source_branch]" }, { toggle_class: "js-compare-dropdown js-source-branch git-revision-dropdown-toggle" } + .dropdown-menu.dropdown-menu-selectable.dropdown-source-branch.git-revision-dropdown = dropdown_title("Select source branch") = dropdown_filter("Search branches") = dropdown_content do @@ -51,8 +51,8 @@ selected: f.object.target_project_id .merge-request-select.dropdown = f.hidden_field :target_branch - = dropdown_toggle f.object.target_branch, { toggle: "dropdown", field_name: "#{f.object_name}[target_branch]" }, { toggle_class: "js-compare-dropdown js-target-branch" } - .dropdown-menu.dropdown-menu-selectable.dropdown-target-branch.js-target-branch-dropdown + = dropdown_toggle f.object.target_branch, { toggle: "dropdown", field_name: "#{f.object_name}[target_branch]" }, { toggle_class: "js-compare-dropdown js-target-branch git-revision-dropdown-toggle" } + .dropdown-menu.dropdown-menu-selectable.dropdown-target-branch.js-target-branch-dropdown.git-revision-dropdown = dropdown_title("Select target branch") = dropdown_filter("Search branches") = dropdown_content do diff --git a/app/views/projects/merge_requests/_new_submit.html.haml b/app/views/projects/merge_requests/_new_submit.html.haml index da79ca2ee75..e3ecbee5490 100644 --- a/app/views/projects/merge_requests/_new_submit.html.haml +++ b/app/views/projects/merge_requests/_new_submit.html.haml @@ -3,9 +3,9 @@ %p.slead - source_title, target_title = format_mr_branch_names(@merge_request) From - %strong.label-branch= source_title + %strong.ref-name= source_title %span into - %strong.label-branch= target_title + %strong.ref-name= target_title %span.pull-right = link_to 'Change branches', mr_change_branches_path(@merge_request) diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 9e306d4543c..b7515e1d91f 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -1,6 +1,6 @@ - @content_class = "limit-container-width" unless fluid_layout -- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests" -- page_description @merge_request.description +- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests" +- page_description @merge_request.description - page_card_attributes @merge_request.card_attributes - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('common_vue') @@ -11,81 +11,58 @@ .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } = render "projects/merge_requests/show/mr_box" - .append-bottom-default.mr-source-target.prepend-top-default - - if @merge_request.open? - .pull-right - - if @merge_request.source_branch_exists? - - if koding_enabled? && @repository.koding_yml - = link_to koding_project_url(@merge_request.source_project, @merge_request.source_branch, @merge_request.commits.first.short_id), class: "btn inline btn-grouped btn-sm", target: '_blank', rel: 'noopener noreferrer' do - Run in IDE (Koding) - = link_to "#modal_merge_info", class: "btn inline btn-grouped btn-sm", "data-toggle" => "modal" do - Check out branch - - %span.dropdown.inline.prepend-left-5 - %a.btn.btn-sm.dropdown-toggle{ data: {toggle: :dropdown} } - Download as - = icon('caret-down') - %ul.dropdown-menu.dropdown-menu-align-right - %li= link_to "Email Patches", merge_request_path(@merge_request, format: :patch) - %li= link_to "Plain Diff", merge_request_path(@merge_request, format: :diff) - .normal - %span <b>Request to merge</b> - %span.label-branch= source_branch_with_namespace(@merge_request) - %span <b>into</b> - %span.label-branch - = link_to_if @merge_request.target_branch_exists?, @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch) - - if @merge_request.open? && @merge_request.diverged_from_target_branch? - %span (#{pluralize(@merge_request.diverged_commits_count, 'commit')} behind) - if @merge_request.source_branch_exists? = render "projects/merge_requests/show/how_to_merge" - = render "projects/merge_requests/widget/show.html.haml" + :javascript + window.gl.mrWidgetData = #{serialize_issuable(@merge_request)} + + #js-vue-mr-widget.mr-widget - - if @merge_request.source_branch_exists? && @merge_request.mergeable? && @merge_request.can_be_merged_by?(current_user) - .merge-manually.light.prepend-top-default - You can also accept this merge request manually using the - = succeed '.' do - = link_to "command line", "#modal_merge_info", class: "how_to_merge_link vlink", title: "How To Merge", "data-toggle" => "modal" + - content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('vue_merge_request_widget') .content-block.content-block-small.emoji-list-container = render 'award_emoji/awards_block', awardable: @merge_request, inline: true .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } - .merge-request-tabs-container.scrolling-tabs-container.inner-page-scroll-tabs - .fade-left= icon('angle-left') - .fade-right= icon('angle-right') - %ul.merge-request-tabs.nav-links.scrolling-tabs - %li.notes-tab - = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#notes', action: 'notes', toggle: 'tab' } do - Discussion - %span.badge= @merge_request.related_notes.user.count - - if @merge_request.source_project - %li.commits-tab - = link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#commits', action: 'commits', toggle: 'tab' } do - Commits - %span.badge= @commits_count - - if @pipelines.any? - %li.pipelines-tab - = link_to pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: '#pipelines', action: 'pipelines', toggle: 'tab' } do - Pipelines - %span.badge= @pipelines.size - %li.diffs-tab - = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do - Changes - %span.badge= @merge_request.diff_size - %li#resolve-count-app.line-resolve-all-container.pull-right.prepend-top-10.hidden-xs{ "v-cloak" => true } - %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" } - %div - .line-resolve-all{ "v-show" => "discussionCount > 0", - ":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" } - %span.line-resolve-btn.is-disabled{ type: "button", - ":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" } - = render "shared/icons/icon_status_success.svg" - %span.line-resolve-text - {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved - = render "discussions/new_issue_for_all_discussions", merge_request: @merge_request - = render "discussions/jump_to_next" + .merge-request-tabs-container + .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller + .fade-left= icon('angle-left') + .fade-right= icon('angle-right') + .nav-links.scrolling-tabs + %ul.merge-request-tabs + %li.notes-tab + = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#notes', action: 'notes', toggle: 'tab' } do + Discussion + %span.badge= @merge_request.related_notes.user.count + - if @merge_request.source_project + %li.commits-tab + = link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#commits', action: 'commits', toggle: 'tab' } do + Commits + %span.badge= @commits_count + - if @pipelines.any? + %li.pipelines-tab + = link_to pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: '#pipelines', action: 'pipelines', toggle: 'tab' } do + Pipelines + %span.badge= @pipelines.size + %li.diffs-tab + = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do + Changes + %span.badge= @merge_request.diff_size + #resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true } + %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" } + %div + .line-resolve-all{ "v-show" => "discussionCount > 0", + ":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" } + %span.line-resolve-btn.is-disabled{ type: "button", + ":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" } + = render "shared/icons/icon_status_success.svg" + %span.line-resolve-text + {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved + = render "discussions/new_issue_for_all_discussions", merge_request: @merge_request + = render "discussions/jump_to_next" .tab-content#diff-notes-app #notes.notes.tab-pane.voting_notes @@ -113,9 +90,7 @@ :javascript $(function () { - new MergeRequest({ + window.mergeRequest = new MergeRequest({ action: "#{controller.action_name}" }); }); - - var mrRefreshWidgetUrl = "#{mr_widget_refresh_url(@merge_request)}"; diff --git a/app/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml b/app/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml deleted file mode 100644 index eab5be488b5..00000000000 --- a/app/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml +++ /dev/null @@ -1,2 +0,0 @@ -:plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/accept'))}"); diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml index 6bf0035e051..502220232a1 100644 --- a/app/views/projects/merge_requests/index.html.haml +++ b/app/views/projects/merge_requests/index.html.haml @@ -8,7 +8,8 @@ = render 'projects/last_push' - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('filtered_search') + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'filtered_search' - if @project.merge_requests.exists? %div{ class: container_class } diff --git a/app/views/projects/merge_requests/merge.js.haml b/app/views/projects/merge_requests/merge.js.haml deleted file mode 100644 index e632fc681cf..00000000000 --- a/app/views/projects/merge_requests/merge.js.haml +++ /dev/null @@ -1,14 +0,0 @@ -- case @status -- when :success - - remove_source_branch = params[:should_remove_source_branch] == '1' || @merge_request.remove_source_branch? - :plain - merge_request_widget.mergeInProgress(#{remove_source_branch}); -- when :merge_when_pipeline_succeeds - :plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/merge_when_pipeline_succeeds'))}"); -- when :sha_mismatch - :plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/sha_mismatch'))}"); -- else - :plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/reload'))}"); diff --git a/app/views/projects/merge_requests/show/_versions.html.haml b/app/views/projects/merge_requests/show/_versions.html.haml index 11b0c55be0b..37117bc64a3 100644 --- a/app/views/projects/merge_requests/show/_versions.html.haml +++ b/app/views/projects/merge_requests/show/_versions.html.haml @@ -20,25 +20,27 @@ - @merge_request_diffs.each do |merge_request_diff| %li = link_to merge_request_version_path(@project, @merge_request, merge_request_diff, @start_sha), class: ('is-active' if merge_request_diff == @merge_request_diff) do - %strong - - if merge_request_diff.latest? - latest version - - else - version #{version_index(merge_request_diff)} - .monospace= short_sha(merge_request_diff.head_commit_sha) - %small - #{number_with_delimiter(merge_request_diff.commits_count)} #{'commit'.pluralize(merge_request_diff.commits_count)}, - = time_ago_with_tooltip(merge_request_diff.created_at) + %div + %strong + - if merge_request_diff.latest? + latest version + - else + version #{version_index(merge_request_diff)} + %div + %small.commit-sha= short_sha(merge_request_diff.head_commit_sha) + %div + %small + #{number_with_delimiter(merge_request_diff.commits_count)} #{'commit'.pluralize(merge_request_diff.commits_count)}, + = time_ago_with_tooltip(merge_request_diff.created_at) - if @merge_request_diff.base_commit_sha and %span.dropdown.inline.mr-version-compare-dropdown %a.btn.btn-default.dropdown-toggle{ data: {toggle: :dropdown} } - %span - - if @start_version - version #{version_index(@start_version)} - - else - #{@merge_request.target_branch} + - if @start_version + version #{version_index(@start_version)} + - else + %span.ref-name= @merge_request.target_branch = icon('caret-down') .dropdown-menu.dropdown-select.dropdown-menu-selectable .dropdown-title @@ -50,19 +52,25 @@ - @comparable_diffs.each do |merge_request_diff| %li = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff, merge_request_diff.head_commit_sha), class: ('is-active' if merge_request_diff == @start_version) do - %strong - - if merge_request_diff.latest? - latest version - - else - version #{version_index(merge_request_diff)} - .monospace= short_sha(merge_request_diff.head_commit_sha) - %small - = time_ago_with_tooltip(merge_request_diff.created_at) + %div + %strong + - if merge_request_diff.latest? + latest version + - else + version #{version_index(merge_request_diff)} + %div + %small.commit-sha= short_sha(merge_request_diff.head_commit_sha) + %div + %small + = time_ago_with_tooltip(merge_request_diff.created_at) %li = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff), class: ('is-active' unless @start_version) do - %strong - #{@merge_request.target_branch} (base) - .monospace= short_sha(@merge_request_diff.base_commit_sha) + %div + %strong + %span.ref-name= @merge_request.target_branch + (base) + %div + %strong.commit-sha= short_sha(@merge_request_diff.base_commit_sha) - if different_base?(@start_version, @merge_request_diff) .content-block diff --git a/app/views/projects/merge_requests/widget/_closed.html.haml b/app/views/projects/merge_requests/widget/_closed.html.haml deleted file mode 100644 index 15f47ecf210..00000000000 --- a/app/views/projects/merge_requests/widget/_closed.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -.mr-state-widget - = render 'projects/merge_requests/widget/heading' - .mr-widget-body - %h4 - Closed - - if @merge_request.closed_event - by #{link_to_member(@project, @merge_request.closed_event.author, avatar: true)} - #{time_ago_with_tooltip(@merge_request.closed_event.created_at)} - %p - = succeed '.' do - The changes were not merged into - %span.label-branch= @merge_request.target_branch diff --git a/app/views/projects/merge_requests/widget/_commit_change_content.html.haml b/app/views/projects/merge_requests/widget/_commit_change_content.html.haml new file mode 100644 index 00000000000..ad0ce7bf501 --- /dev/null +++ b/app/views/projects/merge_requests/widget/_commit_change_content.html.haml @@ -0,0 +1,4 @@ +- if @merge_request.can_be_reverted?(current_user) + = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title +- if @merge_request.can_be_cherry_picked? + = render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit, title: @merge_request.title diff --git a/app/views/projects/merge_requests/widget/_heading.html.haml b/app/views/projects/merge_requests/widget/_heading.html.haml deleted file mode 100644 index 1298376ac25..00000000000 --- a/app/views/projects/merge_requests/widget/_heading.html.haml +++ /dev/null @@ -1,50 +0,0 @@ -- if @pipeline - .mr-widget-heading - - %w[success success_with_warnings skipped manual canceled failed running pending].each do |status| - .ci_widget{ class: "ci-#{status}", style: ("display:none" unless @pipeline.status == status) } - %div{ class: "ci-status-icon ci-status-icon-#{status}" } - = link_to namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'icon-link' do - = ci_icon_for_status(status) - %span - Pipeline - = link_to "##{@pipeline.id}", namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'pipeline' - = ci_label_for_status(status) - - if @pipeline.stages.any? - .mr-widget-pipeline-graph - = render 'shared/mini_pipeline_graph', pipeline: @pipeline, klass: 'js-pipeline-inline-mr-widget-graph' - %span - for - = succeed "." do - = link_to @pipeline.short_sha, namespace_project_commit_path(@merge_request.source_project.namespace, @merge_request.source_project, @pipeline.sha), class: "monospace js-commit-link" - %span.ci-coverage - -- elsif @merge_request.has_ci? - -# Compatibility with old CI integrations (ex jenkins) when you request status from CI server via AJAX - -# TODO, remove in later versions when services like Jenkins will set CI status via Commit status API - .mr-widget-heading - - %w[success skipped canceled failed running pending].each do |status| - .ci_widget{ class: "ci-#{status} ci-status-icon-#{status}", style: "display:none" } - = ci_icon_for_status(status) - %span - CI job - = ci_label_for_status(status) - for - - commit = @merge_request.diff_head_commit - = succeed "." do - = link_to commit.short_id, namespace_project_commit_path(@merge_request.source_project.namespace, @merge_request.source_project, commit), class: "monospace" - %span.ci-coverage - - .ci_widget - = icon("spinner spin") - Checking CI status for #{@merge_request.diff_head_commit.short_id}… - - .ci_widget.ci-not_found{ style: "display:none" } - = icon("times-circle") - Could not find CI status for #{@merge_request.diff_head_commit.short_id}. - - .ci_widget.ci-error{ style: "display:none" } - = icon("times-circle") - Could not connect to the CI server. Please check your settings and try again. - -.js-success-icon.hidden - = ci_icon_for_status('success') diff --git a/app/views/projects/merge_requests/widget/_locked.html.haml b/app/views/projects/merge_requests/widget/_locked.html.haml deleted file mode 100644 index 78d0783cba0..00000000000 --- a/app/views/projects/merge_requests/widget/_locked.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -.mr-state-widget - = render 'projects/merge_requests/widget/heading' - .mr-widget-body - %h4 - = icon("spinner spin") - Merge in progress… - %p - This merge request is in the process of being merged, during which time it is locked and cannot be closed. - diff --git a/app/views/projects/merge_requests/widget/_merged.html.haml b/app/views/projects/merge_requests/widget/_merged.html.haml deleted file mode 100644 index adc3bbc37f3..00000000000 --- a/app/views/projects/merge_requests/widget/_merged.html.haml +++ /dev/null @@ -1,52 +0,0 @@ -.mr-state-widget - = render 'projects/merge_requests/widget/heading' - .mr-widget-body - %h4 - Merged - - if @merge_request.merge_event - by #{link_to_member(@project, @merge_request.merge_event.author, avatar: true)} - #{time_ago_with_tooltip(@merge_request.merge_event.created_at)} - - if !@merge_request.source_branch_exists? || params[:deleted_source_branch] - .remove-message-pipes - %ul - %li - %span - The changes were merged into - #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. - %li - %span - The source branch has been removed. - = render 'projects/merge_requests/widget/merged_buttons' - - elsif @merge_request.can_remove_source_branch?(current_user) - .remove_source_branch_widget.remove-message-pipes - %ul - %li - %span - The changes were merged into - #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. - %li - %span - You can remove the source branch now. - = render 'projects/merge_requests/widget/merged_buttons', source_branch_exists: true - .remove_source_branch_widget.failed.remove-message-pipes.hide - %ul - %li - %span - Failed to remove source branch '#{@merge_request.source_branch}'. - .remove_source_branch_in_progress.remove-message-pipes.hide - %ul - %li - %span - = icon('spinner spin') - Removing source branch '#{@merge_request.source_branch}'. - %li - %span - Please wait, this page will be automatically reloaded. - - else - .remove-message-pipes - %ul - %li - %span - The changes were merged into - #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. - = render 'projects/merge_requests/widget/merged_buttons' diff --git a/app/views/projects/merge_requests/widget/_merged_buttons.haml b/app/views/projects/merge_requests/widget/_merged_buttons.haml deleted file mode 100644 index a0f54bd28ec..00000000000 --- a/app/views/projects/merge_requests/widget/_merged_buttons.haml +++ /dev/null @@ -1,14 +0,0 @@ -- can_remove_source_branch = local_assigns.fetch(:source_branch_exists, false) && @merge_request.can_remove_source_branch?(current_user) -- mr_can_be_reverted = @merge_request.can_be_reverted?(current_user) -- mr_can_be_cherry_picked = @merge_request.can_be_cherry_picked? - -- if can_remove_source_branch || mr_can_be_reverted || mr_can_be_cherry_picked - .clearfix.merged-buttons - - if can_remove_source_branch - = link_to namespace_project_branch_path(@merge_request.source_project.namespace, @merge_request.source_project, @merge_request.source_branch), remote: true, method: :delete, class: "btn btn-default remove_source_branch" do - = icon('trash-o') - Remove source branch - - if mr_can_be_reverted - = revert_commit_link(@merge_request.merge_commit, namespace_project_merge_request_path(@project.namespace, @project, @merge_request), btn_class: "close") - - if mr_can_be_cherry_picked - = cherry_pick_commit_link(@merge_request.merge_commit, namespace_project_merge_request_path(@project.namespace, @project, @merge_request), btn_class: "default") diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml deleted file mode 100644 index 0872a1a0503..00000000000 --- a/app/views/projects/merge_requests/widget/_open.html.haml +++ /dev/null @@ -1,49 +0,0 @@ -.mr-state-widget - = render 'projects/merge_requests/widget/heading' - .mr-widget-body - -# After conflicts are resolved, the user is redirected back to the MR page. - -# There is a short window before background workers run and GitLab processes - -# the new push and commits, during which it will think the conflicts still exist. - -# We send this param to get the widget to treat the MR as having no more conflicts. - - resolved_conflicts = params[:resolved_conflicts] - - - if @project.archived? - = render 'projects/merge_requests/widget/open/archived' - - elsif @merge_request.branch_missing? - = render 'projects/merge_requests/widget/open/missing_branch' - - elsif @merge_request.has_no_commits? - = render 'projects/merge_requests/widget/open/nothing' - - elsif @merge_request.unchecked? - = render 'projects/merge_requests/widget/open/check' - - elsif @merge_request.cannot_be_merged? && !resolved_conflicts - = render 'projects/merge_requests/widget/open/conflicts' - - elsif @merge_request.work_in_progress? - = render 'projects/merge_requests/widget/open/wip' - - elsif @merge_request.merge_when_pipeline_succeeds? && @merge_request.merge_error.present? - = render 'projects/merge_requests/widget/open/error' - - elsif @merge_request.merge_when_pipeline_succeeds? - = render 'projects/merge_requests/widget/open/merge_when_pipeline_succeeds' - - elsif !@merge_request.can_be_merged_by?(current_user) - = render 'projects/merge_requests/widget/open/not_allowed' - - elsif !@merge_request.mergeable_ci_state? && (@pipeline.failed? || @pipeline.canceled?) - = render 'projects/merge_requests/widget/open/build_failed' - - elsif !@merge_request.mergeable_discussions_state? - = render 'projects/merge_requests/widget/open/unresolved_discussions' - - elsif @pipeline&.blocked? - = render 'projects/merge_requests/widget/open/manual' - - elsif @merge_request.can_be_merged? || resolved_conflicts - = render 'projects/merge_requests/widget/open/accept' - - - if mr_closes_issues.present? || mr_issues_mentioned_but_not_closing.present? - .mr-widget-footer - %span - = icon('check') - - if mr_closes_issues.present? - Accepting this merge request will close #{"issue".pluralize(mr_closes_issues.size)} - = succeed '.' do - != markdown issues_sentence(mr_closes_issues), pipeline: :gfm, author: @merge_request.author - = mr_assign_issues_link - - if mr_issues_mentioned_but_not_closing.present? - #{"Issue".pluralize(mr_issues_mentioned_but_not_closing.size)} - != markdown issues_sentence(mr_issues_mentioned_but_not_closing), pipeline: :gfm, author: @merge_request.author - #{mr_issues_mentioned_but_not_closing.size > 1 ? 'are' : 'is'} mentioned but will not be closed. diff --git a/app/views/projects/merge_requests/widget/_show.html.haml b/app/views/projects/merge_requests/widget/_show.html.haml deleted file mode 100644 index c716b69b35b..00000000000 --- a/app/views/projects/merge_requests/widget/_show.html.haml +++ /dev/null @@ -1,40 +0,0 @@ -- if @merge_request.open? - = render 'projects/merge_requests/widget/open' -- elsif @merge_request.merged? - = render 'projects/merge_requests/widget/merged' -- elsif @merge_request.closed? - = render 'projects/merge_requests/widget/closed' -- elsif @merge_request.locked? - = render 'projects/merge_requests/widget/locked' - -:javascript - var opts = { - merge_check_url: "#{merge_check_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", - check_enable: #{@merge_request.unchecked? ? "true" : "false"}, - ci_status_url: "#{ci_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", - pipeline_status_url: "#{pipeline_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", - ci_environments_status_url: "#{ci_environments_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", - gitlab_icon: "#{asset_path 'gitlab_logo.png'}", - ci_status: "#{@merge_request.head_pipeline ? @merge_request.head_pipeline.status : ''}", - ci_message: { - normal: "Pipeline {{status}} for \"{{title}}\"", - preparing: "{{status}} pipeline for \"{{title}}\"" - }, - ci_enable: #{@project.ci_service ? "true" : "false"}, - ci_title: { - preparing: "{{status}} pipeline", - normal: "Pipeline {{status}}" - }, - ci_sha: "#{@merge_request.head_pipeline ? @merge_request.head_pipeline.short_sha : ''}", - ci_pipeline: #{@merge_request.head_pipeline.try(:id).to_json}, - commits_path: "#{project_commits_path(@project)}", - pipeline_path: "#{project_pipelines_path(@project)}", - pipelines_path: "#{pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}" - }; - - if (typeof merge_request_widget !== 'undefined') { - merge_request_widget.cancelPolling(); - merge_request_widget.clearEventListeners(); - } - - merge_request_widget = new window.gl.MergeRequestWidget(opts); diff --git a/app/views/projects/merge_requests/widget/open/_accept.html.haml b/app/views/projects/merge_requests/widget/open/_accept.html.haml deleted file mode 100644 index 4cbd22150c7..00000000000 --- a/app/views/projects/merge_requests/widget/open/_accept.html.haml +++ /dev/null @@ -1,50 +0,0 @@ -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('merge_request_widget') - -= form_for [:merge, @project.namespace.becomes(Namespace), @project, @merge_request], remote: true, method: :post, html: { class: 'accept-mr-form js-quick-submit js-requires-input' } do |f| - = hidden_field_tag :authenticity_token, form_authenticity_token - = hidden_field_tag :sha, @merge_request.diff_head_sha - .accept-merge-holder.clearfix.js-toggle-container - .clearfix - .accept-action - - if @pipeline && @pipeline.active? - %span.btn-group - = button_tag class: "btn btn-info js-merge-when-pipeline-succeeds-button merge-when-pipeline-succeeds" do - Merge when pipeline succeeds - - unless @project.only_allow_merge_if_pipeline_succeeds? - = button_tag class: "btn btn-info dropdown-toggle", 'data-toggle' => 'dropdown' do - = icon('caret-down') - %span.sr-only - Select merge moment - %ul.js-merge-dropdown.dropdown-menu.dropdown-menu-right{ role: 'menu' } - %li - = link_to "#", class: "merge-when-pipeline-succeeds" do - = icon('check fw') - Merge when pipeline succeeds - %li - = link_to "#", class: "accept-merge-request" do - = icon('warning fw') - Merge immediately - - else - = f.button class: "btn btn-grouped js-merge-button accept-merge-request" do - Accept merge request - - if @merge_request.force_remove_source_branch? - .accept-control - The source branch will be removed. - - elsif @merge_request.can_remove_source_branch?(current_user) - .accept-control.checkbox - = label_tag :should_remove_source_branch, class: "merge-param-checkbox" do - = check_box_tag :should_remove_source_branch - Remove source branch - .accept-control - %button.modify-merge-commit-link.js-toggle-button{ type: "button" } - = icon('edit') - Modify commit message - .js-toggle-content.hide.prepend-top-default - = render 'shared/commit_message_container', params: params, - message_with_description: @merge_request.merge_commit_message(include_description: true), - message_without_description: @merge_request.merge_commit_message, - text: @merge_request.merge_commit_message, - rows: 14, hint: true - - = hidden_field_tag :merge_when_pipeline_succeeds, "", autocomplete: "off" diff --git a/app/views/projects/merge_requests/widget/open/_archived.html.haml b/app/views/projects/merge_requests/widget/open/_archived.html.haml deleted file mode 100644 index 0d61e56d8fb..00000000000 --- a/app/views/projects/merge_requests/widget/open/_archived.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -%h4 - Project is archived -%p - This merge request cannot be merged because archived projects cannot be written to. diff --git a/app/views/projects/merge_requests/widget/open/_build_failed.html.haml b/app/views/projects/merge_requests/widget/open/_build_failed.html.haml deleted file mode 100644 index 3979d5fa8ed..00000000000 --- a/app/views/projects/merge_requests/widget/open/_build_failed.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%h4 - = icon('exclamation-triangle') - The pipeline for this merge request failed - -%p - Please retry the job or push a new commit to fix the failure. diff --git a/app/views/projects/merge_requests/widget/open/_check.html.haml b/app/views/projects/merge_requests/widget/open/_check.html.haml deleted file mode 100644 index 909dc52fc06..00000000000 --- a/app/views/projects/merge_requests/widget/open/_check.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('merge_request_widget') - -%strong - = icon("spinner spin") - Checking ability to merge automatically… diff --git a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml b/app/views/projects/merge_requests/widget/open/_conflicts.html.haml deleted file mode 100644 index 621ee313026..00000000000 --- a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml +++ /dev/null @@ -1,27 +0,0 @@ -- can_resolve = @merge_request.conflicts_can_be_resolved_by?(current_user) -- can_resolve_in_ui = @merge_request.conflicts_can_be_resolved_in_ui? -- can_merge = @merge_request.can_be_merged_via_command_line_by?(current_user) - -%h4.has-conflicts - %p - = icon("exclamation-triangle") - This merge request contains merge conflicts - -.remove-message-pipes - %ul - %li - %span - To merge this request, resolve these conflicts - - if can_resolve && !can_resolve_in_ui - locally - or - - unless can_merge - ask someone with write access to this repository to - merge it locally. - -- if (can_resolve && can_resolve_in_ui) || can_merge - .merged-buttons.clearfix - - if can_resolve && can_resolve_in_ui - = link_to "Resolve conflicts", conflicts_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: "btn" - - if can_merge - = link_to "Merge locally", "#modal_merge_info", class: "btn how_to_merge_link vlink", "data-toggle" => "modal" diff --git a/app/views/projects/merge_requests/widget/open/_manual.html.haml b/app/views/projects/merge_requests/widget/open/_manual.html.haml deleted file mode 100644 index 9078b7e21dd..00000000000 --- a/app/views/projects/merge_requests/widget/open/_manual.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -%h4 - Pipeline blocked -%p - The pipeline for this merge request requires a manual action to proceed. diff --git a/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml b/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml deleted file mode 100644 index 76cc1ecd8a5..00000000000 --- a/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml +++ /dev/null @@ -1,33 +0,0 @@ -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('merge_request_widget') - -%h4 - Set by #{link_to_member(@project, @merge_request.merge_user, avatar: true)} - to be merged automatically when the pipeline succeeds. -.remove-message-pipes - %ul - %li - %span - = succeed '.' do - The changes will be merged into #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"} - - if @merge_request.remove_source_branch? - %li - %span - The source branch will be removed. - - else - %li - %span - The source branch will not be removed. - - - remove_source_branch_button = !@merge_request.remove_source_branch? && @merge_request.can_remove_source_branch?(current_user) && @merge_request.merge_user == current_user - - user_can_cancel_automatic_merge = @merge_request.can_cancel_merge_when_pipeline_succeeds?(current_user) - - if remove_source_branch_button || user_can_cancel_automatic_merge - .clearfix.prepend-top-10 - - if remove_source_branch_button - = link_to merge_namespace_project_merge_request_path(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request, merge_params(@merge_request)), remote: true, method: :post, class: "btn btn-grouped btn-primary btn-sm remove_source_branch" do - = icon('times') - Remove source branch when merged - - - if user_can_cancel_automatic_merge - = link_to cancel_merge_when_pipeline_succeeds_namespace_project_merge_request_path(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request), remote: true, method: :post, class: "btn btn-grouped btn-sm" do - Cancel automatic merge diff --git a/app/views/projects/merge_requests/widget/open/_missing_branch.html.haml b/app/views/projects/merge_requests/widget/open/_missing_branch.html.haml deleted file mode 100644 index c9f07629493..00000000000 --- a/app/views/projects/merge_requests/widget/open/_missing_branch.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -- unless @merge_request.source_branch_exists? - %h4 - = icon("exclamation-triangle") - Source branch - %span.label-branch= source_branch_with_namespace(@merge_request) - does not exist - %p - Please restore the source branch or close this merge request and open a new merge request with a different source branch. -- else - %h4 - = icon("exclamation-triangle") - Target branch - %span.label-branch= @merge_request.target_branch - does not exist - %p - Please restore the target branch or use a different target branch. diff --git a/app/views/projects/merge_requests/widget/open/_not_allowed.html.haml b/app/views/projects/merge_requests/widget/open/_not_allowed.html.haml deleted file mode 100644 index 57ce1959021..00000000000 --- a/app/views/projects/merge_requests/widget/open/_not_allowed.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%h4 - Ready to be merged automatically -%p - Ask someone with write access to this repository to merge this request. - - if @merge_request.force_remove_source_branch? - The source branch will be removed. diff --git a/app/views/projects/merge_requests/widget/open/_nothing.html.haml b/app/views/projects/merge_requests/widget/open/_nothing.html.haml deleted file mode 100644 index 7af8c01c134..00000000000 --- a/app/views/projects/merge_requests/widget/open/_nothing.html.haml +++ /dev/null @@ -1,8 +0,0 @@ -%h4 - = icon("exclamation-triangle") - Nothing to merge from - %span.label-branch= source_branch_with_namespace(@merge_request) - into - %span.label-branch= @merge_request.target_branch -%p - Please push new commits to the source branch or use a different target branch. diff --git a/app/views/projects/merge_requests/widget/open/_reload.html.haml b/app/views/projects/merge_requests/widget/open/_reload.html.haml deleted file mode 100644 index acfc31725eb..00000000000 --- a/app/views/projects/merge_requests/widget/open/_reload.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%h4 - = icon("exclamation-triangle") - This merge request failed to be merged automatically - -%p - Please reload the page to find out the reason. diff --git a/app/views/projects/merge_requests/widget/open/_sha_mismatch.html.haml b/app/views/projects/merge_requests/widget/open/_sha_mismatch.html.haml deleted file mode 100644 index 499624f8dd8..00000000000 --- a/app/views/projects/merge_requests/widget/open/_sha_mismatch.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%h4 - = icon("exclamation-triangle") - This merge request has received new commits since the page was loaded. - -%p - Please reload the page to review the new commits before merging. diff --git a/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml b/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml deleted file mode 100644 index ec9346ce89b..00000000000 --- a/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -%h4 - = icon('exclamation-triangle') - This merge request has unresolved discussions - -%p - Please resolve these discussions - - if @project.issues_enabled? && can?(current_user, :create_issue, @project) - or - = link_to "open an issue to resolve them later", new_namespace_project_issue_path(@project.namespace, @project, merge_request_to_resolve_discussions_of: @merge_request.iid) - to allow this merge request to be merged. diff --git a/app/views/projects/merge_requests/widget/open/_wip.html.haml b/app/views/projects/merge_requests/widget/open/_wip.html.haml deleted file mode 100644 index c296422a9cf..00000000000 --- a/app/views/projects/merge_requests/widget/open/_wip.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -%h4 - This merge request is currently a Work In Progress - -- if can?(current_user, :update_merge_request, @merge_request) - %p - When this merge request is ready, - = link_to remove_wip_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), method: :post do - remove the - %code WIP: - prefix from the title - to allow it to be merged. diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 9e292729425..e180cb8bad1 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -30,7 +30,7 @@ #{root_url}#{current_user.username}/ = f.hidden_field :namespace_id, value: current_user.namespace_id .form-group.col-xs-12.col-sm-6.project-path - = f.label :namespace_id, class: 'label-light' do + = f.label :path, class: 'label-light' do %span Project name = f.text_field :path, placeholder: "my-awesome-project", class: "form-control", tabindex: 2, autofocus: true, required: true diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml index 4a21cce024e..d6f4f1a206c 100644 --- a/app/views/projects/pipeline_schedules/_form.html.haml +++ b/app/views/projects/pipeline_schedules/_form.html.haml @@ -1,5 +1,6 @@ - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('schedule_form') + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'schedule_form' = form_for [@project.namespace.becomes(Namespace), @project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "form-horizontal js-pipeline-schedule-form" } do |f| = form_errors(@schedule) @@ -19,7 +20,7 @@ .form-group .col-md-6 = f.label :ref, 'Target Branch', class: 'label-light' - = dropdown_tag("Select target branch", options: { toggle_class: 'btn js-target-branch-dropdown', title: "Select target branch", filter: true, placeholder: "Filter", data: { data: @project.repository.branch_names } } ) + = dropdown_tag("Select target branch", options: { toggle_class: 'btn js-target-branch-dropdown git-revision-dropdown-toggle', dropdown_class: 'git-revision-dropdown', title: "Select target branch", filter: true, placeholder: "Filter", data: { data: @project.repository.branch_names } } ) = f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true .form-group .col-md-6 diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml index 1406868488f..2cd82e1b661 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -4,12 +4,13 @@ = pipeline_schedule.description %td.branch-name-cell = icon('code-fork') - = link_to pipeline_schedule.ref, namespace_project_commits_path(@project.namespace, @project, pipeline_schedule.ref), class: "branch-name" + = link_to pipeline_schedule.ref, project_ref_path(@project, pipeline_schedule.ref), class: "ref-name" %td - if pipeline_schedule.last_pipeline .status-icon-container{ class: "ci-status-icon-#{pipeline_schedule.last_pipeline.status}" } = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline_schedule.last_pipeline.id) do = ci_icon_for_status(pipeline_schedule.last_pipeline.status) + %span ##{pipeline_schedule.last_pipeline.id} - else None %td.next-run-cell diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml index dd35c3055f2..25c52175e3d 100644 --- a/app/views/projects/pipeline_schedules/index.html.haml +++ b/app/views/projects/pipeline_schedules/index.html.haml @@ -1,12 +1,13 @@ - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('schedules_index') + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'schedules_index' - @no_container = true - page_title "Pipeline Schedules" = render "projects/pipelines/head" %div{ class: container_class } - #scheduling-pipelines-callout + #pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipeline_schedules') } } .top-area - schedule_path_proc = ->(scope) { pipeline_schedules_path(@project, scope: scope) } = render "tabs", schedule_path_proc: schedule_path_proc, all_schedules: @all_schedules, scope: @scope @@ -21,4 +22,3 @@ - else .light-well .nothing-here-block No schedules - diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index ab6baaf35b6..8607da8fcdd 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -30,7 +30,7 @@ = pluralize @pipeline.statuses.count(:id), "job" - if @pipeline.ref from - = link_to @pipeline.ref, namespace_project_commits_path(@project.namespace, @project, @pipeline.ref), class: "monospace" + = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name" - if @pipeline.duration in = time_interval_in_words(@pipeline.duration) @@ -40,10 +40,10 @@ .well-segment.branch-info .icon-container.commit-icon = custom_icon("icon_commit") - = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace js-details-short" + = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "commit-sha js-details-short" = link_to("#", class: "js-details-expand hidden-xs hidden-sm") do %span.text-expander \... %span.js-details-content.hide - = link_to @pipeline.sha, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace commit-hash-full" + = link_to @pipeline.sha, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "commit-sha commit-hash-full" = clipboard_button(text: @pipeline.sha, title: "Copy commit SHA to clipboard") diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index 14a270a3039..71a8e490c3e 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -11,8 +11,8 @@ .col-sm-10 = hidden_field_tag 'pipeline[ref]', params[:ref] || @project.default_branch = dropdown_tag(params[:ref] || @project.default_branch, - options: { toggle_class: 'js-branch-select wide', - filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search branches", + options: { toggle_class: 'js-branch-select wide git-revision-dropdown-toggle', + filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: "Search branches", data: { selected: params[:ref] || @project.default_branch, field_name: 'pipeline[ref]' } }) .help-block Existing branch name, tag .form-actions diff --git a/app/views/projects/protected_branches/_dropdown.html.haml b/app/views/projects/protected_branches/_dropdown.html.haml index 5af0cc7a2f3..6e9c473494e 100644 --- a/app/views/projects/protected_branches/_dropdown.html.haml +++ b/app/views/projects/protected_branches/_dropdown.html.haml @@ -1,8 +1,8 @@ = f.hidden_field(:name) = dropdown_tag('Select branch or create wildcard', - options: { toggle_class: 'js-protected-branch-select js-filter-submit wide', - filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search protected branches", + options: { toggle_class: 'js-protected-branch-select js-filter-submit wide git-revision-dropdown-toggle', + filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: "Search protected branches", footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_branch_name], diff --git a/app/views/projects/protected_branches/_matching_branch.html.haml b/app/views/projects/protected_branches/_matching_branch.html.haml index 8a5332ca5bb..27896272733 100644 --- a/app/views/projects/protected_branches/_matching_branch.html.haml +++ b/app/views/projects/protected_branches/_matching_branch.html.haml @@ -1,9 +1,10 @@ %tr %td - = link_to matching_branch.name, namespace_project_tree_path(@project.namespace, @project, matching_branch.name) + = link_to matching_branch.name, project_ref_path(@project, matching_branch.name), class: 'ref-name' + - if @project.root_ref?(matching_branch.name) %span.label.label-info.prepend-left-5 default %td - commit = @project.commit(matching_branch.name) - = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id') + = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha') = time_ago_with_tooltip(commit.committed_date) diff --git a/app/views/projects/protected_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml index b2a6b8469a3..0f80de94392 100644 --- a/app/views/projects/protected_branches/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_protected_branch.html.haml @@ -1,6 +1,7 @@ %tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } } %td - = protected_branch.name + %span.ref-name= protected_branch.name + - if @project.root_ref?(protected_branch.name) %span.label.label-info.prepend-left-5 default %td @@ -9,7 +10,7 @@ = link_to pluralize(matching_branches.count, "matching branch"), namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) - else - if commit = protected_branch.commit - = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id') + = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha') = time_ago_with_tooltip(commit.committed_date) - else (branch was removed from repository) diff --git a/app/views/projects/protected_branches/show.html.haml b/app/views/projects/protected_branches/show.html.haml index f8cfe5e4b11..a806a0756ec 100644 --- a/app/views/projects/protected_branches/show.html.haml +++ b/app/views/projects/protected_branches/show.html.haml @@ -2,7 +2,7 @@ .row.prepend-top-default.append-bottom-default .col-lg-3 - %h4.prepend-top-0 + %h4.prepend-top-0.ref-name = @protected_ref.name .col-lg-9 diff --git a/app/views/projects/protected_tags/_dropdown.html.haml b/app/views/projects/protected_tags/_dropdown.html.haml index c50515cfe06..c8531f96f97 100644 --- a/app/views/projects/protected_tags/_dropdown.html.haml +++ b/app/views/projects/protected_tags/_dropdown.html.haml @@ -1,8 +1,8 @@ = f.hidden_field(:name) = dropdown_tag('Select tag or create wildcard', - options: { toggle_class: 'js-protected-tag-select js-filter-submit wide', - filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header", placeholder: "Search protected tag", + options: { toggle_class: 'js-protected-tag-select js-filter-submit wide git-revision-dropdown-toggle', + filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header git-revision-dropdown", placeholder: "Search protected tag", footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_tag_name], diff --git a/app/views/projects/protected_tags/_matching_tag.html.haml b/app/views/projects/protected_tags/_matching_tag.html.haml index 97e5cd6f9d2..f17353df122 100644 --- a/app/views/projects/protected_tags/_matching_tag.html.haml +++ b/app/views/projects/protected_tags/_matching_tag.html.haml @@ -1,9 +1,10 @@ %tr %td - = link_to matching_tag.name, namespace_project_tree_path(@project.namespace, @project, matching_tag.name) + = link_to matching_tag.name, project_ref_path(@project, matching_tag.name), class: 'ref-name' + - if @project.root_ref?(matching_tag.name) %span.label.label-info.prepend-left-5 default %td - commit = @project.commit(matching_tag.name) - = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id') + = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha') = time_ago_with_tooltip(commit.committed_date) diff --git a/app/views/projects/protected_tags/_protected_tag.html.haml b/app/views/projects/protected_tags/_protected_tag.html.haml index 26bd3a1f5ed..54249ec0db1 100644 --- a/app/views/projects/protected_tags/_protected_tag.html.haml +++ b/app/views/projects/protected_tags/_protected_tag.html.haml @@ -1,6 +1,7 @@ %tr.js-protected-tag-edit-form{ data: { url: namespace_project_protected_tag_path(@project.namespace, @project, protected_tag) } } %td - = protected_tag.name + %span.ref-name= protected_tag.name + - if @project.root_ref?(protected_tag.name) %span.label.label-info.prepend-left-5 default %td @@ -9,7 +10,7 @@ = link_to pluralize(matching_tags.count, "matching tag"), namespace_project_protected_tag_path(@project.namespace, @project, protected_tag) - else - if commit = protected_tag.commit - = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id') + = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha') = time_ago_with_tooltip(commit.committed_date) - else (tag was removed from repository) diff --git a/app/views/projects/protected_tags/show.html.haml b/app/views/projects/protected_tags/show.html.haml index 63743f28b3c..94c3612a449 100644 --- a/app/views/projects/protected_tags/show.html.haml +++ b/app/views/projects/protected_tags/show.html.haml @@ -2,7 +2,7 @@ .row.prepend-top-default.append-bottom-default .col-lg-3 - %h4.prepend-top-0 + %h4.prepend-top-0.ref-name = @protected_ref.name .col-lg-9 diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml index deeadb609f6..674f87e8220 100644 --- a/app/views/projects/runners/_runner.html.haml +++ b/app/views/projects/runners/_runner.html.haml @@ -1,15 +1,18 @@ %li.runner{ id: dom_id(runner) } %h4 = runner_status_icon(runner) - %span.monospace - - if @project_runners.include?(runner) - = link_to runner.short_sha, runner_path(runner) - - if runner.locked? - = icon('lock', class: 'has-tooltip', title: 'Locked to current projects') - %small - = link_to edit_namespace_project_runner_path(@project.namespace, @project, runner) do - %i.fa.fa-edit.btn - - else + + - if @project_runners.include?(runner) + = link_to runner.short_sha, runner_path(runner), class: 'commit-sha' + + - if runner.locked? + = icon('lock', class: 'has-tooltip', title: 'Locked to current projects') + + %small + = link_to edit_namespace_project_runner_path(@project.namespace, @project, runner) do + %i.fa.fa-edit.btn + - else + %span.commit-sha = runner.short_sha .pull-right diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index 4c4f3655b97..44cb734d7b9 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -2,10 +2,9 @@ - release = @releases.find { |release| release.tag == tag.name } %li.flex-row .row-main-content.str-truncated - = link_to namespace_project_tag_path(@project.namespace, @project, tag.name) do - %span.item-title - = icon('tag') - = tag.name + = link_to namespace_project_tag_path(@project.namespace, @project, tag.name), class: 'item-title ref-name' do + = icon('tag') + = tag.name - if protected_tag?(@project, tag) %span.label.label-success diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index 7c607d2956b..52af295bddd 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -1,4 +1,5 @@ - page_title "New Tag" +- default_ref = params[:ref] || @project.default_branch - if @error .alert.alert-danger @@ -16,20 +17,24 @@ = text_field_tag :tag_name, params[:tag_name], required: true, tabindex: 1, autofocus: true, class: 'form-control' .form-group = label_tag :ref, 'Create from', class: 'control-label' - .col-sm-10 - = text_field_tag :ref, params[:ref] || @project.default_branch, required: true, tabindex: 2, class: 'form-control' - .help-block Branch name or commit SHA + .col-sm-10.create-from + .dropdown + = hidden_field_tag :ref, default_ref + = button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide form-control js-branch-select', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do + .text-left.dropdown-toggle-text= default_ref + = render 'shared/ref_dropdown', dropdown_class: 'wide' + .help-block Existing branch name, tag, or commit SHA .form-group = label_tag :message, nil, class: 'control-label' .col-sm-10 - = text_area_tag :message, nil, required: false, tabindex: 3, class: 'form-control', rows: 5 + = text_area_tag :message, @message, required: false, tabindex: 3, class: 'form-control', rows: 5 .help-block Optionally, add a message to the tag. %hr .form-group = label_tag :release_description, 'Release notes', class: 'control-label' .col-sm-10 = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do - = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..." + = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here...", current_text: @release_description = render 'shared/notes/hints' .help-block Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page. .form-actions @@ -37,9 +42,5 @@ = link_to 'Cancel', namespace_project_tags_path(@project.namespace, @project), class: 'btn btn-cancel' :javascript - var availableRefs = #{@project.repository.ref_names.to_json}; - - $("#ref").autocomplete({ - source: availableRefs, - minLength: 1 - }); + window.gl = window.gl || { }; + window.gl.availableRefs = #{@project.repository.ref_names.to_json}; diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index e996ae3e4fc..2b81ce4b9fa 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -6,7 +6,9 @@ .top-area.multi-line .nav-text .title - %span.item-title= @tag.name + %span.item-title.ref-name + = icon('tag') + = @tag.name - if protected_tag?(@project, @tag) %span.label.label-success protected diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml index 01599060844..2c2f64283f5 100644 --- a/app/views/projects/tree/_readme.html.haml +++ b/app/views/projects/tree/_readme.html.haml @@ -1,8 +1,8 @@ %article.file-holder.readme-holder .js-file-title.file-title = blob_icon readme.mode, readme.name - = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, @path, readme.name)) do + = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, readme.path)) do %strong = readme.name - .file-content.wiki - = markup(readme.name, readme.data) + + = render 'projects/blob/viewer', viewer: readme.rich_viewer, viewer_url: namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, readme.path), viewer: :rich, format: :json) diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml index 2497a2d91b1..2e34803b143 100644 --- a/app/views/projects/tree/_tree_content.html.haml +++ b/app/views/projects/tree/_tree_content.html.haml @@ -6,16 +6,6 @@ %th Name %th.hidden-xs .pull-left Last commit - .last-commit.hidden-sm.pull-left - %i.fa.fa-angle-right - %small.light - = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @commit), class: "monospace" - = clipboard_button(text: @commit.id, title: "Copy commit SHA to clipboard") - = time_ago_with_tooltip(@commit.committed_date) - \- - = @commit.full_title - %small.commit-history-link-spacer | - = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), class: 'commit-history-link' %th.text-right Last Update - if @path.present? %tr.tree-item diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 396d1ecd77b..e4d9e24f56e 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -1,5 +1,8 @@ .tree-controls = render 'projects/find_file_link' + + = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), class: 'btn btn-grouped' + = render 'projects/buttons/download', project: @project, ref: @ref .tree-ref-holder diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index 910d765aed0..42700c237fc 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -7,4 +7,13 @@ = render 'projects/last_push' %div{ class: container_class } - = render 'projects/files' + #tree-holder.tree-holder.clearfix + .nav-block + = render 'projects/tree/tree_header', tree: @tree + + .info-well.hidden-xs.append-bottom-default + .well-segment + %ul.blob-commit-info + = render 'projects/commits/commit', commit: @commit, project: @project, ref: @ref + + = render 'projects/tree/tree_content', tree: @tree diff --git a/app/views/projects/wikis/_sidebar.html.haml b/app/views/projects/wikis/_sidebar.html.haml index 713b758727e..c2f9e65015d 100644 --- a/app/views/projects/wikis/_sidebar.html.haml +++ b/app/views/projects/wikis/_sidebar.html.haml @@ -1,4 +1,4 @@ -%aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar.js-right-sidebar{ data: { "offset-top" => "102", "spy" => "affix" } } +%aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar.js-right-sidebar{ data: { "offset-top" => "50", "spy" => "affix" } } .block.wiki-sidebar-header.append-bottom-default %a.gutter-toggle.pull-right.visible-xs-block.visible-sm-block.js-sidebar-wiki-toggle{ href: "#" } = icon('angle-double-right') diff --git a/app/views/projects/wikis/git_access.html.haml b/app/views/projects/wikis/git_access.html.haml index fb0efd85dcd..68862206248 100644 --- a/app/views/projects/wikis/git_access.html.haml +++ b/app/views/projects/wikis/git_access.html.haml @@ -28,7 +28,7 @@ %h3 Clone your wiki %pre.dark :preserve - git clone #{ content_tag(:span, default_url_to_repo(@project_wiki), class: 'clone')} + git clone #{ content_tag(:span, h(default_url_to_repo(@project_wiki)), class: 'clone')} cd #{h @project_wiki.path} %h3 Start Gollum and edit locally diff --git a/app/views/search/_filter.html.haml b/app/views/search/_filter.html.haml index 938be20c7cf..e43796e9654 100644 --- a/app/views/search/_filter.html.haml +++ b/app/views/search/_filter.html.haml @@ -3,7 +3,7 @@ - if params[:project_id].present? = hidden_field_tag :project_id, params[:project_id] .dropdown - %button.dropdown-menu-toggle.js-search-group-dropdown{ type: "button", data: { toggle: "dropdown", default_label: "Group:" } } + %button.dropdown-menu-toggle.js-search-group-dropdown{ type: "button", data: { toggle: "dropdown", default_label: "Group:", group_id: params[:group_id] } } %span.dropdown-toggle-text Group: - if @group.present? diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index 34a4d7398bc..0992a65f7cd 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -17,7 +17,7 @@ %li = http_clone_button(project) - = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true + = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: 'Project clone URL' } .input-group-btn = clipboard_button(target: '#project_clone', title: "Copy URL to clipboard") diff --git a/app/views/shared/_ref_dropdown.html.haml b/app/views/shared/_ref_dropdown.html.haml index 96f68c80c48..8b2a3bee407 100644 --- a/app/views/shared/_ref_dropdown.html.haml +++ b/app/views/shared/_ref_dropdown.html.haml @@ -1,6 +1,6 @@ - dropdown_class = local_assigns.fetch(:dropdown_class, '') -.dropdown-menu.dropdown-menu-selectable{ class: dropdown_class } +.dropdown-menu.dropdown-menu-selectable.git-revision-dropdown{ class: dropdown_class } = dropdown_title "Select Git revision" = dropdown_filter "Filter by Git revision" = dropdown_content diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml index 9a8252ab087..2029eb5824a 100644 --- a/app/views/shared/_ref_switcher.html.haml +++ b/app/views/shared/_ref_switcher.html.haml @@ -6,8 +6,8 @@ - @options && @options.each do |key, value| = hidden_field_tag key, value, id: nil .dropdown - = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_namespace_project_path(@project.namespace, @project), field_name: 'ref', submit_form_on_click: true }, { toggle_class: "js-project-refs-dropdown" } - .dropdown-menu.dropdown-menu-selectable{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) } + = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_namespace_project_path(@project.namespace, @project), field_name: 'ref', submit_form_on_click: true }, { toggle_class: "js-project-refs-dropdown git-revision-dropdown-toggle" } + .dropdown-menu.dropdown-menu-selectable.git-revision-dropdown{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) } = dropdown_title "Switch branch/tag" = dropdown_filter "Search branches and tags" = dropdown_content diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml index c229d18903f..046b127f73c 100644 --- a/app/views/shared/empty_states/_issues.html.haml +++ b/app/views/shared/empty_states/_issues.html.haml @@ -3,10 +3,10 @@ - has_button = button_path || project_select_button .row.empty-state - .pull-right.col-xs-12{ class: "#{'col-sm-6' if has_button}" } + .col-xs-12 .svg-content = render 'shared/empty_states/icons/issues.svg' - .col-xs-12{ class: "#{'col-sm-6' if has_button}" } + .col-xs-12.text-center .text-content - if has_button && current_user %h4 @@ -20,4 +20,3 @@ - else .text-center %h4 There are no issues to show. - = link_to 'New issue', button_path, class: 'btn btn-new', title: 'New issue', id: 'new_issue_link' diff --git a/app/views/shared/empty_states/_labels.html.haml b/app/views/shared/empty_states/_labels.html.haml index 00fb77bdb3b..5e2f4cf109d 100644 --- a/app/views/shared/empty_states/_labels.html.haml +++ b/app/views/shared/empty_states/_labels.html.haml @@ -1,8 +1,8 @@ .row.empty-state.labels - .pull-right.col-xs-12.col-sm-6 + .col-xs-12 .svg-content = render 'shared/empty_states/icons/labels.svg' - .col-xs-12.col-sm-6 + .col-xs-12.text-center .text-content %h4 Labels can be applied to issues and merge requests to categorize them. %p You can also star a label to make it a priority label. diff --git a/app/views/shared/empty_states/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml index 7f2f99f3406..3e64f403b8b 100644 --- a/app/views/shared/empty_states/_merge_requests.html.haml +++ b/app/views/shared/empty_states/_merge_requests.html.haml @@ -3,10 +3,10 @@ - has_button = button_path || project_select_button .row.empty-state.merge-requests - .col-xs-12{ class: "#{'col-sm-6 pull-right' if has_button}" } + .col-xs-12 .svg-content = render 'shared/empty_states/icons/merge_requests.svg' - .col-xs-12{ class: "#{'col-sm-6' if has_button}" } + .col-xs-12.text-center .text-content - if has_button %h4 diff --git a/app/views/shared/empty_states/icons/_pipelines_empty.svg b/app/views/shared/empty_states/icons/_pipelines_empty.svg index 8119d5bebe0..7c672538097 100644 --- a/app/views/shared/empty_states/icons/_pipelines_empty.svg +++ b/app/views/shared/empty_states/icons/_pipelines_empty.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 150"><g fill="none" fill-rule="evenodd" transform="translate(0-3)"><g transform="translate(0 105)"><g fill="#e5e5e5"><rect width="78" height="4" x="34" y="21" opacity=".5" rx="2"/><path d="m152 23c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2"/></g><g transform="translate(0 4)"><path fill="#98d7b2" fill-rule="nonzero" d="m19 38c-10.493 0-19-8.507-19-19 0-10.493 8.507-19 19-19 10.493 0 19 8.507 19 19 0 10.493-8.507 19-19 19m0-4c8.284 0 15-6.716 15-15 0-8.284-6.716-15-15-15-8.284 0-15 6.716-15 15 0 8.284 6.716 15 15 15"/><path fill="#31af64" d="m17.07 21.02l-2.829-2.829c-.786-.786-2.047-.781-2.828 0-.786.786-.781 2.047 0 2.828l4.243 4.243c.392.392.902.587 1.412.588.512.002 1.021-.193 1.41-.582l7.79-7.79c.777-.777.775-2.042-.006-2.823-.786-.786-2.045-.784-2.823-.006l-6.37 6.37"/></g><g fill="#e52c5a" transform="translate(102)"><path fill-rule="nonzero" d="m24 47.5c-12.979 0-23.5-10.521-23.5-23.5 0-12.979 10.521-23.5 23.5-23.5 12.979 0 23.5 10.521 23.5 23.5 0 12.979-10.521 23.5-23.5 23.5m0-5c10.217 0 18.5-8.283 18.5-18.5 0-10.217-8.283-18.5-18.5-18.5-10.217 0-18.5 8.283-18.5 18.5 0 10.217 8.283 18.5 18.5 18.5"/><path d="m28.24 24l2.833-2.833c1.167-1.167 1.167-3.067-.004-4.239-1.169-1.169-3.069-1.173-4.239-.004l-2.833 2.833-2.833-2.833c-1.167-1.167-3.067-1.167-4.239.004-1.169 1.169-1.173 3.069-.004 4.239l2.833 2.833-2.833 2.833c-1.167 1.167-1.167 3.067.004 4.239 1.169 1.169 3.069 1.173 4.239.004l2.833-2.833 2.833 2.833c1.167 1.167 3.067 1.167 4.239-.004 1.169-1.169 1.173-3.069.004-4.239l-2.833-2.833"/></g><path fill="#e5e5e5" fill-rule="nonzero" d="m236 37c-7.732 0-14-6.268-14-14 0-7.732 6.268-14 14-14 7.732 0 14 6.268 14 14 0 7.732-6.268 14-14 14m0-4c5.523 0 10-4.477 10-10 0-5.523-4.477-10-10-10-5.523 0-10 4.477-10 10 0 5.523 4.477 10 10 10"/></g><g transform="translate(69 3)"><path fill="#e5e5e5" fill-rule="nonzero" d="m4 11.99v60.02c0 4.413 3.583 7.99 8 7.99h89.991c4.419 0 8-3.579 8-7.99v-60.02c0-4.413-3.583-7.99-8-7.99h-89.991c-4.419 0-8 3.579-8 7.99m-4 0c0-6.622 5.378-11.99 12-11.99h89.991c6.629 0 12 5.367 12 11.99v60.02c0 6.622-5.378 11.99-12 11.99h-89.991c-6.629 0-12-5.367-12-11.99v-60.02m52.874 80.3l-13.253-15.292h34.76l-13.253 15.292c-2.237 2.582-6.01 2.585-8.253 0m3.02-2.62c.644.743 1.564.743 2.207 0l7.516-8.673h-17.24l7.516 8.673"/><rect width="18" height="6" x="15" y="23" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="39" y="39" fill="#e52c5a" rx="3"/><rect width="18" height="6" x="33" y="55" fill="#e5e5e5" rx="3"/><rect width="12" height="6" x="39" y="23" fill="#fde5d8" rx="3"/><rect width="12" height="6" x="57" y="55" fill="#e52c5a" rx="3"/><rect width="12" height="6" x="15" y="55" fill="#b5a7dd" rx="3"/><rect width="18" height="6" x="81" y="23" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="15" y="39" fill="#fde5d8" rx="3"/><rect width="6" height="6" x="57" y="23" fill="#e52c5a" rx="3"/><g fill="#fde5d8"><rect width="6" height="6" x="69" y="23" rx="3"/><rect width="6" height="6" x="75" y="39" rx="3"/></g><rect width="6" height="6" x="63" y="39" fill="#e52c5a" rx="3"/></g><g transform="matrix(.70711-.70711.70711.70711 84.34 52.5)"><path fill="#6b4fbb" fill-rule="nonzero" d="m28.02 67.48c-15.927-2.825-28.02-16.738-28.02-33.476 0-18.778 15.222-34 34-34 18.778 0 34 15.222 34 34 0 16.738-12.1 30.652-28.02 33.476.015.173.023.347.023.524v21.999c0 3.314-2.693 6-6 6-3.314 0-6-2.682-6-6v-21.999c0-.177.008-.351.023-.524m5.977-7.476c14.359 0 26-11.641 26-26 0-14.359-11.641-26-26-26-14.359 0-26 11.641-26 26 0 14.359 11.641 26 26 26"/><path fill="#fff" fill-opacity=".3" stroke="#6b4fbb" stroke-width="8" d="m31 71c16.569 0 30-13.431 30-30 0-16.569-13.431-30-30-30" transform="matrix(.86603.5-.5.86603 26.663-17.507)"/></g></g></svg>
\ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 150"><g fill="none" fill-rule="evenodd"><g transform="translate(0 102)"><g fill="#e5e5e5"><rect width="74" height="4" x="34" y="21" opacity=".5" rx="2"/><path d="m152 23c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2"/></g><g fill="#31af64" transform="translate(0 4)"><path fill-rule="nonzero" d="m19 38c-10.493 0-19-8.507-19-19 0-10.493 8.507-19 19-19 10.493 0 19 8.507 19 19 0 10.493-8.507 19-19 19m0-4c8.284 0 15-6.716 15-15 0-8.284-6.716-15-15-15-8.284 0-15 6.716-15 15 0 8.284 6.716 15 15 15"/><path d="m17.07 21.02l-2.829-2.829c-.786-.786-2.047-.781-2.828 0-.786.786-.781 2.047 0 2.828l4.243 4.243c.392.392.902.587 1.412.588.512.002 1.021-.193 1.41-.582l7.79-7.79c.777-.777.775-2.042-.006-2.823-.786-.786-2.045-.784-2.823-.006l-6.37 6.37"/></g><g fill="#e52c5a" transform="translate(102)"><path fill-rule="nonzero" d="m24 47.5c-12.979 0-23.5-10.521-23.5-23.5 0-12.979 10.521-23.5 23.5-23.5 12.979 0 23.5 10.521 23.5 23.5 0 12.979-10.521 23.5-23.5 23.5m0-5c10.217 0 18.5-8.283 18.5-18.5 0-10.217-8.283-18.5-18.5-18.5-10.217 0-18.5 8.283-18.5 18.5 0 10.217 8.283 18.5 18.5 18.5"/><path d="m28.24 24l2.833-2.833c1.167-1.167 1.167-3.067-.004-4.239-1.169-1.169-3.069-1.173-4.239-.004l-2.833 2.833-2.833-2.833c-1.167-1.167-3.067-1.167-4.239.004-1.169 1.169-1.173 3.069-.004 4.239l2.833 2.833-2.833 2.833c-1.167 1.167-1.167 3.067.004 4.239 1.169 1.169 3.069 1.173 4.239.004l2.833-2.833 2.833 2.833c1.167 1.167 3.067 1.167 4.239-.004 1.169-1.169 1.173-3.069.004-4.239l-2.833-2.833"/></g><path fill="#e5e5e5" fill-rule="nonzero" d="m236 37c-7.732 0-14-6.268-14-14 0-7.732 6.268-14 14-14 7.732 0 14 6.268 14 14 0 7.732-6.268 14-14 14m0-4c5.523 0 10-4.477 10-10 0-5.523-4.477-10-10-10-5.523 0-10 4.477-10 10 0 5.523 4.477 10 10 10"/></g><g transform="translate(73 4)"><path stroke="#e5e5e5" stroke-width="4" d="m64.82 76h33.18c4.419 0 8-3.579 8-7.99v-60.02c0-4.413-3.583-7.99-8-7.99h-89.991c-4.419 0-8 3.579-8 7.99v60.02c0 4.413 3.583 7.99 8 7.99h31.935l9.263 9.855c1.725 1.835 4.631 1.833 6.354 0l9.263-9.855"/><rect width="18" height="6" x="11" y="19" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="35" y="35" fill="#e52c5a" rx="3"/><rect width="18" height="6" x="29" y="51" fill="#e5e5e5" rx="3"/><rect width="12" height="6" x="35" y="19" fill="#fde5d8" rx="3"/><rect width="12" height="6" x="53" y="51" fill="#e52c5a" rx="3"/><rect width="12" height="6" x="11" y="51" fill="#b5a7dd" rx="3"/><rect width="18" height="6" x="77" y="19" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="11" y="35" fill="#fde5d8" rx="3"/><rect width="6" height="6" x="53" y="19" fill="#e52c5a" rx="3"/><g fill="#fde5d8"><rect width="6" height="6" x="65" y="19" rx="3"/><rect width="6" height="6" x="71" y="35" rx="3"/></g><rect width="6" height="6" x="59" y="35" fill="#e52c5a" rx="3"/></g><path fill="#6b4fbb" fill-rule="nonzero" d="m28.02 67.48c-15.927-2.825-28.02-16.738-28.02-33.476 0-18.778 15.222-34 34-34 18.778 0 34 15.222 34 34 0 16.738-12.1 30.652-28.02 33.476.015.173.023.347.023.524v21.999c0 3.314-2.693 6-6 6-3.314 0-6-2.682-6-6v-21.999c0-.177.008-.351.023-.524m5.977-7.476c14.359 0 26-11.641 26-26 0-14.359-11.641-26-26-26-14.359 0-26 11.641-26 26 0 14.359 11.641 26 26 26" transform="matrix(.70711-.70711.70711.70711 84.34 49.5)"/></g></svg> diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml index 36bbb1148d4..217af7c9fac 100644 --- a/app/views/shared/issuable/_assignees.html.haml +++ b/app/views/shared/issuable/_assignees.html.haml @@ -1,9 +1,8 @@ - max_render = 3 - max = [max_render, issue.assignees.length].min -- issue.assignees.each_with_index do |assignee, index| - - if index < max - = link_to_member(@project, assignee, name: false, title: "Assigned to :name") +- issue.assignees.take(max).each do |assignee| + = link_to_member(@project, assignee, name: false, title: "Assigned to :name") - if issue.assignees.length > max_render - counter = issue.assignees.length - max_render diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 1a12f110945..6cd03f028a9 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -71,7 +71,6 @@ = render 'shared/labels_row', labels: @labels :javascript - new UsersSelect(); new LabelsSelect(); new MilestoneSelect(); new IssueStatusSelect(); diff --git a/app/views/shared/issuable/_milestone_dropdown.html.haml b/app/views/shared/issuable/_milestone_dropdown.html.haml index f0d50828e2a..6750921338a 100644 --- a/app/views/shared/issuable/_milestone_dropdown.html.haml +++ b/app/views/shared/issuable/_milestone_dropdown.html.haml @@ -6,7 +6,7 @@ - if selected.present? || params[:milestone_title].present? = hidden_field_tag(name, name == :milestone_title ? selected_text : selected.id) = dropdown_tag(milestone_dropdown_label(selected_text), options: { title: dropdown_title, toggle_class: "js-milestone-select js-filter-submit #{extra_class}", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", - placeholder: "Search milestones", footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected.try(:title), project_id: project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do + placeholder: "Search milestones", footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected_text, project_id: project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do - if project %ul.dropdown-footer-list - if can? current_user, :admin_milestone, project diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index f7b87171573..0e535117353 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -19,7 +19,7 @@ dropdown_class: "filtered-search-history-dropdown", content_class: "filtered-search-history-dropdown-content", title: "Recent searches" }) do - .js-filtered-search-history-dropdown + .js-filtered-search-history-dropdown{ data: { project_full_path: @project.full_path } } .filtered-search-box-input-container .scroll-container %ul.tokens-container.list-unstyled @@ -150,7 +150,6 @@ - unless type === :boards_modal :javascript - new UsersSelect(); new LabelsSelect(); new MilestoneSelect(); new IssueStatusSelect(); diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 44e624c15a7..ac84fffe831 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -3,7 +3,7 @@ = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('sidebar') -%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "102", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } +%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "50", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } .issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } } - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .block.issuable-sidebar-header @@ -20,55 +20,7 @@ .block.todo.hide-expanded = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true .block.assignee - - if issuable.instance_of?(Issue) - #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]" } } - - else - .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } - - if issuable.assignee - = link_to_member(@project, issuable.assignee, size: 24) - - else - = icon('user', 'aria-hidden': 'true') - .title.hide-collapsed - Assignee - = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - - if can_edit_issuable - = link_to 'Edit', '#', class: 'edit-link pull-right' - .value.hide-collapsed - - if issuable.assignee - = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do - - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee) - %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } - = icon('exclamation-triangle', 'aria-hidden': 'true') - %span.username - = issuable.assignee.to_reference - - else - %span.assign-yourself.no-value - No assignee - - if can_edit_issuable - \- - %a.js-assign-yourself{ href: '#' } - assign yourself - - .selectbox.hide-collapsed - - issuable.assignees.each do |assignee| - = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil - - - options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } } - - - if issuable.instance_of?(Issue) - - if issuable.assignees.length == 0 - = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil - - title = 'Select assignee' - - options[:toggle_class] += ' js-multiselect js-save-user-data' - - options[:data][:field_name] = "#{issuable.to_ability_name}[assignee_ids][]" - - options[:data][:multi_select] = true - - options[:data]['dropdown-title'] = title - - options[:data]['dropdown-header'] = 'Assignee' - - options[:data]['max-select'] = 1 - - else - - title = 'Select assignee' - - = dropdown_tag(title, options: options) + = render "shared/issuable/sidebar_assignees", issuable: issuable, can_edit_issuable: can_edit_issuable .block.milestone .sidebar-collapsed-icon = icon('clock-o', 'aria-hidden': 'true') @@ -91,7 +43,7 @@ .selectbox.hide-collapsed = f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil - = dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true }}) + = dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true, default_no: true, selected: (issuable.milestone.name if issuable.milestone), null_default: true }}) - if issuable.has_attribute?(:time_estimate) #issuable-time-tracker.block // Fallback while content is loading diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml new file mode 100644 index 00000000000..e9ce7b7ce9c --- /dev/null +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -0,0 +1,49 @@ +- if issuable.is_a?(Issue) + #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]" } } +- else + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } + - if issuable.assignee + = link_to_member(@project, issuable.assignee, size: 24) + - else + = icon('user', 'aria-hidden': 'true') + .title.hide-collapsed + Assignee + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.hide-collapsed + - if issuable.assignee + = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do + - if !issuable.can_be_merged_by?(issuable.assignee) + %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } + = icon('exclamation-triangle', 'aria-hidden': 'true') + %span.username + = issuable.assignee.to_reference + - else + %span.assign-yourself.no-value + No assignee + - if can_edit_issuable + \- + %a.js-assign-yourself{ href: '#' } + assign yourself + +.selectbox.hide-collapsed + - issuable.assignees.each do |assignee| + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil + + - options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } } + + - title = 'Select assignee' + + - if issuable.is_a?(Issue) + - unless issuable.assignees.any? + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil + - options[:toggle_class] += ' js-multiselect js-save-user-data' + - data = { field_name: "#{issuable.to_ability_name}[assignee_ids][]" } + - data[:multi_select] = true + - data['dropdown-title'] = title + - data['dropdown-header'] = 'Assignee' + - data['max-select'] = 1 + - options[:data].merge!(data) + + = dropdown_tag(title, options: options) diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index f57b4d899ce..203d2adc8db 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -10,14 +10,14 @@ = form.label :source_branch, class: 'control-label' .col-sm-10 .issuable-form-select-holder - = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2', disabled: true }) + = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2 ref-name', disabled: true }) .form-group = form.label :target_branch, class: 'control-label' .col-sm-10.target-branch-select-dropdown-container .issuable-form-select-holder = form.select(:target_branch, issuable.target_branches, { include_blank: true }, - { class: 'target_branch js-target-branch-select', + { class: 'target_branch js-target-branch-select ref-name', disabled: issuable.new_record?, data: { placeholder: "Select branch" }}) - if issuable.new_record? diff --git a/app/views/shared/issuable/form/_issue_assignee.html.haml b/app/views/shared/issuable/form/_issue_assignee.html.haml index c33474ac3b4..66091d95a91 100644 --- a/app/views/shared/issuable/form/_issue_assignee.html.haml +++ b/app/views/shared/issuable/form/_issue_assignee.html.haml @@ -1,8 +1,9 @@ - issue = issuable +- assignees = issue.assignees .block.assignee .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee_list) } - - if issue.assignees.any? - - issue.assignees.each do |assignee| + - if assignees.any? + - assignees.each do |assignee| = link_to_member(@project, assignee, size: 24) - else = icon('user', 'aria-hidden': 'true') @@ -12,8 +13,8 @@ - if can_edit_issuable = link_to 'Edit', '#', class: 'edit-link pull-right' .value.hide-collapsed - - if issue.assignees.any? - - issue.assignees.each do |assignee| + - if assignees.any? + - assignees.each do |assignee| = link_to_member(@project, assignee, size: 32, extra_class: 'bold') do %span.username = assignee.to_reference diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml index 03309722326..d23f79be2be 100644 --- a/app/views/shared/issuable/form/_merge_params.html.haml +++ b/app/views/shared/issuable/form/_merge_params.html.haml @@ -5,12 +5,3 @@ -# This check is duplicated below, to avoid conflicts with EE. - return unless issuable.can_remove_source_branch?(current_user) - -.form-group - .col-sm-10.col-sm-offset-2 - - if issuable.can_remove_source_branch?(current_user) - .checkbox - = label_tag 'merge_request[force_remove_source_branch]' do - = hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil - = check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch? - Remove source branch when merge request is accepted. diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index 9281a515744..1608bd59cf1 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -11,26 +11,9 @@ %div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") } .form-group.issue-assignee - if issuable.is_a?(Issue) - = form.label :assignee_ids, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" - .col-sm-10{ class: ("col-lg-8" if has_due_date) } - .issuable-form-select-holder.selectbox - - issuable.assignees.each do |assignee| - = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { meta: assignee.name } - - - if issuable.assignees.length === 0 - = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' } - - = dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_dropdown_options(issuable,false)) - = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}" + = render "shared/issuable/form/metadata_issue_assignee", issuable: issuable, form: form, has_due_date: has_due_date - else - = form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" - .col-sm-10{ class: ("col-lg-8" if has_due_date) } - .issuable-form-select-holder - = form.hidden_field :assignee_id - - = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", - placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) - = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}" + = render "shared/issuable/form/metadata_merge_request_assignee", issuable: issuable, form: form, has_due_date: has_due_date .form-group.issue-milestone = form.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}" .col-sm-10{ class: ("col-lg-8" if has_due_date) } diff --git a/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml new file mode 100644 index 00000000000..8119f19291b --- /dev/null +++ b/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml @@ -0,0 +1,11 @@ += form.label :assignee_ids, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" +.col-sm-10{ class: ("col-lg-8" if has_due_date) } + .issuable-form-select-holder.selectbox + - issuable.assignees.each do |assignee| + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { meta: assignee.name } + + - if issuable.assignees.length === 0 + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' } + + = dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_dropdown_options(issuable,false)) + = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}" diff --git a/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml new file mode 100644 index 00000000000..d0ea4e149df --- /dev/null +++ b/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml @@ -0,0 +1,8 @@ += form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" +.col-sm-10{ class: ("col-lg-8" if has_due_date) } + .issuable-form-select-holder + = form.hidden_field :assignee_id + + = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", + placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) + = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}" diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index 5e8a2a0f5d8..9bb87640319 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -1,4 +1,4 @@ -- affix_offset = local_assigns.fetch(:affix_offset, "102") +- affix_offset = local_assigns.fetch(:affix_offset, "50") - project = local_assigns[:project] %aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index 5c1156b06fb..87aae793966 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -29,6 +29,8 @@ - if note.system %span.system-note-message = note.redacted_note_html + .original-note-content.hidden + = note.note %a{ href: "##{dom_id(note)}" } = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') - unless note.system? diff --git a/app/views/shared/notifications/_custom_notifications.html.haml b/app/views/shared/notifications/_custom_notifications.html.haml index 708adbc38f1..183ed34fba1 100644 --- a/app/views/shared/notifications/_custom_notifications.html.haml +++ b/app/views/shared/notifications/_custom_notifications.html.haml @@ -1,9 +1,9 @@ -.modal.fade{ tabindex: "-1", role: "dialog", id: notifications_menu_identifier("modal", notification_setting), aria: { labelledby: "custom-notifications-title" } } +.modal.fade{ tabindex: "-1", role: "dialog", id: notifications_menu_identifier("modal", notification_setting), "aria-labelledby": "custom-notifications-title" } .modal-dialog .modal-content .modal-header - %button.close{ type: "button", data: { dismiss: "modal" }, aria: { label: "close" } } - %span{ aria: { hidden: "true" } } × + %button.close{ type: "button", "aria-label": "close", data: { dismiss: "modal" } } + %span{ "aria-hidden": "true" } } × %h4#custom-notifications-title.modal-title Custom notification events diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb index c9658b3fe17..22f67fa9e9f 100644 --- a/app/workers/irker_worker.rb +++ b/app/workers/irker_worker.rb @@ -142,10 +142,10 @@ class IrkerWorker end def files_count(commit) - diffs = commit.raw_diffs(deltas_only: true) + diff_size = commit.raw_deltas.size - files = "#{diffs.real_size} file" - files += 's' if diffs.size > 1 + files = "#{diff_size} file" + files += 's' if diff_size > 1 files end diff --git a/app/workers/namespaceless_project_destroy_worker.rb b/app/workers/namespaceless_project_destroy_worker.rb new file mode 100644 index 00000000000..bfae0c77700 --- /dev/null +++ b/app/workers/namespaceless_project_destroy_worker.rb @@ -0,0 +1,43 @@ +# Worker to destroy projects that do not have a namespace +# +# It destroys everything it can without having the info about the namespace it +# used to belong to. Projects in this state should be rare. +# The worker will reject doing anything for projects that *do* have a +# namespace. For those use ProjectDestroyWorker instead. +class NamespacelessProjectDestroyWorker + include Sidekiq::Worker + include DedicatedSidekiqQueue + + def self.bulk_perform_async(args_list) + Sidekiq::Client.push_bulk('class' => self, 'queue' => sidekiq_options['queue'], 'args' => args_list) + end + + def perform(project_id) + begin + project = Project.unscoped.find(project_id) + rescue ActiveRecord::RecordNotFound + return + end + return unless project.namespace_id.nil? # Reject doing anything for projects that *do* have a namespace + + project.team.truncate + + unlink_fork(project) if project.forked? + + # Override Project#remove_pages for this instance so it doesn't do anything + def project.remove_pages + end + + project.destroy! + end + + private + + def unlink_fork(project) + merge_requests = project.forked_from_project.merge_requests.opened.from_project(project) + + merge_requests.update_all(state: 'closed') + + project.forked_project_link.destroy + end +end diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb index a449a765f7b..7eb0e84acb2 100644 --- a/app/workers/pipeline_schedule_worker.rb +++ b/app/workers/pipeline_schedule_worker.rb @@ -3,8 +3,14 @@ class PipelineScheduleWorker include CronjobQueue def perform - Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now).find_each do |schedule| + Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now) + .preload(:owner, :project).find_each do |schedule| begin + unless schedule.runnable_by_owner? + schedule.deactivate! + next + end + Ci::CreatePipelineService.new(schedule.project, schedule.owner, ref: schedule.ref) diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 127d8dfbb61..c29571d3c62 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -20,13 +20,32 @@ class PostReceive # Nothing defined here yet. else process_project_changes(post_received) + process_repository_update(post_received) end end - def process_project_changes(post_received) - post_received.changes.each do |change| - oldrev, newrev, ref = change.strip.split(' ') + def process_repository_update(post_received) + changes = [] + refs = Set.new + + post_received.changes_refs do |oldrev, newrev, ref| + @user ||= post_received.identify(newrev) + unless @user + log("Triggered hook for non-existing user \"#{post_received.identifier}\"") + return false + end + + changes << Gitlab::DataBuilder::Repository.single_change(oldrev, newrev, ref) + refs << ref + end + + hook_data = Gitlab::DataBuilder::Repository.update(post_received.project, @user, changes, refs.to_a) + SystemHooksService.new.execute_hooks(hook_data, :repository_update_hooks) + end + + def process_project_changes(post_received) + post_received.changes_refs do |oldrev, newrev, ref| @user ||= post_received.identify(newrev) unless @user diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index 2f7967cf531..d6ed0e253ad 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -23,6 +23,9 @@ class ProcessCommitWorker return unless user commit = build_commit(project, commit_hash) + + return unless commit.matches_cross_reference_regex? + author = commit.author || user process_commit_message(project, commit, user, author, default) diff --git a/app/workers/repository_check/clear_worker.rb b/app/workers/repository_check/clear_worker.rb index 1f1b38540ee..85bc9103538 100644 --- a/app/workers/repository_check/clear_worker.rb +++ b/app/workers/repository_check/clear_worker.rb @@ -8,7 +8,7 @@ module RepositoryCheck Project.select(:id).find_in_batches(batch_size: 100) do |batch| Project.where(id: batch.map(&:id)).update_all( last_repository_check_failed: nil, - last_repository_check_at: nil, + last_repository_check_at: nil ) end end diff --git a/app/workers/repository_check/single_repository_worker.rb b/app/workers/repository_check/single_repository_worker.rb index 3d8bfc6fc6c..164586cf0b7 100644 --- a/app/workers/repository_check/single_repository_worker.rb +++ b/app/workers/repository_check/single_repository_worker.rb @@ -7,7 +7,7 @@ module RepositoryCheck project = Project.find(project_id) project.update_columns( last_repository_check_failed: !check(project), - last_repository_check_at: Time.now, + last_repository_check_at: Time.now ) end diff --git a/changelogs/unreleased/20517-delete-projects-issuescontroller-redirect_old.yml b/changelogs/unreleased/20517-delete-projects-issuescontroller-redirect_old.yml new file mode 100644 index 00000000000..1f3ab3a2c10 --- /dev/null +++ b/changelogs/unreleased/20517-delete-projects-issuescontroller-redirect_old.yml @@ -0,0 +1,4 @@ +--- +title: Remove redirect for old issue url containing id instead of iid +merge_request: 11135 +author: blackst0ne diff --git a/changelogs/unreleased/2247-emails-forwarded-to-service-desk-email-don-t-come.yml b/changelogs/unreleased/2247-emails-forwarded-to-service-desk-email-don-t-come.yml deleted file mode 100644 index f062143960e..00000000000 --- a/changelogs/unreleased/2247-emails-forwarded-to-service-desk-email-don-t-come.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Handle incoming emails from aliases correctly -merge_request: -author: diff --git a/changelogs/unreleased/24373-warning-message-go-away.yml b/changelogs/unreleased/24373-warning-message-go-away.yml new file mode 100644 index 00000000000..c0f2fd260ba --- /dev/null +++ b/changelogs/unreleased/24373-warning-message-go-away.yml @@ -0,0 +1,4 @@ +--- +title: 'Notes: Warning message should go away once resolved' +merge_request: 10823 +author: Jacopo Beschi @jacopo-beschi diff --git a/changelogs/unreleased/26325-system-hooks.yml b/changelogs/unreleased/26325-system-hooks.yml new file mode 100644 index 00000000000..62b8adaeccd --- /dev/null +++ b/changelogs/unreleased/26325-system-hooks.yml @@ -0,0 +1,4 @@ +--- +title: 'Backported new SystemHook event: `repository_update`' +merge_request: 11140 +author: diff --git a/changelogs/unreleased/30286-ci-badge-component.yml b/changelogs/unreleased/30286-ci-badge-component.yml new file mode 100644 index 00000000000..13c2a4598c8 --- /dev/null +++ b/changelogs/unreleased/30286-ci-badge-component.yml @@ -0,0 +1,4 @@ +--- +title: Refactor all CI vue badges to use the same vue component +merge_request: +author: diff --git a/changelogs/unreleased/30827-changes-to-audit-log.yml b/changelogs/unreleased/30827-changes-to-audit-log.yml new file mode 100644 index 00000000000..32db3bf8e95 --- /dev/null +++ b/changelogs/unreleased/30827-changes-to-audit-log.yml @@ -0,0 +1,4 @@ +--- +title: Renamed users 'Audit Log'' to 'Authentication Log' +merge_request: 11400 +author: diff --git a/changelogs/unreleased/30949-empty-states.yml b/changelogs/unreleased/30949-empty-states.yml new file mode 100644 index 00000000000..bef87a954b7 --- /dev/null +++ b/changelogs/unreleased/30949-empty-states.yml @@ -0,0 +1,4 @@ +--- +title: Center all empty states +merge_request: +author: diff --git a/changelogs/unreleased/30973-network-graph-sorted-by-date-and-topo.yml b/changelogs/unreleased/30973-network-graph-sorted-by-date-and-topo.yml deleted file mode 100644 index 42426c1865e..00000000000 --- a/changelogs/unreleased/30973-network-graph-sorted-by-date-and-topo.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Sort the network graph both by commit date and topographically -merge_request: 11057 -author: diff --git a/changelogs/unreleased/31106-tabs-alignment.yml b/changelogs/unreleased/31106-tabs-alignment.yml new file mode 100644 index 00000000000..53da08cc32d --- /dev/null +++ b/changelogs/unreleased/31106-tabs-alignment.yml @@ -0,0 +1,4 @@ +--- +title: prevent nav tabs from wrapping to new line +merge_request: +author: diff --git a/changelogs/unreleased/31157-respect-project-features-in-wiki-search.yml b/changelogs/unreleased/31157-respect-project-features-in-wiki-search.yml new file mode 100644 index 00000000000..721bb435a2e --- /dev/null +++ b/changelogs/unreleased/31157-respect-project-features-in-wiki-search.yml @@ -0,0 +1,4 @@ +--- +title: Enforce project features when searching blobs and wikis +merge_request: +author: diff --git a/changelogs/unreleased/31274-creating-schedule-trigger--causes-http-500-when-accessing-settings-ci_cd.yml b/changelogs/unreleased/31274-creating-schedule-trigger--causes-http-500-when-accessing-settings-ci_cd.yml deleted file mode 100644 index b0c33ab3fa4..00000000000 --- a/changelogs/unreleased/31274-creating-schedule-trigger--causes-http-500-when-accessing-settings-ci_cd.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fix error on CI/CD Settings page related to invalid pipeline trigger -merge_request: 10948 -author: dosuken123 diff --git a/changelogs/unreleased/31384-new-issue-button-on-no-results-page-after-search-doesn-t-go-to-correct-form.yml b/changelogs/unreleased/31384-new-issue-button-on-no-results-page-after-search-doesn-t-go-to-correct-form.yml new file mode 100644 index 00000000000..8d586616e07 --- /dev/null +++ b/changelogs/unreleased/31384-new-issue-button-on-no-results-page-after-search-doesn-t-go-to-correct-form.yml @@ -0,0 +1,4 @@ +--- +title: Remove 'New issue' button when issues search returns no results. +merge_request: !11263 +author: diff --git a/changelogs/unreleased/31474-issue-boards-sidebar-milestone-dropdown-should-not-be-multi-select.yml b/changelogs/unreleased/31474-issue-boards-sidebar-milestone-dropdown-should-not-be-multi-select.yml new file mode 100644 index 00000000000..88e79e3b6ea --- /dev/null +++ b/changelogs/unreleased/31474-issue-boards-sidebar-milestone-dropdown-should-not-be-multi-select.yml @@ -0,0 +1,4 @@ +--- +title: Disallow multiple selections for Milestone dropdown +merge_request: 11084 +author: diff --git a/changelogs/unreleased/31554-update-rufus-scheduler-and-sidekiq.yml b/changelogs/unreleased/31554-update-rufus-scheduler-and-sidekiq.yml new file mode 100644 index 00000000000..0a36b52d561 --- /dev/null +++ b/changelogs/unreleased/31554-update-rufus-scheduler-and-sidekiq.yml @@ -0,0 +1,5 @@ +--- +title: Update gem sidekiq-cron from 0.4.4 to 0.6.0 and rufus-scheduler from 3.1.10 + to 3.4.0 +merge_request: 10976 +author: dosuken123 diff --git a/changelogs/unreleased/31625-tag-editor-loses-all-inputs-when-you-try-to-add-a-tag-that-already-exists.yml b/changelogs/unreleased/31625-tag-editor-loses-all-inputs-when-you-try-to-add-a-tag-that-already-exists.yml new file mode 100644 index 00000000000..aae760b0ef5 --- /dev/null +++ b/changelogs/unreleased/31625-tag-editor-loses-all-inputs-when-you-try-to-add-a-tag-that-already-exists.yml @@ -0,0 +1,4 @@ +--- +title: Keep input data after creating a tag that already exists +merge_request: 11155 +author: diff --git a/changelogs/unreleased/31781-print-rendered-files-not-possible.yml b/changelogs/unreleased/31781-print-rendered-files-not-possible.yml new file mode 100644 index 00000000000..14915823ff7 --- /dev/null +++ b/changelogs/unreleased/31781-print-rendered-files-not-possible.yml @@ -0,0 +1,4 @@ +--- +title: Include the blob content when printing a blob page +merge_request: 11247 +author: diff --git a/changelogs/unreleased/31886-remover-comment-load-spinner.yml b/changelogs/unreleased/31886-remover-comment-load-spinner.yml new file mode 100644 index 00000000000..4b36538064a --- /dev/null +++ b/changelogs/unreleased/31886-remover-comment-load-spinner.yml @@ -0,0 +1,4 @@ +--- +title: Remove spinner from loading comment +merge_request: +author: diff --git a/changelogs/unreleased/31902-namespace-recent-searches-to-project.yml b/changelogs/unreleased/31902-namespace-recent-searches-to-project.yml new file mode 100644 index 00000000000..e00eb6d8f72 --- /dev/null +++ b/changelogs/unreleased/31902-namespace-recent-searches-to-project.yml @@ -0,0 +1,4 @@ +--- +title: Scope issue/merge request recent searches to project +merge_request: +author: diff --git a/changelogs/unreleased/32178-prevent-merge-on-sha-change.yml b/changelogs/unreleased/32178-prevent-merge-on-sha-change.yml new file mode 100644 index 00000000000..d3208973de6 --- /dev/null +++ b/changelogs/unreleased/32178-prevent-merge-on-sha-change.yml @@ -0,0 +1,4 @@ +--- +title: Add state to MR widget that prevent merges when branch changes after page load +merge_request: 11316 +author: diff --git a/changelogs/unreleased/32219-speed-up-yarn-install-in-ci-by-utilizing-inter-pipeline-cache.yml b/changelogs/unreleased/32219-speed-up-yarn-install-in-ci-by-utilizing-inter-pipeline-cache.yml new file mode 100644 index 00000000000..7fb3cb3a30b --- /dev/null +++ b/changelogs/unreleased/32219-speed-up-yarn-install-in-ci-by-utilizing-inter-pipeline-cache.yml @@ -0,0 +1,4 @@ +--- +title: Cache npm modules between pipelines with yarn to speed up setup-test-env +merge_request: 11343 +author: diff --git a/changelogs/unreleased/adam-influxdb-hostname.yml b/changelogs/unreleased/adam-influxdb-hostname.yml new file mode 100644 index 00000000000..ab201ae7894 --- /dev/null +++ b/changelogs/unreleased/adam-influxdb-hostname.yml @@ -0,0 +1,4 @@ +--- +title: Allow GitLab instance to start when InfluxDB hostname cannot be resolved +merge_request: 11356 +author: diff --git a/changelogs/unreleased/add-index-for-auto_canceled_by_id-mysql.yml b/changelogs/unreleased/add-index-for-auto_canceled_by_id-mysql.yml new file mode 100644 index 00000000000..eac78e9ee1f --- /dev/null +++ b/changelogs/unreleased/add-index-for-auto_canceled_by_id-mysql.yml @@ -0,0 +1,4 @@ +--- +title: Add indices for auto_canceled_by_id for ci_pipelines and ci_builds on PostgreSQL +merge_request: 11034 +author: diff --git a/changelogs/unreleased/allow_numeric_values_in_gitlab_ci_yml.yml b/changelogs/unreleased/allow_numeric_values_in_gitlab_ci_yml.yml new file mode 100644 index 00000000000..8c7fa53a18b --- /dev/null +++ b/changelogs/unreleased/allow_numeric_values_in_gitlab_ci_yml.yml @@ -0,0 +1,4 @@ +--- +title: Allow numeric values in gitlab-ci.yml +merge_request: 10607 +author: blackst0ne diff --git a/changelogs/unreleased/branch-name-escape.yml b/changelogs/unreleased/branch-name-escape.yml new file mode 100644 index 00000000000..bf46235fd79 --- /dev/null +++ b/changelogs/unreleased/branch-name-escape.yml @@ -0,0 +1,4 @@ +--- +title: Fixed branches dropdown rendering branch names as HTML +merge_request: +author: diff --git a/changelogs/unreleased/bvl-markup-pipeline.yml b/changelogs/unreleased/bvl-markup-pipeline.yml new file mode 100644 index 00000000000..d73bad03340 --- /dev/null +++ b/changelogs/unreleased/bvl-markup-pipeline.yml @@ -0,0 +1,4 @@ +--- +title: Make Asciidoc & other markup go through pipeline to prevent XSS +merge_request: +author: diff --git a/changelogs/unreleased/bvl-validate-urls-in-markdown-using-uri.yml b/changelogs/unreleased/bvl-validate-urls-in-markdown-using-uri.yml new file mode 100644 index 00000000000..03c4e531d73 --- /dev/null +++ b/changelogs/unreleased/bvl-validate-urls-in-markdown-using-uri.yml @@ -0,0 +1,4 @@ +--- +title: Validate URLs in markdown using URI to detect the host correctly +merge_request: +author: diff --git a/changelogs/unreleased/disable-usage-ping-2.yml b/changelogs/unreleased/disable-usage-ping-2.yml new file mode 100644 index 00000000000..4abd325f120 --- /dev/null +++ b/changelogs/unreleased/disable-usage-ping-2.yml @@ -0,0 +1,4 @@ +--- +title: Add hostname to usage ping +merge_request: +author: diff --git a/changelogs/unreleased/disable-usage-ping.yml b/changelogs/unreleased/disable-usage-ping.yml new file mode 100644 index 00000000000..5438eb56dba --- /dev/null +++ b/changelogs/unreleased/disable-usage-ping.yml @@ -0,0 +1,4 @@ +--- +title: Allow usage ping to be disabled completely in gitlab.yml +merge_request: +author: diff --git a/changelogs/unreleased/dm-async-tree-readme.yml b/changelogs/unreleased/dm-async-tree-readme.yml new file mode 100644 index 00000000000..fb1cfeb210a --- /dev/null +++ b/changelogs/unreleased/dm-async-tree-readme.yml @@ -0,0 +1,4 @@ +--- +title: Load tree readme asynchronously +merge_request: +author: diff --git a/changelogs/unreleased/dm-auxiliary-viewers.yml b/changelogs/unreleased/dm-auxiliary-viewers.yml new file mode 100644 index 00000000000..ba73a499115 --- /dev/null +++ b/changelogs/unreleased/dm-auxiliary-viewers.yml @@ -0,0 +1,5 @@ +--- +title: Display extra info about files on .gitlab-ci.yml, .gitlab/route-map.yml and + LICENSE blob pages +merge_request: +author: diff --git a/changelogs/unreleased/dm-consistent-commit-sha-style.yml b/changelogs/unreleased/dm-consistent-commit-sha-style.yml new file mode 100644 index 00000000000..b6dace34d9b --- /dev/null +++ b/changelogs/unreleased/dm-consistent-commit-sha-style.yml @@ -0,0 +1,4 @@ +--- +title: Consistently use monospace font for commit SHAs and branch and tag names +merge_request: +author: diff --git a/changelogs/unreleased/dm-copy-mr-source-branch-as-gfm.yml b/changelogs/unreleased/dm-copy-mr-source-branch-as-gfm.yml new file mode 100644 index 00000000000..708c82604ad --- /dev/null +++ b/changelogs/unreleased/dm-copy-mr-source-branch-as-gfm.yml @@ -0,0 +1,4 @@ +--- +title: Paste a copied MR source branch name as code when pasted into a GFM form +merge_request: +author: diff --git a/changelogs/unreleased/dm-dependency-linker-gemfile.yml b/changelogs/unreleased/dm-dependency-linker-gemfile.yml new file mode 100644 index 00000000000..2d4167a1be5 --- /dev/null +++ b/changelogs/unreleased/dm-dependency-linker-gemfile.yml @@ -0,0 +1,4 @@ +--- +title: Autolink package names in Gemfile +merge_request: +author: diff --git a/changelogs/unreleased/document-foreign-keys.yml b/changelogs/unreleased/document-foreign-keys.yml new file mode 100644 index 00000000000..faa467e8185 --- /dev/null +++ b/changelogs/unreleased/document-foreign-keys.yml @@ -0,0 +1,4 @@ +--- +title: Add documentation about adding foreign keys +merge_request: +author: diff --git a/changelogs/unreleased/dont-blow-up-when-email-has-no-references-header.yml b/changelogs/unreleased/dont-blow-up-when-email-has-no-references-header.yml deleted file mode 100644 index a4345b70744..00000000000 --- a/changelogs/unreleased/dont-blow-up-when-email-has-no-references-header.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Gracefully handle failures for incoming emails which do not match on the To - header, and have no References header -merge_request: -author: diff --git a/changelogs/unreleased/dturner-username.yml b/changelogs/unreleased/dturner-username.yml new file mode 100644 index 00000000000..09ba822ee65 --- /dev/null +++ b/changelogs/unreleased/dturner-username.yml @@ -0,0 +1,4 @@ +--- +title: add username field to push webhook +merge_request: +author: David Turner diff --git a/changelogs/unreleased/dz-project-list-cache-key.yml b/changelogs/unreleased/dz-project-list-cache-key.yml new file mode 100644 index 00000000000..9e4826e686a --- /dev/null +++ b/changelogs/unreleased/dz-project-list-cache-key.yml @@ -0,0 +1,4 @@ +--- +title: Use route.cache_key for project list cache key +merge_request: 11325 +author: diff --git a/changelogs/unreleased/enable-auto-cancelling-by-default.yml b/changelogs/unreleased/enable-auto-cancelling-by-default.yml new file mode 100644 index 00000000000..8b1659bf38b --- /dev/null +++ b/changelogs/unreleased/enable-auto-cancelling-by-default.yml @@ -0,0 +1,4 @@ +--- +title: Enable cancelling non-HEAD pending pipelines by default for all projects +merge_request: 11023 +author: diff --git a/changelogs/unreleased/feature-print-go-version-in-env-info.yml b/changelogs/unreleased/feature-print-go-version-in-env-info.yml new file mode 100644 index 00000000000..34c19b06eda --- /dev/null +++ b/changelogs/unreleased/feature-print-go-version-in-env-info.yml @@ -0,0 +1,4 @@ +--- +title: Print Go version in rake gitlab:env:info +merge_request: 11241 +author: diff --git a/changelogs/unreleased/fix-conflict-resolution-with-corrupt-repos.yml b/changelogs/unreleased/fix-conflict-resolution-with-corrupt-repos.yml new file mode 100644 index 00000000000..19a3c56e478 --- /dev/null +++ b/changelogs/unreleased/fix-conflict-resolution-with-corrupt-repos.yml @@ -0,0 +1,5 @@ +--- +title: Prevent further repository corruption when resolving conflicts from a fork + where both the fork and upstream projects require housekeeping +merge_request: +author: diff --git a/changelogs/unreleased/fix-gb-fix-skipped-manual-actions.yml b/changelogs/unreleased/fix-gb-fix-skipped-manual-actions.yml new file mode 100644 index 00000000000..d8d4c668a44 --- /dev/null +++ b/changelogs/unreleased/fix-gb-fix-skipped-manual-actions.yml @@ -0,0 +1,4 @@ +--- +title: Fix skipped manual actions problem when processing the pipeline +merge_request: 11164 +author: diff --git a/changelogs/unreleased/fix-github-import.yml b/changelogs/unreleased/fix-github-import.yml new file mode 100644 index 00000000000..3a57152f7a8 --- /dev/null +++ b/changelogs/unreleased/fix-github-import.yml @@ -0,0 +1,4 @@ +--- +title: Fix token interpolation when setting the Github remote +merge_request: +author: diff --git a/changelogs/unreleased/fix-import-export-missing-attributes.yml b/changelogs/unreleased/fix-import-export-missing-attributes.yml deleted file mode 100644 index a1338b4eb48..00000000000 --- a/changelogs/unreleased/fix-import-export-missing-attributes.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Add missing project attributes to Import/Export -merge_request: -author: diff --git a/changelogs/unreleased/hamlit-xss-fix.yml b/changelogs/unreleased/hamlit-xss-fix.yml new file mode 100644 index 00000000000..ba4713846e9 --- /dev/null +++ b/changelogs/unreleased/hamlit-xss-fix.yml @@ -0,0 +1,4 @@ +--- +title: Fix for XSS in project import view caused by Hamlit filter usage. +merge_request: +author: diff --git a/changelogs/unreleased/issue-boards-sidebar-create-new-label-404-error.yml b/changelogs/unreleased/issue-boards-sidebar-create-new-label-404-error.yml new file mode 100644 index 00000000000..b935ef14786 --- /dev/null +++ b/changelogs/unreleased/issue-boards-sidebar-create-new-label-404-error.yml @@ -0,0 +1,4 @@ +--- +title: Fixed create new label form in issue boards sidebar +merge_request: +author: diff --git a/changelogs/unreleased/issue-templates-summary-lines.yml b/changelogs/unreleased/issue-templates-summary-lines.yml new file mode 100644 index 00000000000..0c8c3d884ce --- /dev/null +++ b/changelogs/unreleased/issue-templates-summary-lines.yml @@ -0,0 +1,4 @@ +--- +title: Add summary lines for collapsed details in the bug report template +merge_request: +author: diff --git a/changelogs/unreleased/issue_27168_2.yml b/changelogs/unreleased/issue_27168_2.yml new file mode 100644 index 00000000000..c67692493e0 --- /dev/null +++ b/changelogs/unreleased/issue_27168_2.yml @@ -0,0 +1,4 @@ +--- +title: Preloads head pipeline for merge request collection +merge_request: +author: diff --git a/changelogs/unreleased/issue_api_change.yml b/changelogs/unreleased/issue_api_change.yml new file mode 100644 index 00000000000..3ad2d57317c --- /dev/null +++ b/changelogs/unreleased/issue_api_change.yml @@ -0,0 +1,5 @@ +--- +title: 'Issue API change: assignee_id parameter and assignee object in a response + have been deprecated' +merge_request: +author: diff --git a/changelogs/unreleased/merge-request-poll-json-endpoint.yml b/changelogs/unreleased/merge-request-poll-json-endpoint.yml deleted file mode 100644 index 6c41984e9b7..00000000000 --- a/changelogs/unreleased/merge-request-poll-json-endpoint.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fixed bug where merge request JSON would be displayed -merge_request: -author: diff --git a/changelogs/unreleased/mrchrisw-fix-slack-notify.yml b/changelogs/unreleased/mrchrisw-fix-slack-notify.yml new file mode 100644 index 00000000000..bb45a117be6 --- /dev/null +++ b/changelogs/unreleased/mrchrisw-fix-slack-notify.yml @@ -0,0 +1,4 @@ +--- +title: Fix notify_only_default_branch check for Slack service +merge_request: +author: diff --git a/changelogs/unreleased/omega-submodules.yml b/changelogs/unreleased/omega-submodules.yml new file mode 100644 index 00000000000..1488eb72174 --- /dev/null +++ b/changelogs/unreleased/omega-submodules.yml @@ -0,0 +1,4 @@ +--- +title: 'Repository browser: handle in-repository submodule urls' +merge_request: +author: David Turner diff --git a/changelogs/unreleased/pipeline-schedules-callout-docs-url.yml b/changelogs/unreleased/pipeline-schedules-callout-docs-url.yml new file mode 100644 index 00000000000..b21bb162380 --- /dev/null +++ b/changelogs/unreleased/pipeline-schedules-callout-docs-url.yml @@ -0,0 +1,4 @@ +--- +title: Pass docsUrl to pipeline schedules callout component. +merge_request: !1126 +author: diff --git a/changelogs/unreleased/protected-branches-no-one-merge.yml b/changelogs/unreleased/protected-branches-no-one-merge.yml new file mode 100644 index 00000000000..52d93793f3d --- /dev/null +++ b/changelogs/unreleased/protected-branches-no-one-merge.yml @@ -0,0 +1,4 @@ +--- +title: Allow 'no one' as an option for allowed to merge on a procted branch +merge_request: +author: diff --git a/changelogs/unreleased/remove-old-isobject.yml b/changelogs/unreleased/remove-old-isobject.yml new file mode 100644 index 00000000000..67b18642253 --- /dev/null +++ b/changelogs/unreleased/remove-old-isobject.yml @@ -0,0 +1,4 @@ +--- +title: Remove unused code and uses underscore +merge_request: +author: diff --git a/changelogs/unreleased/rs-sanitize-submodule-urls.yml b/changelogs/unreleased/rs-sanitize-submodule-urls.yml new file mode 100644 index 00000000000..463b3695687 --- /dev/null +++ b/changelogs/unreleased/rs-sanitize-submodule-urls.yml @@ -0,0 +1,4 @@ +--- +title: Sanitize submodule URLs before linking to them in the file tree view +merge_request: +author: diff --git a/changelogs/unreleased/search-restrict-projects-to-group.yml b/changelogs/unreleased/search-restrict-projects-to-group.yml new file mode 100644 index 00000000000..ac134bc5bce --- /dev/null +++ b/changelogs/unreleased/search-restrict-projects-to-group.yml @@ -0,0 +1,4 @@ +--- +title: Restricts search projects dropdown to group projects when group is selected +merge_request: +author: diff --git a/changelogs/unreleased/snippets-finder-visibility.yml b/changelogs/unreleased/snippets-finder-visibility.yml new file mode 100644 index 00000000000..fde2262cc8d --- /dev/null +++ b/changelogs/unreleased/snippets-finder-visibility.yml @@ -0,0 +1,4 @@ +--- +title: Refactor snippets finder & dont return internal snippets for external users +merge_request: +author: diff --git a/changelogs/unreleased/snippets_visibility.yml b/changelogs/unreleased/snippets_visibility.yml new file mode 100644 index 00000000000..4c10c6882ab --- /dev/null +++ b/changelogs/unreleased/snippets_visibility.yml @@ -0,0 +1,4 @@ +--- +title: Fix snippets visibility for show action - external users can not see internal snippets +merge_request: +author: diff --git a/changelogs/unreleased/store-retried-in-database-for-ci-builds.yml b/changelogs/unreleased/store-retried-in-database-for-ci-builds.yml new file mode 100644 index 00000000000..9185113f51c --- /dev/null +++ b/changelogs/unreleased/store-retried-in-database-for-ci-builds.yml @@ -0,0 +1,4 @@ +--- +title: Store retried in database for CI Builds +merge_request: +author: diff --git a/changelogs/unreleased/tc-cache-trackable-attributes.yml b/changelogs/unreleased/tc-cache-trackable-attributes.yml new file mode 100644 index 00000000000..4a2cf50893a --- /dev/null +++ b/changelogs/unreleased/tc-cache-trackable-attributes.yml @@ -0,0 +1,4 @@ +--- +title: "Limit User's trackable attributes, like `current_sign_in_at`, to update at most once/hour" +merge_request: 11053 +author: diff --git a/changelogs/unreleased/tc-clean-pending-delete-projects.yml b/changelogs/unreleased/tc-clean-pending-delete-projects.yml new file mode 100644 index 00000000000..31b43999c31 --- /dev/null +++ b/changelogs/unreleased/tc-clean-pending-delete-projects.yml @@ -0,0 +1,4 @@ +--- +title: Add post-deploy migration to clean up projects in `pending_delete` state +merge_request: 11044 +author: diff --git a/changelogs/unreleased/tc-fix-private-subgroups-shown.yml b/changelogs/unreleased/tc-fix-private-subgroups-shown.yml new file mode 100644 index 00000000000..82e03921854 --- /dev/null +++ b/changelogs/unreleased/tc-fix-private-subgroups-shown.yml @@ -0,0 +1,4 @@ +--- +title: "Do not show private groups on subgroups page if user doesn't have access to" +merge_request: +author: diff --git a/changelogs/unreleased/up-arrow-focus-discussion-comment.yml b/changelogs/unreleased/up-arrow-focus-discussion-comment.yml new file mode 100644 index 00000000000..5457dab6d3d --- /dev/null +++ b/changelogs/unreleased/up-arrow-focus-discussion-comment.yml @@ -0,0 +1,4 @@ +--- +title: Fix up arrow not editing last discussion comment +merge_request: +author: diff --git a/changelogs/unreleased/use_relative_path_for_project_avatars.yml b/changelogs/unreleased/use_relative_path_for_project_avatars.yml new file mode 100644 index 00000000000..e3d0c0e1187 --- /dev/null +++ b/changelogs/unreleased/use_relative_path_for_project_avatars.yml @@ -0,0 +1,4 @@ +--- +title: Use relative paths for group/project/user avatars +merge_request: 11001 +author: blackst0ne diff --git a/changelogs/unreleased/winh-german-cycle-analytics.yml b/changelogs/unreleased/winh-german-cycle-analytics.yml new file mode 100644 index 00000000000..14b2d672bd0 --- /dev/null +++ b/changelogs/unreleased/winh-german-cycle-analytics.yml @@ -0,0 +1,4 @@ +--- +title: Add German translation for Cycle Analytics +merge_request: 11161 +author: diff --git a/changelogs/unreleased/winh-pipeline-author-link.yml b/changelogs/unreleased/winh-pipeline-author-link.yml new file mode 100644 index 00000000000..1b903d1e357 --- /dev/null +++ b/changelogs/unreleased/winh-pipeline-author-link.yml @@ -0,0 +1,4 @@ +--- +title: Link to commit author user page from pipelines +merge_request: 11100 +author: diff --git a/changelogs/unreleased/zj-clean-up-ci-variables-table.yml b/changelogs/unreleased/zj-clean-up-ci-variables-table.yml new file mode 100644 index 00000000000..ea2db40d590 --- /dev/null +++ b/changelogs/unreleased/zj-clean-up-ci-variables-table.yml @@ -0,0 +1,4 @@ +--- +title: Cleanup ci_variables schema and table +merge_request: +author: diff --git a/changelogs/unreleased/zj-pipeline-schedule-owner.yml b/changelogs/unreleased/zj-pipeline-schedule-owner.yml new file mode 100644 index 00000000000..be704e173ab --- /dev/null +++ b/changelogs/unreleased/zj-pipeline-schedule-owner.yml @@ -0,0 +1,4 @@ +--- +title: Add foreign key for pipeline schedule owner +merge_request: 11233 +author: diff --git a/config/application.rb b/config/application.rb index 32ad2393648..bf3fb7a18c1 100644 --- a/config/application.rb +++ b/config/application.rb @@ -22,7 +22,6 @@ module Gitlab # This is a nice reference article on autoloading/eager loading: # http://blog.arkency.com/2014/11/dont-forget-about-eager-load-when-extending-autoload config.eager_load_paths.push(*%W(#{config.root}/lib - #{config.root}/app/models/ci #{config.root}/app/models/hooks #{config.root}/app/models/members #{config.root}/app/models/project_services diff --git a/config/environments/production.rb b/config/environments/production.rb index a9d8ac4b6d4..82a19085b1d 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -16,7 +16,7 @@ Rails.application.configure do # config.assets.css_compressor = :sass # Don't fallback to assets pipeline if a precompiled asset is missed - config.assets.compile = true + config.assets.compile = false # Generate digests for assets URLs config.assets.digest = true diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 6097ae6534e..ea1815f500a 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -241,6 +241,7 @@ Settings.gitlab['domain_whitelist'] ||= [] Settings.gitlab['import_sources'] ||= %w[github bitbucket gitlab google_code fogbugz git gitlab_project gitea] Settings.gitlab['trusted_proxies'] ||= [] Settings.gitlab['no_todos_messages'] ||= YAML.load_file(Rails.root.join('config', 'no_todos_messages.yml')) +Settings.gitlab['usage_ping_enabled'] = true if Settings.gitlab['usage_ping_enabled'].nil? # # CI diff --git a/config/initializers/8_gitaly.rb b/config/initializers/8_gitaly.rb index 42ec7240b0f..31c7c91d78f 100644 --- a/config/initializers/8_gitaly.rb +++ b/config/initializers/8_gitaly.rb @@ -1,6 +1,8 @@ require 'uri' -# Make sure we initialize our Gitaly channels before Sidekiq starts multi-threaded execution. if Gitlab.config.gitaly.enabled || Rails.env.test? - Gitlab::GitalyClient.configure_channels + Gitlab.config.repositories.storages.keys.each do |storage| + # Force validation of each address + Gitlab::GitalyClient.address(storage) + end end diff --git a/config/initializers/ar_monkey_patch.rb b/config/initializers/ar_monkey_patch.rb index 6979f4641b0..9266ff0f615 100644 --- a/config/initializers/ar_monkey_patch.rb +++ b/config/initializers/ar_monkey_patch.rb @@ -33,7 +33,7 @@ module ActiveRecord affected_rows = relation.where( self.class.primary_key => id, - lock_col => previous_lock_value, + lock_col => previous_lock_value ).update_all( attributes_for_update(attribute_names).map do |name| [name, _read_attribute(name)] diff --git a/config/initializers/doorkeeper_openid_connect.rb b/config/initializers/doorkeeper_openid_connect.rb index 700ca25b884..4ff9019c43c 100644 --- a/config/initializers/doorkeeper_openid_connect.rb +++ b/config/initializers/doorkeeper_openid_connect.rb @@ -30,7 +30,7 @@ Doorkeeper::OpenidConnect.configure do o.claim(:email_verified) { |user| true if user.public_email? } o.claim(:website) { |user| user.full_website_url if user.website_url? } o.claim(:profile) { |user| Rails.application.routes.url_helpers.user_url user } - o.claim(:picture) { |user| user.avatar_url } + o.claim(:picture) { |user| user.avatar_url(only_path: false) } end end end diff --git a/config/initializers/hamlit.rb b/config/initializers/hamlit.rb index 7b545d8c06c..51dbffeda05 100644 --- a/config/initializers/hamlit.rb +++ b/config/initializers/hamlit.rb @@ -3,7 +3,7 @@ module Hamlit def call(template) Engine.new( generator: Temple::Generators::RailsOutputBuffer, - attr_quote: '"', + attr_quote: '"' ).call(template.source) end end @@ -11,7 +11,7 @@ end ActionView::Template.register_template_handler( :haml, - Hamlit::TemplateHandler.new, + Hamlit::TemplateHandler.new ) Hamlit::Filters.remove_filter('coffee') diff --git a/config/initializers/static_files.rb b/config/initializers/static_files.rb index 74aba6c5d06..9ed96ddb0b4 100644 --- a/config/initializers/static_files.rb +++ b/config/initializers/static_files.rb @@ -23,21 +23,21 @@ if app.config.serve_static_files host: dev_server.host, port: dev_server.port, manifest_host: dev_server.host, - manifest_port: dev_server.port, + manifest_port: dev_server.port } if Rails.env.development? settings.merge!( host: Gitlab.config.gitlab.host, port: Gitlab.config.gitlab.port, - https: Gitlab.config.gitlab.https, + https: Gitlab.config.gitlab.https ) app.config.middleware.insert_before( Gitlab::Middleware::Static, Gitlab::Middleware::WebpackProxy, proxy_path: app.config.webpack.public_path, proxy_host: dev_server.host, - proxy_port: dev_server.port, + proxy_port: dev_server.port ) end diff --git a/config/routes/project.rb b/config/routes/project.rb index 7f6e5447b19..c786cbdee1e 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -75,10 +75,9 @@ constraints(ProjectUrlConstrainer.new) do get :conflict_for_path get :pipelines get :merge_check + get :commit_change_content post :merge - get :merge_widget_refresh post :cancel_merge_when_pipeline_succeeds - get :ci_status get :pipeline_status get :ci_environments_status post :toggle_subscription @@ -146,7 +145,11 @@ constraints(ProjectUrlConstrainer.new) do get :folder, path: 'folders/*id', constraints: { format: /(html|json)/ } end - resources :deployments, only: [:index] + resources :deployments, only: [:index] do + member do + get :metrics + end + end end resource :cycle_analytics, only: [:show] @@ -241,7 +244,7 @@ constraints(ProjectUrlConstrainer.new) do get :referenced_merge_requests get :related_branches get :can_create_branch - get :rendered_title + get :realtime_changes post :create_merge_request end collection do diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 433381e79d3..0ca1f565185 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -40,6 +40,7 @@ - [expire_build_instance_artifacts, 1] - [group_destroy, 1] - [irker, 1] + - [namespaceless_project_destroy, 1] - [project_cache, 1] - [project_destroy, 1] - [project_export, 1] diff --git a/config/webpack.config.js b/config/webpack.config.js index cb6bd949ddb..5d5a42512b1 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -42,7 +42,6 @@ var config = { locale: './locale/index.js', main: './main.js', merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js', - merge_request_widget: './merge_request_widget/ci_bundle.js', monitoring: './monitoring/monitoring_bundle.js', network: './network/network_bundle.js', notebook_viewer: './blob/notebook_viewer.js', @@ -63,6 +62,7 @@ var config = { u2f: ['vendor/u2f'], users: './users/users_bundle.js', raven: './raven/index.js', + vue_merge_request_widget: './vue_merge_request_widget/index.js', }, output: { @@ -94,7 +94,7 @@ var config = { query: { mimetype: 'image/gif' }, }, { - test: /\.(worker\.js|pdf)$/, + test: /\.(worker\.js|pdf|bmpr)$/, exclude: /node_modules/, loader: 'file-loader', }, @@ -141,14 +141,16 @@ var config = { 'diff_notes', 'environments', 'environments_folder', - 'sidebar', + 'filtered_search', 'issue_show', 'merge_conflicts', 'notebook_viewer', 'pdf_viewer', 'pipelines', - 'balsamiq_viewer', 'pipelines_graph', + 'schedule_form', + 'schedules_index', + 'sidebar', ], minChunks: function(module, count) { return module.resource && (/vue_shared/).test(module.resource); diff --git a/db/migrate/20160810142633_remove_redundant_indexes.rb b/db/migrate/20160810142633_remove_redundant_indexes.rb index d7ab022d7bc..ea7d1f9a436 100644 --- a/db/migrate/20160810142633_remove_redundant_indexes.rb +++ b/db/migrate/20160810142633_remove_redundant_indexes.rb @@ -69,7 +69,7 @@ class RemoveRedundantIndexes < ActiveRecord::Migration [:namespaces, 'index_namespaces_on_created_at_and_id'], [:notes, 'index_notes_on_created_at_and_id'], [:projects, 'index_projects_on_created_at_and_id'], - [:users, 'index_users_on_created_at_and_id'], + [:users, 'index_users_on_created_at_and_id'] ] transaction do diff --git a/db/migrate/20160829114652_add_markdown_cache_columns.rb b/db/migrate/20160829114652_add_markdown_cache_columns.rb index 9cb44dfa9f9..6ad7237f4cd 100644 --- a/db/migrate/20160829114652_add_markdown_cache_columns.rb +++ b/db/migrate/20160829114652_add_markdown_cache_columns.rb @@ -25,7 +25,7 @@ class AddMarkdownCacheColumns < ActiveRecord::Migration notes: [:note], projects: [:description], releases: [:description], - snippets: [:title, :content], + snippets: [:title, :content] }.freeze def change diff --git a/db/migrate/20170502065653_make_auto_cancel_pending_pipelines_on_by_default.rb b/db/migrate/20170502065653_make_auto_cancel_pending_pipelines_on_by_default.rb new file mode 100644 index 00000000000..03bf626a08a --- /dev/null +++ b/db/migrate/20170502065653_make_auto_cancel_pending_pipelines_on_by_default.rb @@ -0,0 +1,13 @@ +class MakeAutoCancelPendingPipelinesOnByDefault < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + change_column_default(:projects, :auto_cancel_pending_pipelines, 1) + end + + def down + change_column_default(:projects, :auto_cancel_pending_pipelines, 0) + end +end diff --git a/db/migrate/20170502135553_create_index_ci_pipelines_auto_canceled_by_id.rb b/db/migrate/20170502135553_create_index_ci_pipelines_auto_canceled_by_id.rb new file mode 100644 index 00000000000..b64d7e0e3f6 --- /dev/null +++ b/db/migrate/20170502135553_create_index_ci_pipelines_auto_canceled_by_id.rb @@ -0,0 +1,21 @@ +class CreateIndexCiPipelinesAutoCanceledById < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + # MySQL would already have the index + unless index_exists?(:ci_pipelines, :auto_canceled_by_id) + add_concurrent_index(:ci_pipelines, :auto_canceled_by_id) + end + end + + def down + # We cannot remove index for MySQL because it's needed for foreign key + if Gitlab::Database.postgresql? + remove_concurrent_index(:ci_pipelines, :auto_canceled_by_id) + end + end +end diff --git a/db/migrate/20170502140503_create_index_ci_builds_auto_canceled_by_id.rb b/db/migrate/20170502140503_create_index_ci_builds_auto_canceled_by_id.rb new file mode 100644 index 00000000000..0a8d2c8ff61 --- /dev/null +++ b/db/migrate/20170502140503_create_index_ci_builds_auto_canceled_by_id.rb @@ -0,0 +1,21 @@ +class CreateIndexCiBuildsAutoCanceledById < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + # MySQL would already have the index + unless index_exists?(:ci_builds, :auto_canceled_by_id) + add_concurrent_index(:ci_builds, :auto_canceled_by_id) + end + end + + def down + # We cannot remove index for MySQL because it's needed for foreign key + if Gitlab::Database.postgresql? + remove_concurrent_index(:ci_builds, :auto_canceled_by_id) + end + end +end diff --git a/db/migrate/20170503004426_add_retried_to_ci_build.rb b/db/migrate/20170503004426_add_retried_to_ci_build.rb new file mode 100644 index 00000000000..2851e3de473 --- /dev/null +++ b/db/migrate/20170503004426_add_retried_to_ci_build.rb @@ -0,0 +1,9 @@ +class AddRetriedToCiBuild < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column(:ci_builds, :retried, :boolean) + end +end diff --git a/db/migrate/20170503023315_add_repository_update_events_to_web_hooks.rb b/db/migrate/20170503023315_add_repository_update_events_to_web_hooks.rb new file mode 100644 index 00000000000..0faea87a962 --- /dev/null +++ b/db/migrate/20170503023315_add_repository_update_events_to_web_hooks.rb @@ -0,0 +1,15 @@ +class AddRepositoryUpdateEventsToWebHooks < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default :web_hooks, :repository_update_events, :boolean, default: false, allow_null: false + end + + def down + remove_column :web_hooks, :repository_update_events + end +end diff --git a/db/migrate/20170507205316_add_head_pipeline_id_to_merge_requests.rb b/db/migrate/20170507205316_add_head_pipeline_id_to_merge_requests.rb new file mode 100644 index 00000000000..8fc6e380a77 --- /dev/null +++ b/db/migrate/20170507205316_add_head_pipeline_id_to_merge_requests.rb @@ -0,0 +1,7 @@ +class AddHeadPipelineIdToMergeRequests < ActiveRecord::Migration + DOWNTIME = false + + def change + add_column :merge_requests, :head_pipeline_id, :integer + end +end diff --git a/db/migrate/20170508153950_add_not_null_contraints_to_ci_variables.rb b/db/migrate/20170508153950_add_not_null_contraints_to_ci_variables.rb new file mode 100644 index 00000000000..41c687a4f6e --- /dev/null +++ b/db/migrate/20170508153950_add_not_null_contraints_to_ci_variables.rb @@ -0,0 +1,12 @@ +class AddNotNullContraintsToCiVariables < ActiveRecord::Migration + DOWNTIME = false + + def up + change_column(:ci_variables, :key, :string, null: false) + change_column(:ci_variables, :project_id, :integer, null: false) + end + + def down + # no op + end +end diff --git a/db/migrate/20170508190732_add_foreign_key_to_ci_variables.rb b/db/migrate/20170508190732_add_foreign_key_to_ci_variables.rb new file mode 100644 index 00000000000..20ecaa2c36c --- /dev/null +++ b/db/migrate/20170508190732_add_foreign_key_to_ci_variables.rb @@ -0,0 +1,24 @@ +class AddForeignKeyToCiVariables < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + execute <<~SQL + DELETE FROM ci_variables + WHERE NOT EXISTS ( + SELECT true + FROM projects + WHERE projects.id = ci_variables.project_id + ) + SQL + + add_concurrent_foreign_key(:ci_variables, :projects, column: :project_id) + end + + def down + remove_foreign_key(:ci_variables, column: :project_id) + end +end diff --git a/db/post_migrate/20170502070007_enable_auto_cancel_pending_pipelines_for_all.rb b/db/post_migrate/20170502070007_enable_auto_cancel_pending_pipelines_for_all.rb new file mode 100644 index 00000000000..a19b73fc114 --- /dev/null +++ b/db/post_migrate/20170502070007_enable_auto_cancel_pending_pipelines_for_all.rb @@ -0,0 +1,15 @@ +class EnableAutoCancelPendingPipelinesForAll < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + DOWNTIME = false + + def up + update_column_in_batches(:projects, :auto_cancel_pending_pipelines, 1) + end + + def down + # Nothing we can do! + end +end diff --git a/db/post_migrate/20170502101023_cleanup_namespaceless_pending_delete_projects.rb b/db/post_migrate/20170502101023_cleanup_namespaceless_pending_delete_projects.rb new file mode 100644 index 00000000000..ce52de91cdd --- /dev/null +++ b/db/post_migrate/20170502101023_cleanup_namespaceless_pending_delete_projects.rb @@ -0,0 +1,47 @@ +# This is the counterpart of RequeuePendingDeleteProjects and cleans all +# projects with `pending_delete = true` and that do not have a namespace. +class CleanupNamespacelessPendingDeleteProjects < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + @offset = 0 + + loop do + ids = pending_delete_batch + + break if ids.empty? + + args = ids.map { |id| Array(id) } + + NamespacelessProjectDestroyWorker.bulk_perform_async(args) + + @offset += 1 + end + end + + def down + # noop + end + + private + + def pending_delete_batch + connection.exec_query(find_batch).map{ |row| row['id'].to_i } + end + + BATCH_SIZE = 5000 + + def find_batch + projects = Arel::Table.new(:projects) + projects.project(projects[:id]). + where(projects[:pending_delete].eq(true)). + where(projects[:namespace_id].eq(nil)). + skip(@offset * BATCH_SIZE). + take(BATCH_SIZE). + to_sql + end +end diff --git a/db/post_migrate/20170503004427_upate_retried_for_ci_build.rb b/db/post_migrate/20170503004427_upate_retried_for_ci_build.rb new file mode 100644 index 00000000000..80215d662e4 --- /dev/null +++ b/db/post_migrate/20170503004427_upate_retried_for_ci_build.rb @@ -0,0 +1,29 @@ +class UpateRetriedForCiBuild < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + disable_statement_timeout + + latest_id = <<-SQL.strip_heredoc + SELECT MAX(ci_builds2.id) + FROM ci_builds ci_builds2 + WHERE ci_builds.commit_id=ci_builds2.commit_id + AND ci_builds.name=ci_builds2.name + SQL + + # This is slow update as it does single-row query + # This is designed to be run as idle, or a post deployment migration + is_retried = Arel.sql("((#{latest_id}) != ci_builds.id)") + + update_column_in_batches(:ci_builds, :retried, is_retried) do |table, query| + query.where(table[:retried].eq(nil)) + end + end + + def down + end +end diff --git a/db/post_migrate/20170508170547_add_head_pipeline_for_each_merge_request.rb b/db/post_migrate/20170508170547_add_head_pipeline_for_each_merge_request.rb new file mode 100644 index 00000000000..bc3850c0c23 --- /dev/null +++ b/db/post_migrate/20170508170547_add_head_pipeline_for_each_merge_request.rb @@ -0,0 +1,25 @@ +class AddHeadPipelineForEachMergeRequest < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + disable_statement_timeout + + pipelines = Arel::Table.new(:ci_pipelines) + merge_requests = Arel::Table.new(:merge_requests) + + head_id = pipelines. + project(Arel::Nodes::NamedFunction.new('max', [pipelines[:id]])). + from(pipelines). + where(pipelines[:ref].eq(merge_requests[:source_branch])). + where(pipelines[:project_id].eq(merge_requests[:source_project_id])) + + sub_query = Arel::Nodes::SqlLiteral.new(Arel::Nodes::Grouping.new(head_id).to_sql) + + update_column_in_batches(:merge_requests, :head_pipeline_id, sub_query) + end + + def down + end +end diff --git a/db/post_migrate/20170510101043_add_foreign_key_on_pipeline_schedule_owner.rb b/db/post_migrate/20170510101043_add_foreign_key_on_pipeline_schedule_owner.rb new file mode 100644 index 00000000000..6a870f08e89 --- /dev/null +++ b/db/post_migrate/20170510101043_add_foreign_key_on_pipeline_schedule_owner.rb @@ -0,0 +1,35 @@ +class AddForeignKeyOnPipelineScheduleOwner < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + execute <<-SQL + UPDATE ci_pipeline_schedules + SET owner_id = NULL + WHERE NOT EXISTS ( + SELECT true + FROM users + WHERE ci_pipeline_schedules.owner_id = users.id + ) + SQL + + add_concurrent_foreign_key(:ci_pipeline_schedules, :users, column: :owner_id, on_delete: on_delete) + end + + def down + remove_foreign_key(:ci_pipeline_schedules, column: :owner_id) + end + + private + + def on_delete + if Gitlab::Database.mysql? + :nullify + else + 'SET NULL' + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 722e776c27d..fd0fb2d7220 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: 20170506185517) do +ActiveRecord::Schema.define(version: 20170510101043) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -232,8 +232,10 @@ ActiveRecord::Schema.define(version: 20170506185517) do t.integer "lock_version" t.string "coverage_regex" t.integer "auto_canceled_by_id" + t.boolean "retried" end + add_index "ci_builds", ["auto_canceled_by_id"], name: "index_ci_builds_on_auto_canceled_by_id", using: :btree add_index "ci_builds", ["commit_id", "stage_idx", "created_at"], name: "index_ci_builds_on_commit_id_and_stage_idx_and_created_at", using: :btree add_index "ci_builds", ["commit_id", "status", "type"], name: "index_ci_builds_on_commit_id_and_status_and_type", using: :btree add_index "ci_builds", ["commit_id", "type", "name", "ref"], name: "index_ci_builds_on_commit_id_and_type_and_name_and_ref", using: :btree @@ -283,6 +285,7 @@ ActiveRecord::Schema.define(version: 20170506185517) do t.integer "pipeline_schedule_id" end + add_index "ci_pipelines", ["auto_canceled_by_id"], name: "index_ci_pipelines_on_auto_canceled_by_id", using: :btree add_index "ci_pipelines", ["pipeline_schedule_id"], name: "index_ci_pipelines_on_pipeline_schedule_id", using: :btree add_index "ci_pipelines", ["project_id", "ref", "status"], name: "index_ci_pipelines_on_project_id_and_ref_and_status", using: :btree add_index "ci_pipelines", ["project_id", "sha"], name: "index_ci_pipelines_on_project_id_and_sha", using: :btree @@ -346,12 +349,12 @@ ActiveRecord::Schema.define(version: 20170506185517) do add_index "ci_triggers", ["project_id"], name: "index_ci_triggers_on_project_id", using: :btree create_table "ci_variables", force: :cascade do |t| - t.string "key" + t.string "key", null: false t.text "value" t.text "encrypted_value" t.string "encrypted_value_salt" t.string "encrypted_value_iv" - t.integer "project_id" + t.integer "project_id", null: false end add_index "ci_variables", ["project_id"], name: "index_ci_variables_on_project_id", using: :btree @@ -690,6 +693,7 @@ ActiveRecord::Schema.define(version: 20170506185517) do t.integer "cached_markdown_version" t.datetime "last_edited_at" t.integer "last_edited_by_id" + t.integer "head_pipeline_id" end add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree @@ -984,7 +988,7 @@ ActiveRecord::Schema.define(version: 20170506185517) do t.text "description_html" t.boolean "only_allow_merge_if_all_discussions_are_resolved" t.boolean "printing_merge_request_link_enabled", default: true, null: false - t.integer "auto_cancel_pending_pipelines", default: 0, null: false + t.integer "auto_cancel_pending_pipelines", default: 1, null: false t.string "import_jid" t.integer "cached_markdown_version" t.datetime "last_repository_updated_at" @@ -1402,6 +1406,7 @@ ActiveRecord::Schema.define(version: 20170506185517) do t.string "token" t.boolean "pipeline_events", default: false, null: false t.boolean "confidential_issues_events", default: false, null: false + t.boolean "repository_update_events", default: false, null: false end add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree @@ -1411,10 +1416,12 @@ ActiveRecord::Schema.define(version: 20170506185517) do add_foreign_key "chat_teams", "namespaces", on_delete: :cascade add_foreign_key "ci_builds", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_a2141b1522", on_delete: :nullify add_foreign_key "ci_pipeline_schedules", "projects", name: "fk_8ead60fcc4", on_delete: :cascade + add_foreign_key "ci_pipeline_schedules", "users", column: "owner_id", name: "fk_9ea99f58d2", on_delete: :nullify add_foreign_key "ci_pipelines", "ci_pipeline_schedules", column: "pipeline_schedule_id", name: "fk_3d34ab2e06", on_delete: :nullify add_foreign_key "ci_pipelines", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_262d4c2d19", on_delete: :nullify add_foreign_key "ci_trigger_requests", "ci_triggers", column: "trigger_id", name: "fk_b8ec8b7245", 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 "container_repositories", "projects" add_foreign_key "issue_assignees", "issues", on_delete: :cascade add_foreign_key "issue_assignees", "users", on_delete: :cascade diff --git a/doc/api/README.md b/doc/api/README.md index d444ce94573..1b0f6470b13 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -61,8 +61,9 @@ The following documentation is for the [internal CI API](ci/README.md): ## Authentication -All API requests require authentication via a session cookie or token. There are -three types of tokens available: private tokens, OAuth 2 tokens, and personal +Most API requests require authentication via a session cookie or token. 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 tokens available: private tokens, OAuth 2 tokens, and personal access tokens. If authentication information is invalid or omitted, an error message will be diff --git a/doc/api/access_requests.md b/doc/api/access_requests.md index 21de7d18632..603fa4a8194 100644 --- a/doc/api/access_requests.md +++ b/doc/api/access_requests.md @@ -1,4 +1,4 @@ -# Group and project access requests +# Group and project access requests API >**Note:** This feature was introduced in GitLab 8.11 diff --git a/doc/api/enviroments.md b/doc/api/enviroments.md index 49930f01945..5ca766bf87d 100644 --- a/doc/api/enviroments.md +++ b/doc/api/enviroments.md @@ -1,4 +1,4 @@ -# Environments +# Environments API ## List environments diff --git a/doc/api/groups.md b/doc/api/groups.md index bc61bfec9b9..2b3d8e125c8 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -1,4 +1,4 @@ -# Groups +# Groups API ## List groups diff --git a/doc/api/issues.md b/doc/api/issues.md index 1d43b1298b9..3f949ca5667 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -1,4 +1,4 @@ -# Issues +# Issues API Every API call to issues must be authenticated. @@ -100,7 +100,7 @@ Example response: ] ``` -**Note**: `assignee` column is deprecated, it shows the first assignee only. +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. ## List group issues @@ -192,7 +192,7 @@ Example response: ] ``` -**Note**: `assignee` column is deprecated, it shows the first assignee only. +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. ## List project issues @@ -284,7 +284,7 @@ Example response: ] ``` -**Note**: `assignee` column is deprecated, it shows the first assignee only. +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. ## Single issue @@ -359,7 +359,7 @@ Example response: } ``` -**Note**: `assignee` column is deprecated, it shows the first assignee only. +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. ## New issue @@ -375,7 +375,7 @@ POST /projects/:id/issues | `title` | string | yes | The title of an issue | | `description` | string | no | The description of an issue | | `confidential` | boolean | no | Set an issue to be confidential. Default is `false`. | -| `assignee_ids` | Array[integer] | no | The ID of a user to assign issue | +| `assignee_ids` | Array[integer] | no | The ID of the users to assign issue | | `milestone_id` | integer | no | The ID of a milestone to assign issue | | `labels` | string | no | Comma-separated label names for an issue | | `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` (requires admin or project owner rights) | @@ -421,7 +421,7 @@ Example response: } ``` -**Note**: `assignee` column is deprecated, it shows the first assignee only. +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. ## Edit issue @@ -439,7 +439,7 @@ PUT /projects/:id/issues/:issue_iid | `title` | string | no | The title of an issue | | `description` | string | no | The description of an issue | | `confidential` | boolean | no | Updates an issue to be confidential | -| `assignee_ids` | Array[integer] | no | The ID of a user to assign the issue to | +| `assignee_ids` | Array[integer] | no | The ID of the users to assign the issue to | | `milestone_id` | integer | no | The ID of a milestone to assign the issue to | | `labels` | string | no | Comma-separated label names for an issue | | `state_event` | string | no | The state event of an issue. Set `close` to close the issue and `reopen` to reopen it | @@ -484,7 +484,7 @@ Example response: } ``` -**Note**: `assignee` column is deprecated, it shows the first assignee only. +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. ## Delete an issue @@ -570,7 +570,7 @@ Example response: } ``` -**Note**: `assignee` column is deprecated, it shows the first assignee only. +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. ## Subscribe to an issue @@ -635,7 +635,7 @@ Example response: } ``` -**Note**: `assignee` column is deprecated, it shows the first assignee only. +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. ## Unsubscribe from an issue @@ -757,7 +757,7 @@ Example response: } ``` -**Note**: `assignee` column is deprecated, it shows the first assignee only. +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. ## Set a time estimate for an issue diff --git a/doc/api/keys.md b/doc/api/keys.md index 3ace1040f38..376ac27df3a 100644 --- a/doc/api/keys.md +++ b/doc/api/keys.md @@ -1,4 +1,4 @@ -# Keys +# Keys API ## Get SSH key with user by ID of an SSH key diff --git a/doc/api/labels.md b/doc/api/labels.md index 778348ea371..ec93cf50e7a 100644 --- a/doc/api/labels.md +++ b/doc/api/labels.md @@ -1,4 +1,4 @@ -# Labels +# Labels API ## List labels diff --git a/doc/api/members.md b/doc/api/members.md index 3c661284f11..3234f833eae 100644 --- a/doc/api/members.md +++ b/doc/api/members.md @@ -1,4 +1,4 @@ -# Group and project members +# Group and project members API **Valid access levels** diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index dde855b2bd4..cb22b67f556 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -1,4 +1,4 @@ -# Merge requests +# Merge requests API ## List merge requests diff --git a/doc/api/notification_settings.md b/doc/api/notification_settings.md index 43047917f77..3a2c398e355 100644 --- a/doc/api/notification_settings.md +++ b/doc/api/notification_settings.md @@ -1,4 +1,4 @@ -# Notification settings +# Notification settings API >**Note:** This feature was [introduced][ce-5632] in GitLab 8.12. diff --git a/doc/api/projects.md b/doc/api/projects.md index 188fbe7447d..673cf02705d 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -1,4 +1,4 @@ -# Projects +# Projects API ### Project visibility level diff --git a/doc/api/settings.md b/doc/api/settings.md index d99695ca986..eefbdda42ce 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -1,4 +1,4 @@ -# Application settings +# Application settings API These API calls allow you to read and modify GitLab instance application settings as appear in `/admin/application_settings`. You have to be an diff --git a/doc/api/snippets.md b/doc/api/snippets.md index e09d930698e..fb8cf97896c 100644 --- a/doc/api/snippets.md +++ b/doc/api/snippets.md @@ -1,4 +1,4 @@ -# Snippets +# Snippets API > [Introduced][ce-6373] in GitLab 8.15. diff --git a/doc/api/todos.md b/doc/api/todos.md index 77667a57195..dd4c737b729 100644 --- a/doc/api/todos.md +++ b/doc/api/todos.md @@ -1,4 +1,4 @@ -# Todos +# Todos API > [Introduced][ce-3188] in GitLab 8.10. diff --git a/doc/api/users.md b/doc/api/users.md index 86027bcc05c..331f9a9b80b 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -1,4 +1,4 @@ -# Users +# Users API ## List users diff --git a/doc/articles/how_to_configure_ldap_gitlab_ce/index.md b/doc/articles/how_to_configure_ldap_gitlab_ce/index.md index 1702c2184f2..6892905dd94 100644 --- a/doc/articles/how_to_configure_ldap_gitlab_ce/index.md +++ b/doc/articles/how_to_configure_ldap_gitlab_ce/index.md @@ -1,6 +1,6 @@ # How to configure LDAP with GitLab CE -> **Type:** admin guide || +> **Article [Type](../../development/writing_documentation.html#types-of-technical-articles):** admin guide || > **Level:** intermediary || > **Author:** [Chris Wilson](https://gitlab.com/MrChrisW) || > **Publication date:** 2017/05/03 diff --git a/doc/articles/how_to_install_git/index.md b/doc/articles/how_to_install_git/index.md new file mode 100644 index 00000000000..66d866b2d09 --- /dev/null +++ b/doc/articles/how_to_install_git/index.md @@ -0,0 +1,66 @@ +# Installing Git + +> **Article [Type](../../development/writing_documentation.html#types-of-technical-articles):** user guide || +> **Level:** beginner || +> **Author:** [Sean Packham](https://gitlab.com/SeanPackham) || +> **Publication date:** 2017/05/15 + +To begin contributing to GitLab projects +you will need to install the Git client on your computer. +This article will show you how to install Git on macOS, Ubuntu Linux and Windows. + +## Install Git on macOS using the Homebrew package manager + +Although it is easy to use the version of Git shipped with macOS +or install the latest version of Git on macOS by downloading it from the project website, +we recommend installing it via Homebrew to get access to +an extensive selection of dependancy managed libraries and applications. + +If you are sure you don't need access to any additional development libraries +or don't have approximately 15gb of available disk space for Xcode and Homebrew +use one of the the aforementioned methods. + +### Installing Xcode + +Xcode is needed by Homebrew to build dependencies. +You can install [XCode](https://developer.apple.com/xcode/) +through the macOS App Store. + +### Installing Homebrew + +Once Xcode is installed browse to the [Homebrew website](http://brew.sh/index.html) +for the official Homebrew installation instructions. + +### Installing Git via Homebrew + +With Homebrew installed you are now ready to install Git. +Open a Terminal and enter in the following command: + +```bash +brew install git +``` + +Congratulations you should now have Git installed via Homebrew. +Next read our article on [adding an SSH key to GitLab](../../ssh/README.md). + +## Install Git on Ubuntu Linux + +On Ubuntu and other Linux operating systems +it is recommended to use the built in package manager to install Git. + +Open a Terminal and enter in the following commands +to install the latest Git from the official Git maintained package archives: + +```bash +sudo apt-add-repository ppa:git-core/ppa +sudo apt-get update +sudo apt-get install git +``` + +Congratulations you should now have Git installed via the Ubuntu package manager. +Next read our article on [adding an SSH key to GitLab](../../ssh/README.md). + +## Installing Git on Windows from the Git website + +Browse to the [Git website](https://git-scm.com/) and download and install Git for Windows. +Next read our article on [adding an SSH key to GitLab](../../ssh/README.md). diff --git a/doc/articles/index.md b/doc/articles/index.md index 49db64134f5..342fa88e80f 100644 --- a/doc/articles/index.md +++ b/doc/articles/index.md @@ -12,6 +12,10 @@ They are written by members of the GitLab Team and by - **LDAP** - [How to configure LDAP with GitLab CE](how_to_configure_ldap_gitlab_ce/index.md) +## Git + +- [How to install Git](how_to_install_git/index.md) + ## GitLab Pages - **GitLab Pages from A to Z** diff --git a/doc/ci/docker/using_docker_images.md b/doc/ci/docker/using_docker_images.md index f025a7e3496..96834e15bb9 100644 --- a/doc/ci/docker/using_docker_images.md +++ b/doc/ci/docker/using_docker_images.md @@ -146,7 +146,7 @@ private registries that could also require authentication. All you have to do is be explicit on the image definition in `.gitlab-ci.yml`. ```yaml -image: my.registry.tld:5000/namepace/image:tag +image: my.registry.tld:5000/namespace/image:tag ``` In the example above, GitLab Runner will look at `my.registry.tld:5000` for the diff --git a/doc/ci/environments.md b/doc/ci/environments.md index b28f3e13eae..bab765d1e12 100644 --- a/doc/ci/environments.md +++ b/doc/ci/environments.md @@ -442,7 +442,8 @@ and/or `production`) you can see this information in the merge request itself. ![Environment URLs in merge request](img/environments_link_url_mr.png) -### Go directly from source files to public pages on the environment +### <a name="route-map"></a>Go directly from source files to public pages on the environment + > Introduced in GitLab 8.17. diff --git a/doc/ci/pipeline_schedules.md b/doc/ci/pipeline_schedules.md index 0a9b0e7173f..73451da6c0c 100644 --- a/doc/ci/pipeline_schedules.md +++ b/doc/ci/pipeline_schedules.md @@ -35,6 +35,10 @@ To change the Sidekiq worker's frequency, you have to edit the `trigger_schedule value in your `gitlab.rb` and restart GitLab. The Sidekiq worker's configuration on GiLab.com is able to be looked up at [here](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/gitlab.yml.example#L185). - Cron notation is parsed by [Rufus-Scheduler](https://github.com/jmettraux/rufus-scheduler). +- When the owner of the schedule does not have the ability to create pipelines +anymore, due to e.g. being blocked or removed from the project, the schedule is +deactivated. Another user can take ownership and activate it, so the schedule is +run again. [ce-10533]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10533 [ce-10853]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10853 diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md index 30f209f80eb..41cae58782d 100644 --- a/doc/ci/quick_start/README.md +++ b/doc/ci/quick_start/README.md @@ -155,7 +155,7 @@ Find more information about different Runners in the [Runners](../runners/README.md) documentation. You can find whether any Runners are assigned to your project by going to -**Settings ➔ Runners**. Setting up a Runner is easy and straightforward. The +**Settings ➔ CI/CD Pipelines**. Setting up a Runner is easy and straightforward. The official Runner supported by GitLab is written in Go and its documentation can be found at <https://docs.gitlab.com/runner/>. @@ -168,7 +168,7 @@ Follow the links above to set up your own Runner or use a Shared Runner as described in the next section. Once the Runner has been set up, you should see it on the Runners page of your -project, following **Settings ➔ Runners**. +project, following **Settings ➔ CI/CD Pipelines**. ![Activated runners](img/runners_activated.png) @@ -181,7 +181,7 @@ These are special virtual machines that run on GitLab's infrastructure and can build any project. To enable the **Shared Runners** you have to go to your project's -**Settings ➔ Runners** and click **Enable shared runners**. +**Settings ➔ CI/CD Pipelines** and click **Enable shared runners**. [Read more on Shared Runners](../runners/README.md). diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 045d3821f66..9a3bbcf2853 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -333,7 +333,7 @@ prefix the variable name with the dollar sign (`$`): ``` job_name: script: - - echo $CI_job_ID + - echo $CI_JOB_ID ``` You can also list all environment variables with the `export` command, diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 16308a957cb..8546a99a022 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -147,6 +147,10 @@ variables: DATABASE_URL: "postgres://postgres@postgres/my_database" ``` +>**Note:** +Integers (as well as strings) are legal both for variable's name and value. +Floats are not legal and cannot be used. + These variables can be later used in all executed commands and scripts. The YAML-defined variables are also set to all created service containers, thus allowing to fine tune them. Variables can be also defined on a @@ -1152,7 +1156,7 @@ Example: ```yaml variables: - GET_SOURCES_ATTEMPTS: "3" + GET_SOURCES_ATTEMPTS: 3 ``` You can set them in the global [`variables`](#variables) section or the diff --git a/doc/development/README.md b/doc/development/README.md index d04380e5b33..63db332b557 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -48,6 +48,7 @@ - [What requires downtime?](what_requires_downtime.md) - [Adding database indexes](adding_database_indexes.md) - [Post Deployment Migrations](post_deployment_migrations.md) +- [Foreign Keys & Associations](foreign_keys.md) ## Compliance diff --git a/doc/development/architecture.md b/doc/development/architecture.md index 4eb7a8eee48..b36fd52603b 100644 --- a/doc/development/architecture.md +++ b/doc/development/architecture.md @@ -4,7 +4,7 @@ There are two editions of GitLab: [Enterprise Edition](https://about.gitlab.com/gitlab-ee/) (EE) and [Community Edition](https://about.gitlab.com/gitlab-ce/) (CE). GitLab CE is delivered via git from the [gitlabhq repository](https://gitlab.com/gitlab-org/gitlab-ce/tree/master). New versions of GitLab are released in stable branches and the master branch is for bleeding edge development. -EE releases are available not long after CE releases. To obtain the GitLab EE there is a [repository at gitlab.com](https://gitlab.com/subscribers/gitlab-ee). For more information about the release process see the section 'New versions and upgrading' in the readme. +EE releases are available not long after CE releases. To obtain the GitLab EE there is a [repository at gitlab.com](https://gitlab.com/gitlab-org/gitlab-ee). For more information about the release process see the section 'New versions and upgrading' in the readme. Both EE and CE require some add-on components called gitlab-shell and Gitaly. These components are available from the [gitlab-shell](https://gitlab.com/gitlab-org/gitlab-shell/tree/master) and [gitaly](https://gitlab.com/gitlab-org/gitaly/tree/master) repositories respectively. New versions are usually tags but staying on the master branch will give you the latest stable version. New releases are generally around the same time as GitLab CE releases with exception for informal security updates deemed critical. diff --git a/doc/development/build_test_package.md b/doc/development/build_test_package.md index 2bc1a700844..439d228baef 100644 --- a/doc/development/build_test_package.md +++ b/doc/development/build_test_package.md @@ -15,6 +15,10 @@ When you push a commit to either the gitlab-ce or gitlab-ee project, the pipeline for that commit will have a `build-package` manual action you can trigger. +![Manual actions](img/trigger_ss1.png) + +![Build package manual action](img/trigger_ss2.png) + ## Specifying versions of components If you want to create a package from a specific branch, commit or tag of any of diff --git a/doc/development/code_review.md b/doc/development/code_review.md index be3dd1e2cc6..4ed89146072 100644 --- a/doc/development/code_review.md +++ b/doc/development/code_review.md @@ -6,18 +6,20 @@ There are a few rules to get your merge request accepted: 1. Your merge request should only be **merged by a [maintainer][team]**. 1. If your merge request includes only backend changes [^1], it must be - **approved by a [backend maintainer][team]**. + **approved by a [backend maintainer][projects]**. 1. If your merge request includes only frontend changes [^1], it must be - **approved by a [frontend maintainer][team]**. + **approved by a [frontend maintainer][projects]**. 1. If your merge request includes frontend and backend changes [^1], it must - be **approved by a [frontend and a backend maintainer][team]**. + be **approved by a [frontend and a backend maintainer][projects]**. 1. To lower the amount of merge requests maintainers need to review, you can - ask or assign any [reviewers][team] for a first review. + ask or assign any [reviewers][projects] for a first review. 1. If you need some guidance (e.g. it's your first merge request), feel free to ask one of the [Merge request coaches][team]. 1. The reviewer will assign the merge request to a maintainer once the reviewer is satisfied with the state of the merge request. +For more guidance, see [CONTRIBUTING.md](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md). + ## Best practices This guide contains advice and best practices for performing code review, and @@ -30,7 +32,7 @@ code is effective, understandable, and maintainable. Any developer can, and is encouraged to, perform code review on merge requests of colleagues and contributors. However, the final decision to accept a merge request is up to one the project's maintainers, denoted on the -[team page](https://about.gitlab.com/team). +[engineering projects][projects]. ### Everyone @@ -140,3 +142,6 @@ Largely based on the [thoughtbot code review guide]. --- [Return to Development documentation](README.md) + +[projects]: https://about.gitlab.com/handbook/engineering/projects/ +[team]: https://about.gitlab.com/team/ diff --git a/doc/development/doc_styleguide.md b/doc/development/doc_styleguide.md index 1e81905c081..5b09f79f143 100644 --- a/doc/development/doc_styleguide.md +++ b/doc/development/doc_styleguide.md @@ -198,10 +198,17 @@ You can combine one or more of the following: the `.md` document that you're working on is located. Always prepend their names with the name of the document that they will be included in. For example, if there is a document called `twitter.md`, then a valid image name - could be `twitter_login_screen.png`. + could be `twitter_login_screen.png`. [**Exception**: images for + [articles](writing_documentation.md#technical-articles) should be + put in a directory called `img` underneath `/articles/article_title/img/`, therefore, + there's no need to prepend the document name to their filenames.] - Images should have a specific, non-generic name that will differentiate them. - Keep all file names in lower case. - Consider using PNG images instead of JPEG. +- Compress all images with <https://tinypng.com/> or similar tool. +- Compress gifs with <https://ezgif.com/optimize> or similar toll. +- Images should be used (only when necessary) to _illustrate_ the description +of a process, not to _replace_ it. Inside the document: diff --git a/doc/development/fe_guide/testing.md b/doc/development/fe_guide/testing.md index 157c13352ca..a8c264bbd3c 100644 --- a/doc/development/fe_guide/testing.md +++ b/doc/development/fe_guide/testing.md @@ -29,6 +29,33 @@ browser and you will not have access to certain APIs, such as which will have to be stubbed. ### Writing tests + +When writing describe test blocks to test specific functions/methods, +please use the method name as the describe block name. + +```javascript +// Good +describe('methodName', () => { + it('passes', () => { + expect(true).toEqual(true); + }); +}); + +// Bad +describe('#methodName', () => { + it('passes', () => { + expect(true).toEqual(true); + }); +}); + +// Bad +describe('.methodName', () => { + it('passes', () => { + expect(true).toEqual(true); + }); +}); +``` + ### Vue.js unit tests See this [section][vue-test]. diff --git a/doc/development/foreign_keys.md b/doc/development/foreign_keys.md new file mode 100644 index 00000000000..0ab0deb156f --- /dev/null +++ b/doc/development/foreign_keys.md @@ -0,0 +1,63 @@ +# Foreign Keys & Associations + +When adding an association to a model you must also add a foreign key. For +example, say you have the following model: + +```ruby +class User < ActiveRecord::Base + has_many :posts +end +``` + +Here you will need to add a foreign key on column `posts.user_id`. This ensures +that data consistency is enforced on database level. Foreign keys also mean that +the database can very quickly remove associated data (e.g. when removing a +user), instead of Rails having to do this. + +## Adding Foreign Keys In Migrations + +Foreign keys can be added concurrently using `add_concurrent_foreign_key` as +defined in `Gitlab::Database::MigrationHelpers`. See the [Migration Style +Guide](migration_style_guide.md) for more information. + +Keep in mind that you can only safely add foreign keys to existing tables after +you have removed any orphaned rows. The method `add_concurrent_foreign_key` +does not take care of this so you'll need to do so manually. + +## Cascading Deletes + +Every foreign key must define an `ON DELETE` clause, and in 99% of the cases +this should be set to `CASCADE`. + +## Indexes + +When adding a foreign key in PostgreSQL the column is not indexed automatically, +thus you must also add a concurrent index. Not doing so will result in cascading +deletes being very slow. + +## Dependent Removals + +Don't define options such as `dependent: :destroy` or `dependent: :delete` when +defining an association. Defining these options means Rails will handle the +removal of data, instead of letting the database handle this in the most +efficient way possible. + +In other words, this is bad and should be avoided at all costs: + +```ruby +class User < ActiveRecord::Base + has_many :posts, dependent: :destroy +end +``` + +Should you truly have a need for this it should be approved by a database +specialist first. + +You should also not define any `before_destroy` or `after_destroy` callbacks on +your models _unless_ absolutely required and only when approved by database +specialists. For example, if each row in a table has a corresponding file on a +file system it may be tempting to add a `after_destroy` hook. This however +introduces non database logic to a model, and means we can no longer rely on +foreign keys to remove the data as this would result in the filesystem data +being left behind. In such a case you should use a service class instead that +takes care of removing non database data. diff --git a/doc/development/img/trigger_ss1.png b/doc/development/img/trigger_ss1.png Binary files differnew file mode 100644 index 00000000000..ccff1009a25 --- /dev/null +++ b/doc/development/img/trigger_ss1.png diff --git a/doc/development/img/trigger_ss2.png b/doc/development/img/trigger_ss2.png Binary files differnew file mode 100644 index 00000000000..94dfd048793 --- /dev/null +++ b/doc/development/img/trigger_ss2.png diff --git a/doc/development/writing_documentation.md b/doc/development/writing_documentation.md index 2814c18e0b6..657a826d7ee 100644 --- a/doc/development/writing_documentation.md +++ b/doc/development/writing_documentation.md @@ -52,11 +52,13 @@ Every **Technical Article** contains, in the very beginning, a blockquote with t - A reference to the **type of article** (user guide, admin guide, tech overview, tutorial) - A reference to the **knowledge level** expected from the reader to be able to follow through (beginner, intermediate, advanced) - A reference to the **author's name** and **GitLab.com handle** +- A reference of the **publication date** ```md -> **Type:** tutorial || +> **Article [Type](../../development/writing_documentation.html#types-of-technical-articles):** tutorial || > **Level:** intermediary || -> **Author:** [Name Surname](https://gitlab.com/username) +> **Author:** [Name Surname](https://gitlab.com/username) || +> **Publication date:** AAAA/MM/DD ``` #### Technical Articles - Writing Method diff --git a/doc/install/README.md b/doc/install/README.md index 58cc7d312fd..bc831a37735 100644 --- a/doc/install/README.md +++ b/doc/install/README.md @@ -18,8 +18,8 @@ the hardware requirements. Useful for unsupported systems like *BSD. For an overview of the directory structure, read the [structure documentation](structure.md). - [Docker](https://docs.gitlab.com/omnibus/docker/) - Install GitLab using Docker. -- [Installation on Google Cloud Platform](google_cloud_platform/index.md) - Install - GitLab on Google Cloud Platform using our official image. +- [Installing in Kubernetes](kubernetes/index.md) - Install GitLab into a Kubernetes + Cluster using our official Helm Chart Repository. - Testing only! [DigitalOcean and Docker Machine](digitaloceandocker.md) - Quickly test any version of GitLab on DigitalOcean using Docker Machine. diff --git a/doc/install/google_cloud_platform/index.md b/doc/install/google_cloud_platform/index.md index 26506111548..35220119e9b 100644 --- a/doc/install/google_cloud_platform/index.md +++ b/doc/install/google_cloud_platform/index.md @@ -2,6 +2,10 @@ ![GCP landing page](img/gcp_landing.png) +>**Important note:** +GitLab has no official images in Google Cloud Platform yet. This guide serves +as a template for when the GitLab VM will be available. + The fastest way to get started on [Google Cloud Platform (GCP)][gcp] is through the [Google Cloud Launcher][launcher] program. diff --git a/doc/install/installation.md b/doc/install/installation.md index dc807d93bbb..5bba405f159 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -109,14 +109,19 @@ Then select 'Internet Site' and press enter to confirm the hostname. ## 2. Ruby -**Note:** The current supported Ruby version is 2.3.x. GitLab 9.0 dropped support -for Ruby 2.1.x. +The Ruby interpreter is required to run GitLab. + +**Note:** The current supported Ruby (MRI) version is 2.3.x. GitLab 9.0 dropped +support for Ruby 2.1.x. The use of Ruby version managers such as [RVM], [rbenv] or [chruby] with GitLab in production, frequently leads to hard to diagnose problems. For example, GitLab Shell is called from OpenSSH, and having a version manager can prevent pushing and pulling over SSH. Version managers are not supported and we strongly -advise everyone to follow the instructions below to use a system Ruby. +advise everyone to follow the instructions below to use a system Ruby. + +Linux distributions generally have older versions of Ruby available, so these +instructions are designed to install Ruby from the official source code. Remove the old Ruby 1.8 if present: @@ -132,7 +137,7 @@ Download Ruby and compile it: make sudo make install -Install the Bundler Gem: +Then install the Bundler Gem: sudo gem install bundler --no-ri --no-rdoc @@ -289,9 +294,9 @@ sudo usermod -aG redis git ### Clone the Source # Clone GitLab repository - sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 9-1-stable gitlab + sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 9-2-stable gitlab -**Note:** You can change `9-1-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! +**Note:** You can change `9-2-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! ### Configure It diff --git a/doc/install/kubernetes/gitlab_chart.md b/doc/install/kubernetes/gitlab_chart.md new file mode 100644 index 00000000000..2d7edbe16e4 --- /dev/null +++ b/doc/install/kubernetes/gitlab_chart.md @@ -0,0 +1,436 @@ +# GitLab Helm Chart + +The `gitlab` Helm chart deploys GitLab into your Kubernetes cluster. + +This chart includes the following: + +- Deployment using the [gitlab-ce](https://hub.docker.com/r/gitlab/gitlab-ce) or [gitlab-ee](https://hub.docker.com/r/gitlab/gitlab-ee) container image +- ConfigMap containing the `gitlab.rb` contents that configure [Omnibus GitLab](https://docs.gitlab.com/omnibus/settings/configuration.html#configuration-options) +- Persistent Volume Claims for Data, Config, Logs, and Registry Storage +- A Kubernetes service +- Optional Redis deployment using the [Redis Chart](https://github.com/kubernetes/charts/tree/master/stable/redis) (defaults to enabled) +- Optional PostgreSQL deployment using the [PostgreSQL Chart](https://github.com/kubernetes/charts/tree/master/stable/postgresql) (defaults to enabled) +- Optional Ingress (defaults to disabled) + +## Prerequisites + +- _At least_ 3 GB of RAM available on your cluster, in chunks of 1 GB +- Kubernetes 1.4+ with Beta APIs enabled +- [Persistent Volume](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) provisioner support in the underlying infrastructure +- The ability to point a DNS entry or URL at your GitLab install +- The `kubectl` CLI installed locally and authenticated for the cluster +- The Helm Client installed locally +- The Helm Server (Tiller) already installed and running in the cluster, by running `helm init` +- The GitLab Helm Repo [added to your Helm Client](index.md#add-the-gitlab-helm-repository) + +## Configuring GitLab + +Create a `values.yaml` file for your GitLab configuration. See the +[Helm docs](https://github.com/kubernetes/helm/blob/master/docs/chart_template_guide/values_files.md) +for information on how your values file will override the defaults. + +The default configuration can always be [found in the `values.yaml`](https://gitlab.com/charts/charts.gitlab.io/blob/master/charts/gitlab/values.yaml), in the chart repository. + +### Required configuration + +In order for GitLab to function, your config file **must** specify the following: + +- An `externalUrl` that GitLab will be reachable at. + +### Choosing GitLab Edition + +The Helm chart defaults to installing GitLab CE. This can be controlled by setting the `edition` variable in your values. + +Setting `edition` to GitLab Enterprise Edition (EE) in your `values.yaml` + +```yaml +edition: EE + +externalUrl: 'http://gitlab.example.com' +``` + +### Choosing a different GitLab release version + +The version of GitLab installed is based on the `edition` setting (see [section](#choosing-gitlab-edition) above), and +the value of the corresponding helm setting: `ceImage` or `eeImage`. + +```yaml +## GitLab Edition +## ref: https://about.gitlab.com/products/ +## - CE - Community Edition +## - EE - Enterprise Edition - (requires license issued by GitLab Inc) +## +edition: CE + +## GitLab CE image +## ref: https://hub.docker.com/r/gitlab/gitlab-ce/tags/ +## +ceImage: gitlab/gitlab-ce:9.1.2-ce.0 + +## GitLab EE image +## ref: https://hub.docker.com/r/gitlab/gitlab-ee/tags/ +## +eeImage: gitlab/gitlab-ee:9.1.2-ee.0 +``` + +The different images can be found in the [gitlab-ce](https://hub.docker.com/r/gitlab/gitlab-ce/tags/) and [gitlab-ee](https://hub.docker.com/r/gitlab/gitlab-ee/tags/) +repositories on Docker Hub + +> **Note:** +There is no guarantee that other release versions of GitLab, other than what are +used by default in the chart, will be supported by a chart install. + + +### Custom Omnibus GitLab configuration + +In addition to the configuration options provided for GitLab in the Helm Chart, you can also pass any custom configuration +that is valid for the [Omnibus GitLab Configuration](https://docs.gitlab.com/omnibus/settings/configuration.html). + +The setting to pass these values in is `omnibusConfigRuby`. It accepts any valid +Ruby code that could used in the Omnibus `/etc/gitlab/gitlab.rb` file. In +Kubernetes, the contents will be stored in a ConfigMap. + +Example setting: + +```yaml +omnibusConfigRuby: | + unicorn['worker_processes'] = 2; + gitlab_rails['trusted_proxies'] = ["10.0.0.0/8","172.16.0.0/12","192.168.0.0/16"]; +``` + +### Persistent storage + +By default, persistent storage is enabled for GitLab and the charts it depends +on (Redis and PostgreSQL). + +Components can have their claim size set from your `values.yaml`, and each +component allows you to optionally configure the `storageClass` variable so you +can take advantage of faster drives on your cloud provider. + +Basic configuration: + +```yaml +## Enable persistence using Persistent Volume Claims +## ref: http://kubernetes.io/docs/user-guide/persistent-volumes/ +## ref: https://docs.gitlab.com/ce/install/requirements.html#storage +## +persistence: + ## This volume persists generated configuration files, keys, and certs. + ## + gitlabEtc: + enabled: true + size: 1Gi + ## If defined, volume.beta.kubernetes.io/storage-class: <storageClass> + ## Default: volume.alpha.kubernetes.io/storage-class: default + ## + # storageClass: + accessMode: ReadWriteOnce + ## This volume is used to store git data and other project files. + ## ref: https://docs.gitlab.com/omnibus/settings/configuration.html#storing-git-data-in-an-alternative-directory + ## + gitlabData: + enabled: true + size: 10Gi + ## If defined, volume.beta.kubernetes.io/storage-class: <storageClass> + ## Default: volume.alpha.kubernetes.io/storage-class: default + ## + # storageClass: + accessMode: ReadWriteOnce + gitlabRegistry: + enabled: true + size: 10Gi + ## If defined, volume.beta.kubernetes.io/storage-class: <storageClass> + ## Default: volume.alpha.kubernetes.io/storage-class: default + ## + # storageClass: + + postgresql: + persistence: + # storageClass: + size: 10Gi + ## Configuration values for the Redis dependency. + ## ref: https://github.com/kubernetes/charts/blob/master/stable/redis/README.md + ## + redis: + persistence: + # storageClass: + size: 10Gi +``` + +>**Note:** +You can make use of faster SSD drives by adding a [StorageClass] to your cluster +and using the `storageClass` setting in the above config to the name of +your new storage class. + +### Routing + +By default, the GitLab chart uses a service type of `LoadBalancer` which will +result in the GitLab service being exposed externally using your cloud provider's +load balancer. + +This field is configurable in your `values.yml` by setting the top-level +`serviceType` field. See the [Service documentation][kube-srv] for more +information on the possible values. + +#### Ingress routing + +Optionally, you can enable the Chart's ingress for use by an ingress controller +deployed in your cluster. + +To enable the ingress, edit its section in your `values.yaml`: + +```yaml +ingress: + ## If true, gitlab Ingress will be created + ## + enabled: true + + ## gitlab Ingress hostnames + ## Must be provided if Ingress is enabled + ## + hosts: + - gitlab.example.com + + ## gitlab Ingress annotations + ## + annotations: + kubernetes.io/ingress.class: nginx +``` + +You must also provide the list of hosts that the ingress will use. In order for +you ingress controller to work with the GitLab Ingress, you will need to specify +its class in an annotation. + +>**Note:** +The Ingress alone doesn't expose GitLab externally. You need to have a Ingress controller setup to do that. +Setting up an Ingress controller can be as simple as installing the `nginx-ingress` helm chart. But be sure +to read the [documentation](https://github.com/kubernetes/charts/blob/master/stable/nginx-ingress/README.md) + +### External database + +You can configure the GitLab Helm chart to connect to an external PostgreSQL +database. + +>**Note:** +This is currently our recommended approach for a Production setup. + +To use an external database, in your `values.yaml`, disable the included +PostgreSQL dependency, then configure access to your database: + +```yaml +dbHost: "<reachable postgres hostname>" +dbPassword: "<password for the user with access to the db>" +dbUsername: "<user with read/write access to the database>" +dbDatabase: "<database name on postgres to connect to for GitLab>" + +postgresql: + # Sets whether the PostgreSQL helm chart is used as a dependency + enabled: false +``` + +Be sure to check the GitLab documentation on how to +[configure the external database](../requirements.md#postgresql-requirements) + +You can also configure the chart to use an external Redis server, but this is +not required for basic production use: + +```yaml +dbHost: "<reachable redis hostname>" +dbPassword: "<password>" + +redis: + # Sets whether the Redis helm chart is used as a dependency + enabled: false +``` + +### Sending email + +By default, the GitLab container will not be able to send email from your cluster. +In order to send email, you should configure SMTP settings in the +`omnibusConfigRuby` section, as per the [GitLab Omnibus documentation](https://docs.gitlab.com/omnibus/settings/smtp.html). + +>**Note:** +Some cloud providers restrict emails being sent out on SMTP, so you will have +to use a SMTP service that is supported by your provider. See this +[Google Cloud Platform page](https://cloud.google.com/compute/docs/tutorials/sending-mail/) +as and example. + +Here is an example configuration for Mailgun SMTP support: + +```yaml +omnibusConfigRuby: | + # This is example config of what you may already have in your omnibusConfigRuby object + unicorn['worker_processes'] = 2; + gitlab_rails['trusted_proxies'] = ["10.0.0.0/8","172.16.0.0/12","192.168.0.0/16"]; + + # SMTP settings + gitlab_rails['smtp_enable'] = true + gitlab_rails['smtp_address'] = "smtp.mailgun.org" + gitlab_rails['smtp_port'] = 2525 # High port needed for Google Cloud + gitlab_rails['smtp_authentication'] = "plain" + gitlab_rails['smtp_enable_starttls_auto'] = false + gitlab_rails['smtp_user_name'] = "postmaster@mg.your-mail-domain" + gitlab_rails['smtp_password'] = "you-password" + gitlab_rails['smtp_domain'] = "mg.your-mail-domain" +``` + +### HTTPS configuration + +To setup HTTPS access to your GitLab server, first you need to configure the +chart to use the [ingress](#ingress-routing). + +GitLab's config should be updated to support [proxied SSL](https://docs.gitlab.com/omnibus/settings/nginx.html#supporting-proxied-ssl). + +In addition to having a Ingress Controller deployed and the basic ingress +settings configured, you will also need to specify in the ingress settings +which hosts to use HTTPS for. + +Make sure `externalUrl` now includes `https://` instead of `http://` in its +value, and update the `omnibusConfigRuby` section: + +```yaml +externalUrl: 'https://gitlab.example.com' + +omnibusConfigRuby: | + # This is example config of what you may already have in your omnibusConfigRuby object + unicorn['worker_processes'] = 2; + gitlab_rails['trusted_proxies'] = ["10.0.0.0/8","172.16.0.0/12","192.168.0.0/16"]; + + # These are the settings needed to support proxied SSL + nginx['listen_port'] = 80 + nginx['listen_https'] = false + nginx['proxy_set_headers'] = { + "X-Forwarded-Proto" => "https", + "X-Forwarded-Ssl" => "on" + } + +ingress: + enabled: true + annotations: + kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: 'true' Annotation used for letsencrypt support + + hosts: + - gitlab.example.com + + ## gitlab Ingress TLS configuration + ## Secrets must be created in the namespace, and is not done for you in this chart + ## + tls: + - secretName: gitlab-tls + hosts: + - gitlab.example.com +``` + +You will need to create the named secret in your cluster, specifying the private +and public certificate pair using the format outlined in the +[ingress documentation](https://kubernetes.io/docs/concepts/services-networking/ingress/#tls). + +Alternatively, you can use the `kubernetes.io/tls-acme` annotation, and install +the `kube-lego` chart to your cluster to have Let's Encrypt issue your +certificate. See the [kube-lego documentation](https://github.com/kubernetes/charts/blob/master/stable/kube-lego/README.md) +for more information. + +### Enabling the GitLab Container Registry + +The GitLab Registry is disabled by default but can be enabled by providing an +external URL for it in the configuration. In order for the Registry to be easily +used by GitLab CI and your Kubernetes cluster, you will need to set it up with +a TLS certificate, so these examples will include the ingress settings for that +as well. See the [HTTPS Configuration section](#https-configuration) +for more explanation on some of these settings. + +Example config: + +```yaml +externalUrl: 'https://gitlab.example.com' + +omnibusConfigRuby: | + # This is example config of what you may already have in your omnibusConfigRuby object + unicorn['worker_processes'] = 2; + gitlab_rails['trusted_proxies'] = ["10.0.0.0/8","172.16.0.0/12","192.168.0.0/16"]; + + registry_external_url 'https://registry.example.com'; + + # These are the settings needed to support proxied SSL + nginx['listen_port'] = 80 + nginx['listen_https'] = false + nginx['proxy_set_headers'] = { + "X-Forwarded-Proto" => "https", + "X-Forwarded-Ssl" => "on" + } + registry_nginx['listen_port'] = 80 + registry_nginx['listen_https'] = false + registry_nginx['proxy_set_headers'] = { + "X-Forwarded-Proto" => "https", + "X-Forwarded-Ssl" => "on" + } + +ingress: + enabled: true + annotations: + kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: 'true' Annotation used for letsencrypt support + + hosts: + - gitlab.example.com + - registry.example.com + + ## gitlab Ingress TLS configuration + ## Secrets must be created in the namespace, and is not done for you in this chart + ## + tls: + - secretName: gitlab-tls + hosts: + - gitlab.example.com + - registry.example.com +``` + +## Installing GitLab using the Helm Chart + +Once you [have configured](#configuration) GitLab in your `values.yml` file, +run the following: + +```bash +helm install --namespace <NAMESPACE> --name gitlab -f <CONFIG_VALUES_FILE> gitlab/gitlab +``` + +where: + +- `<NAMESPACE>` is the Kubernetes namespace where you want to install GitLab. +- `<CONFIG_VALUES_FILE>` is the path to values file containing your custom + configuration. See the [Configuration](#configuration) section to create it. + +## Updating GitLab using the Helm Chart + +Once your GitLab Chart is installed, configuration changes and chart updates +should we done using `helm upgrade` + +```bash +helm upgrade --namespace <NAMESPACE> -f <CONFIG_VALUES_FILE> <RELEASE-NAME> gitlab/gitlab +``` + +where: + +- `<NAMESPACE>` is the Kubernetes namespace where GitLab is installed. +- `<CONFIG_VALUES_FILE>` is the path to values file containing your custom + [configuration] (#configuration). +- `<RELEASE-NAME>` is the name you gave the chart when installing it. + In the [Install section](#installing) we called it `gitlab`. + +## Uninstalling GitLab using the Helm Chart + +To uninstall the GitLab Chart, run the following: + +```bash +helm delete --namespace <NAMESPACE> <RELEASE-NAME> +``` + +where: + +- `<NAMESPACE>` is the Kubernetes namespace where GitLab is installed. +- `<RELEASE-NAME>` is the name you gave the chart when installing it. + In the [Install section](#installing) we called it `gitlab`. + +[kube-srv]: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services---service-types +[storageclass]: https://kubernetes.io/docs/concepts/storage/persistent-volumes/#storageclasses diff --git a/doc/install/kubernetes/gitlab_runner_chart.md b/doc/install/kubernetes/gitlab_runner_chart.md new file mode 100644 index 00000000000..dbd9ae3f70c --- /dev/null +++ b/doc/install/kubernetes/gitlab_runner_chart.md @@ -0,0 +1,175 @@ +# GitLab Runner Helm Chart + +The `gitlab-runner` Helm chart deploys a GitLab Runner instance into your +Kubernetes cluster. + +This chart configures the Runner to: + +- Run using the GitLab Runner [Kubernetes executor](https://docs.gitlab.com/runner/install/kubernetes.html) +- For each new job it receives from [GitLab CI](https://about.gitlab.com/features/gitlab-ci-cd/), it will provision a + new pod within the specified namespace to run it. + +## Prerequisites + +- Your GitLab Server's API is reachable from the cluster +- Kubernetes 1.4+ with Beta APIs enabled +- The `kubectl` CLI installed locally and authenticated for the cluster +- The Helm Client installed locally +- The Helm Server (Tiller) already installed and running in the cluster, by running `helm init` +- The GitLab Helm Repo added to your Helm Client. See [Adding GitLab Helm Repo](index.md#add-the-gitlab-helm-repository) + +## Configuring GitLab Runner using the Helm Chart + +Create a `values.yaml` file for your GitLab Runner configuration. See [Helm docs](https://github.com/kubernetes/helm/blob/master/docs/chart_template_guide/values_files.md) +for information on how your values file will override the defaults. + +The default configuration can always be found in the [values.yaml](https://gitlab.com/charts/charts.gitlab.io/blob/master/charts/gitlab-runner/values.yaml) in the chart repository. + +### Required configuration + +In order for GitLab Runner to function, your config file **must** specify the following: + + - `gitlabURL` - the GitLab Server URL (with protocol) to register the runner against + - `runnerRegistrationToken` - The Registration Token for adding new Runners to the GitLab Server. This must be + retrieved from your GitLab Instance. See the [GitLab Runner Documentation](../../ci/runners/README.md#creating-and-registering-a-runner) for more information. + +### Other configuration + +The rest of the configuration is [documented in the `values.yaml`](https://gitlab.com/charts/charts.gitlab.io/blob/master/charts/gitlab-runner/values.yaml) in the chart repository. + +Here is a snippet of the important settings: + +```yaml +## The GitLab Server URL (with protocol) that want to register the runner against +## ref: https://docs.gitlab.com/runner/commands/README.html#gitlab-runner-register +## +gitlabURL: http://gitlab.your-domain.com/ + +## The Registration Token for adding new Runners to the GitLab Server. This must +## be retreived from your GitLab Instance. +## ref: https://docs.gitlab.com/ce/ci/runners/README.html#creating-and-registering-a-runner +## +runnerRegistrationToken: "" + +## Configure the maximum number of concurrent jobs +## ref: https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-global-section +## +concurrent: 10 + +## Defines in seconds how often to check GitLab for a new builds +## ref: https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-global-section +## +checkInterval: 30 + +## Configuration for the Pods that that the runner launches for each new job +## +runners: + ## Default container image to use for builds when none is specified + ## + image: ubuntu:16.04 + + ## Run all containers with the privileged flag enabled + ## This will allow the docker:dind image to run if you need to run Docker + ## commands. Please read the docs before turning this on: + ## ref: https://docs.gitlab.com/runner/executors/kubernetes.html#using-docker-dind + ## + privileged: false + + ## Namespace to run Kubernetes jobs in (defaults to 'default') + ## + # namespace: + + ## Build Container specific configuration + ## + builds: + # cpuLimit: 200m + # memoryLimit: 256Mi + cpuRequests: 100m + memoryRequests: 128Mi + + ## Service Container specific configuration + ## + services: + # cpuLimit: 200m + # memoryLimit: 256Mi + cpuRequests: 100m + memoryRequests: 128Mi + + ## Helper Container specific configuration + ## + helpers: + # cpuLimit: 200m + # memoryLimit: 256Mi + cpuRequests: 100m + memoryRequests: 128Mi + +``` + +### Running Docker-in-Docker containers with GitLab Runners + +See [Running Privileged Containers for the Runners](#running-privileged-containers-for-the-runners) for how to enable it, +and the [GitLab CI Runner documentation](https://docs.gitlab.com/runner/executors/kubernetes.html#using-docker-in-your-builds) on running dind. + +### Running privileged containers for the Runners + +You can tell the GitLab Runner to run using privileged containers. You may need +this enabled if you need to use the Docker executable within your GitLab CI jobs. + +This comes with several risks that you can read about in the +[GitLab CI Runner documentation](https://docs.gitlab.com/runner/executors/kubernetes.html#using-docker-in-your-builds). + +If you are okay with the risks, and your GitLab CI Runner instance is registered +against a specific project in GitLab that you trust the CI jobs of, you can +enable privileged mode in `values.yaml`: + +```yaml +runners: + ## Run all containers with the privileged flag enabled + ## This will allow the docker:dind image to run if you need to run Docker + ## commands. Please read the docs before turning this on: + ## ref: https://docs.gitlab.com/runner/executors/kubernetes.html#using-docker-dind + ## + privileged: true +``` + +## Installing GitLab Runner using the Helm Chart + +Once you [have configured](#configuration) GitLab Runner in your `values.yml` file, +run the following: + +```bash +helm install --namepace <NAMEPACE> --name gitlab-runner -f <CONFIG_VALUES_FILE> gitlab/gitlab-runner +``` + +- `<NAMESPACE>` is the Kubernetes namespace where you want to install the GitLab Runner. +- `<CONFIG_VALUES_FILE>` is the path to values file containing your custom configuration. See the + [Configuration](#configuration) section to create it. + +## Updating GitLab Runner using the Helm Chart + +Once your GitLab Runner Chart is installed, configuration changes and chart updates should we done using `helm upgrade` + +```bash +helm upgrade --namepace <NAMEPACE> -f <CONFIG_VALUES_FILE> <RELEASE-NAME> gitlab/gitlab-runner +``` + +Where: +- `<NAMESPACE>` is the Kubernetes namespace where GitLab Runner is installed +- `<CONFIG_VALUES_FILE>` is the path to values file containing your custom configuration. See the + [Configuration](#configuration) section to create it. +- `<RELEASE-NAME>` is the name you gave the chart when installing it. + In the [Install section](#installing) we called it `gitlab-runner`. + +## Uninstalling GitLab Runner using the Helm Chart + +To uninstall the GitLab Runner Chart, run the following: + +```bash +helm delete --namespace <NAMESPACE> <RELEASE-NAME> +``` + +where: + +- `<NAMESPACE>` is the Kubernetes namespace where GitLab Runner is installed +- `<RELEASE-NAME>` is the name you gave the chart when installing it. + In the [Install section](#installing) we called it `gitlab-runner`. diff --git a/doc/install/kubernetes/index.md b/doc/install/kubernetes/index.md new file mode 100644 index 00000000000..db0430fc27b --- /dev/null +++ b/doc/install/kubernetes/index.md @@ -0,0 +1,44 @@ +# Installing GitLab in Kubernetes + +The easiest method to deploy GitLab in [Kubernetes](https://kubernetes.io/) is +to take advantage of the official GitLab Helm charts. [Helm] is a package +management tool for Kubernetes, allowing apps to be easily managed via their +Charts. A [Chart] is a detailed description of the application including how it +should be deployed, upgraded, and configured. + +The GitLab Helm repository is located at https://charts.gitlab.io. +You can report any issues related to GitLab's Helm Charts at +https://gitlab.com/charts/charts.gitlab.io/issues. +Contributions and improvements are also very welcome. + +## Prerequisites + +To use the charts, the Helm tool must be installed and initialized. The best +place to start is by reviewing the [Helm Quick Start Guide][helm-quick]. + +## Add the GitLab Helm repository + +Once Helm has been installed, the GitLab chart repository must be added: + +```bash +helm repo add gitlab https://charts.gitlab.io +``` + +After adding the repository, Helm must be re-initialized: + +```bash +helm init +``` + +## Using the GitLab Helm Charts + +GitLab makes available two Helm Charts, one for the GitLab server and another +for the Runner. More detailed information on installing and configuring each +Chart can be found below: + +- [Install GitLab](gitlab_chart.md) +- [Install GitLab Runner](gitlab_runner_chart.md) + +[chart]: https://github.com/kubernetes/charts +[helm-quick]: https://github.com/kubernetes/helm/blob/master/docs/quickstart.md +[helm]: https://github.com/kubernetes/helm/blob/master/README.md diff --git a/doc/install/requirements.md b/doc/install/requirements.md index 35586091f74..2e456557d77 100644 --- a/doc/install/requirements.md +++ b/doc/install/requirements.md @@ -122,15 +122,25 @@ To change the Unicorn workers when you have the Omnibus package please see [the We currently support the following databases: -- PostgreSQL (recommended) +- PostgreSQL - MySQL/MariaDB -If you want to run the database separately, expect a size of about 1 MB per user. +We _highly_ recommend the use of PostgreSQL instead of MySQL/MariaDB as not all +features of GitLab may work with MySQL/MariaDB. For example, MySQL does not have +the right features to support nested groups in an efficient manner; see +<https://gitlab.com/gitlab-org/gitlab-ce/issues/30472> for more information +about this. Existing users using GitLab with MySQL/MariaDB are advised to +migrate to PostgreSQL instead. + +The server running the database should have _at least_ 5-10 GB of storage +available, though the exact requirements depend on the size of the GitLab +installation (e.g. the number of users, projects, etc). ### PostgreSQL Requirements -As of GitLab 9.0, PostgreSQL 9.6 is recommended. Lower versions of PostgreSQL -may work but primary testing and developement takes place using PostgreSQL 9.6. +As of GitLab 9.0, PostgreSQL 9.2 or newer is required, and earlier versions are +not supported. We highly recommend users to use at least PostgreSQL 9.6 as this +is the PostgreSQL version used for development and testing. Users using PostgreSQL must ensure the `pg_trgm` extension is loaded into every GitLab database. This extension can be enabled (using a PostgreSQL super user) @@ -165,4 +175,4 @@ about it, check the [Prometheus documentation](../administration/monitoring/prom We support the current and the previous major release of Firefox, Chrome/Chromium, Safari and Microsoft browsers (Microsoft Edge and Internet Explorer 11). -Each time a new browser version is released, we begin supporting that version and stop supporting the third most recent version.
\ No newline at end of file +Each time a new browser version is released, we begin supporting that version and stop supporting the third most recent version. diff --git a/doc/system_hooks/system_hooks.md b/doc/system_hooks/system_hooks.md index ad5ffc84473..583ec5522fd 100644 --- a/doc/system_hooks/system_hooks.md +++ b/doc/system_hooks/system_hooks.md @@ -266,7 +266,8 @@ X-Gitlab-Event: System Hook ## Push events -Triggered when you push to the repository except when pushing tags. +Triggered when you push to the repository, except when pushing tags. +It generates one event per modified branch. **Request header**: @@ -332,6 +333,7 @@ X-Gitlab-Event: System Hook ## Tag events Triggered when you create (or delete) tags to the repository. +It generates one event per modified tag. **Request header**: @@ -381,3 +383,49 @@ X-Gitlab-Event: System Hook "total_commits_count": 0 } ``` +## Repository Update events + +Triggered only once when you push to the repository (including tags). + +**Request header**: + +``` +X-Gitlab-Event: System Hook +``` + +**Request body:** + +```json +{ + "event_name": "repository_update", + "user_id": 1, + "user_name": "John Smith", + "user_email": "admin@example.com", + "user_avatar": "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80", + "project_id": 1, + "project": { + "name":"Example", + "description":"", + "web_url":"http://example.com/jsmith/example", + "avatar_url":null, + "git_ssh_url":"git@example.com:jsmith/example.git", + "git_http_url":"http://example.com/jsmith/example.git", + "namespace":"Jsmith", + "visibility_level":0, + "path_with_namespace":"jsmith/example", + "default_branch":"master", + "homepage":"http://example.com/jsmith/example", + "url":"git@example.com:jsmith/example.git", + "ssh_url":"git@example.com:jsmith/example.git", + "http_url":"http://example.com/jsmith/example.git", + }, + "changes": [ + { + "before":"8205ea8d81ce0c6b90fbe8280d118cc9fdad6130", + "after":"4045ea7a3df38697b3730a20fb73c8bed8a3e69e", + "ref":"refs/heads/master" + } + ], + "refs":["refs/heads/master"] +} +``` diff --git a/doc/topics/authentication/index.md b/doc/topics/authentication/index.md index 3e756d96ed2..0c0d482499a 100644 --- a/doc/topics/authentication/index.md +++ b/doc/topics/authentication/index.md @@ -19,7 +19,7 @@ This page gathers all the resources for the topic **Authentication** within GitL - [Enforce Two-factor Authentication (2FA)](../../security/two_factor_authentication.md#enforce-two-factor-authentication-2fa) - **Articles:** - [How to Configure LDAP with GitLab CE](../../articles/how_to_configure_ldap_gitlab_ce/index.md) - - [How to Configure LDAP with GitLab EE](https://docs.gitlab.com/articles/how_to_configure_ldap_gitlab_ee/) + - [How to Configure LDAP with GitLab EE](https://docs.gitlab.com/ee/articles/how_to_configure_ldap_gitlab_ee/) - [Feature Highlight: LDAP Integration](https://about.gitlab.com/2014/07/10/feature-highlight-ldap-sync/) - [Debugging LDAP](https://about.gitlab.com/handbook/support/workflows/ldap/debugging_ldap.html) - **Integrations:** diff --git a/doc/topics/git/index.md b/doc/topics/git/index.md index d13066c9015..604f9375714 100644 --- a/doc/topics/git/index.md +++ b/doc/topics/git/index.md @@ -22,6 +22,7 @@ We've gathered some resources to help you to get the best from Git with GitLab. - [Cherry-picking a commit](../../user/project/merge_requests/cherry_pick_changes.md#cherry-picking-a-commit) - [Squashing commits](../../workflow/gitlab_flow.md#squashing-commits-with-rebase) - **Articles:** + - [How to install Git](../../articles/how_to_install_git/index.md) - [Git Tips & Tricks](https://about.gitlab.com/2016/12/08/git-tips-and-tricks/) - [Eight Tips to help you work better with Git](https://about.gitlab.com/2015/02/19/8-tips-to-help-you-work-better-with-git/) - **Presentations:** diff --git a/doc/update/9.1-to-9.2.md b/doc/update/9.1-to-9.2.md new file mode 100644 index 00000000000..19db6e5763e --- /dev/null +++ b/doc/update/9.1-to-9.2.md @@ -0,0 +1,288 @@ +# From 9.1 to 9.2 + +Make sure you view this update guide from the tag (version) of GitLab you would +like to install. In most cases this should be the highest numbered production +tag (without rc in it). You can select the tag in the version dropdown at the +top left corner of GitLab (below the menu bar). + +If the highest number stable branch is unclear please check the +[GitLab Blog](https://about.gitlab.com/blog/archives.html) for installation +guide links by version. + +### 1. Stop server + +```bash +sudo service gitlab stop +``` + +### 2. Backup + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production +``` + +### 3. Update Ruby + +NOTE: GitLab 9.0 and higher only support Ruby 2.3.x and dropped support for Ruby 2.1.x. Be +sure to upgrade your interpreter if necessary. + +You can check which version you are running with `ruby -v`. + +Download and compile Ruby: + +```bash +mkdir /tmp/ruby && cd /tmp/ruby +curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.3.tar.gz +echo '1014ee699071aa2ddd501907d18cbe15399c997d ruby-2.3.3.tar.gz' | shasum -c - && tar xzf ruby-2.3.3.tar.gz +cd ruby-2.3.3 +./configure --disable-install-rdoc +make +sudo make install +``` + +Install Bundler: + +```bash +sudo gem install bundler --no-ri --no-rdoc +``` + +### 4. Update Node + +GitLab now runs [webpack](http://webpack.js.org) to compile frontend assets and +it has a minimum requirement of node v4.3.0. + +You can check which version you are running with `node -v`. If you are running +a version older than `v4.3.0` you will need to update to a newer version. You +can find instructions to install from community maintained packages or compile +from source at the nodejs.org website. + +<https://nodejs.org/en/download/> + + +Since 8.17, GitLab requires the use of yarn `>= v0.17.0` to manage +JavaScript dependencies. + +```bash +curl --location https://yarnpkg.com/install.sh | bash - +``` + +More information can be found on the [yarn website](https://yarnpkg.com/en/docs/install). + +### 5. Get latest code + +```bash +cd /home/git/gitlab + +sudo -u git -H git fetch --all +sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically +``` + +For GitLab Community Edition: + +```bash +cd /home/git/gitlab + +sudo -u git -H git checkout 9-2-stable +``` + +OR + +For GitLab Enterprise Edition: + +```bash +cd /home/git/gitlab + +sudo -u git -H git checkout 9-2-stable-ee +``` + +### 6. Update gitlab-shell + +```bash +cd /home/git/gitlab-shell + +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_SHELL_VERSION) +sudo -u git -H bin/compile +``` + +### 7. Update gitlab-workhorse + +Install and compile gitlab-workhorse. This requires +[Go 1.5](https://golang.org/dl) which should already be on your system from +GitLab 8.1. GitLab-Workhorse uses [GNU Make](https://www.gnu.org/software/make/). +If you are not using Linux you may have to run `gmake` instead of +`make` below. + +```bash +cd /home/git/gitlab-workhorse + +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_WORKHORSE_VERSION) +sudo -u git -H make +``` + +### 8. Update configuration files + +#### New configuration options for `gitlab.yml` + +There might be configuration options available for [`gitlab.yml`][yaml]. View them with the command below and apply them manually to your current `gitlab.yml`: + +```sh +cd /home/git/gitlab + +git diff origin/9-1-stable:config/gitlab.yml.example origin/9-2-stable:config/gitlab.yml.example +``` + +#### Nginx configuration + +Ensure you're still up-to-date with the latest NGINX configuration changes: + +```sh +cd /home/git/gitlab + +# For HTTPS configurations +git diff origin/9-1-stable:lib/support/nginx/gitlab-ssl origin/9-2-stable:lib/support/nginx/gitlab-ssl + +# For HTTP configurations +git diff origin/9-1-stable:lib/support/nginx/gitlab origin/9-2-stable:lib/support/nginx/gitlab +``` + +If you are using Strict-Transport-Security in your installation to continue using it you must enable it in your Nginx +configuration as GitLab application no longer handles setting it. + +If you are using Apache instead of NGINX please see the updated [Apache templates]. +Also note that because Apache does not support upstreams behind Unix sockets you +will need to let gitlab-workhorse listen on a TCP port. You can do this +via [/etc/default/gitlab]. + +[Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache +[/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-2-stable/lib/support/init.d/gitlab.default.example#L38 + +#### SMTP configuration + +If you're installing from source and use SMTP to deliver mail, you will need to add the following line +to config/initializers/smtp_settings.rb: + +```ruby +ActionMailer::Base.delivery_method = :smtp +``` + +See [smtp_settings.rb.sample] as an example. + +[smtp_settings.rb.sample]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-1-stable/config/initializers/smtp_settings.rb.sample#L13 + +#### Init script + +There might be new configuration options available for [`gitlab.default.example`][gl-example]. View them with the command below and apply them manually to your current `/etc/default/gitlab`: + +```sh +cd /home/git/gitlab + +git diff origin/9-1-stable:lib/support/init.d/gitlab.default.example origin/9-2-stable:lib/support/init.d/gitlab.default.example +``` + +Ensure you're still up-to-date with the latest init script changes: + +```bash +cd /home/git/gitlab + +sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab +``` + +For Ubuntu 16.04.1 LTS: + +```bash +sudo systemctl daemon-reload +``` + +### 9. Install libs, migrations, etc. + +```bash +cd /home/git/gitlab + +# MySQL installations (note: the line below states '--without postgres') +sudo -u git -H bundle install --without postgres development test --deployment + +# PostgreSQL installations (note: the line below states '--without mysql') +sudo -u git -H bundle install --without mysql development test --deployment + +# Optional: clean up old gems +sudo -u git -H bundle clean + +# Run database migrations +sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production + +# Update node dependencies and recompile assets +sudo -u git -H bundle exec rake yarn:install gitlab:assets:clean gitlab:assets:compile RAILS_ENV=production NODE_ENV=production + +# Clean up cache +sudo -u git -H bundle exec rake cache:clear RAILS_ENV=production +``` + +**MySQL installations**: Run through the `MySQL strings limits` and `Tables and data conversion to utf8mb4` [tasks](../install/database_mysql.md). + +### 10. Optional: install Gitaly + +Gitaly is still an optional component of GitLab. If you want to save time +during your 9.2 upgrade **you can skip this step**. + +If you have not yet set up Gitaly then follow [Gitaly section of the installation +guide](../install/installation.md#install-gitaly). + +#### Compile Gitaly + +```shell +cd /home/git/gitaly +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v$(</home/git/gitlab/GITALY_SERVER_VERSION) +sudo -u git -H make +``` + +### 11. Start application + +```bash +sudo service gitlab start +sudo service nginx restart +``` + +### 12. Check application status + +Check if GitLab and its environment are configured correctly: + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production +``` + +To make sure you didn't miss anything run a more thorough check: + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production +``` + +If all items are green, then congratulations, the upgrade is complete! + +## Things went south? Revert to previous version (9.1) + +### 1. Revert the code to the previous version + +Follow the [upgrade guide from 9.0 to 9.1](9.0-to-9.1.md), except for the +database migration (the backup is already migrated to the previous version). + +### 2. Restore from the backup + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production +``` + +If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above. + +[yaml]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-2-stable/config/gitlab.yml.example +[gl-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-2-stable/lib/support/init.d/gitlab.default.example diff --git a/doc/user/admin_area/settings/usage_statistics.md b/doc/user/admin_area/settings/usage_statistics.md index 733e70ca9bf..375e7f08e8b 100644 --- a/doc/user/admin_area/settings/usage_statistics.md +++ b/doc/user/admin_area/settings/usage_statistics.md @@ -28,60 +28,13 @@ for all signed in users. [were added][ee-735] in GitLab Enterprise Edition 8.12. [Moved to GitLab Community Edition][ce-23361] in 9.1. -GitLab Inc. can collect non-sensitive information about how GitLab users -use their GitLab instance upon the activation of a ping feature -located in the admin panel (`/admin/application_settings`). - -You can see the **exact** JSON payload that your instance sends to GitLab -in the "Usage statistics" section of the admin panel. - -Nothing qualitative is collected. Only quantitative. That means no project -names, author names, comment bodies, names of labels, etc. - -The usage ping is sent in order for GitLab Inc. to have a better understanding -of how our users use our product, and to be more data-driven when creating or -changing features. - -The total number of the following is sent back to GitLab Inc.: - -- Comments -- Groups -- Users -- Projects -- Issues -- Labels -- CI builds -- Snippets -- Milestones -- Todos -- Pushes -- Merge requests -- Environments -- Triggers -- Deploy keys -- Pages -- Project Services -- Projects using the Prometheus service -- Issue Boards -- CI Runners -- Deployments -- Geo Nodes -- LDAP Groups -- LDAP Keys -- LDAP Users -- LFS objects -- Protected branches -- Releases -- Remote mirrors -- Uploads -- Web hooks - -Also, we track if you've installed Mattermost with GitLab. -For example: `"mattermost_enabled":true"`. - -More data will be added over time. The goal of this ping is to be as light as -possible, so it won't have any performance impact on your installation when -the calculation is made. +GitLab sends a weekly payload containing usage data to GitLab Inc. The usage +ping uses high-level data to help our product, support, and sales teams. It does +not send any project names, usernames, or any other specific data. The +information from the usage ping is not anonymous, it is linked to the hostname +of the instance. + +You can view the exact JSON payload in the administration panel. ### Deactivate the usage ping @@ -89,13 +42,23 @@ By default, usage ping is opt-out. If you want to deactivate this feature, go to the Settings page of your administration panel and uncheck the Usage ping checkbox. -## Privacy policy +To disable the usage ping and prevent it from being configured in future through +the administration panel, Omnibus installs can set the following in +[`gitlab.rb`](https://docs.gitlab.com/omnibus/settings/configuration.html#configuration-options): + +```ruby +gitlab_rails['usage_ping_enabled'] = false +``` -GitLab Inc. does **not** collect any sensitive information, like project names -or the content of the comments. GitLab Inc. does not disclose or otherwise make -available any of the data collected on a customer specific basis. +And source installs can set the following in `gitlab.yml`: -Read more about this in the [Privacy policy](https://about.gitlab.com/privacy). +```yaml +production: &base + # ... + gitlab: + # ... + usage_ping_enabled: false +``` [ee-557]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/557 [ee-735]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/735 diff --git a/doc/user/project/integrations/prometheus.md b/doc/user/project/integrations/prometheus.md index a74014b6b2f..b71d6981d1e 100644 --- a/doc/user/project/integrations/prometheus.md +++ b/doc/user/project/integrations/prometheus.md @@ -169,6 +169,14 @@ Clicking on the Monitoring button will display a new page, showing up to the las 8 hours of performance data. It may take a minute or two for data to appear after initial deployment. +## Determining performance impact of a merge + +> [Introduced][ce-10408] in GitLab 9.1. + +After a merge request has been approved, a sparkline will appear on the merge request page displaying the average memory usage of the application. The sparkline includes thirty minutes of data prior to the merge, a dot to indicate the merge itself, and then will begin capturing thirty minutes of data after the merge. + +This sparkline serves as a quick indicator of the impact on memory consumption of the recently merged changes. If there is a problem, action can then be taken to troubleshoot or revert the merge. + ## Troubleshooting If the "Attempting to load performance data" screen continues to appear, it could be due to: @@ -189,4 +197,5 @@ If the "Attempting to load performance data" screen continues to appear, it coul [gitlab.com-ip-range]: https://gitlab.com/gitlab-com/infrastructure/issues/434 [ci-environment-slug]: https://docs.gitlab.com/ce/ci/variables/#predefined-variables-environment-variables [ce-8935]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8935 +[ce-10408]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10408 [promgldocs]: ../../../administration/monitoring/prometheus/index.md diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index e15daa2feae..48d49c5d40c 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -74,6 +74,7 @@ X-Gitlab-Event: Push Hook "checkout_sha": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", "user_id": 4, "user_name": "John Smith", + "user_username": "jsmith", "user_email": "john@example.com", "user_avatar": "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80", "project_id": 15, diff --git a/doc/user/project/issues/img/create_new_merge_request.png b/doc/user/project/issues/img/create_new_merge_request.png Binary files differnew file mode 100644 index 00000000000..d4bfb6fa463 --- /dev/null +++ b/doc/user/project/issues/img/create_new_merge_request.png diff --git a/doc/user/project/issues/issues_functionalities.md b/doc/user/project/issues/issues_functionalities.md index 1efd07a058b..33fe768a0c6 100644 --- a/doc/user/project/issues/issues_functionalities.md +++ b/doc/user/project/issues/issues_functionalities.md @@ -155,3 +155,9 @@ Once you wrote your comment, you can either: - [New branch](../repository/web_editor.md#create-a-new-branch-from-an-issue): create a new branch, followed by a new merge request which will automatically close that issue as soon as that merge request is merged. + +#### 19. New merge request + +- Create a new merge request (with source branch) in one action. Optionally just create a new branch, as explained above. + +![Create new merge request](img/create_new_merge_request.png) diff --git a/doc/user/project/new_ci_build_permissions_model.md b/doc/user/project/new_ci_build_permissions_model.md index f846736028f..051a28efea6 100644 --- a/doc/user/project/new_ci_build_permissions_model.md +++ b/doc/user/project/new_ci_build_permissions_model.md @@ -89,7 +89,7 @@ to steal the tokens of other jobs. ## Pipeline triggers -Since 9.0 [pipelnie triggers][triggers] do support the new permission model. +Since 9.0 [pipeline triggers][triggers] do support the new permission model. The new triggers do impersonate their associated user including their access to projects and their project permissions. To migrate trigger to use new permisison model use **Take ownership**. diff --git a/doc/user/project/pages/getting_started_part_four.md b/doc/user/project/pages/getting_started_part_four.md index 50767095aa0..bd0cb437924 100644 --- a/doc/user/project/pages/getting_started_part_four.md +++ b/doc/user/project/pages/getting_started_part_four.md @@ -1,8 +1,9 @@ # GitLab Pages from A to Z: Part 4 -> **Type**: user guide || +> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide || > **Level**: intermediate || -> **Author**: [Marcia Ramos](https://gitlab.com/marcia) +> **Author**: [Marcia Ramos](https://gitlab.com/marcia) || +> **Publication date:** 2017/02/22 - [Part 1: Static sites and GitLab Pages domains](getting_started_part_one.md) - [Part 2: Quick start guide - Setting up GitLab Pages](getting_started_part_two.md) diff --git a/doc/user/project/pages/getting_started_part_one.md b/doc/user/project/pages/getting_started_part_one.md index e92549aa0df..2f104c7becc 100644 --- a/doc/user/project/pages/getting_started_part_one.md +++ b/doc/user/project/pages/getting_started_part_one.md @@ -1,15 +1,16 @@ # GitLab Pages from A to Z: Part 1 -> **Type**: user guide || +> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide || > **Level**: beginner || -> **Author**: [Marcia Ramos](https://gitlab.com/marcia) +> **Author**: [Marcia Ramos](https://gitlab.com/marcia) || +> **Publication date:** 2017/02/22 - **Part 1: Static sites and GitLab Pages domains** - [Part 2: Quick start guide - Setting up GitLab Pages](getting_started_part_two.md) - [Part 3: Setting Up Custom Domains - DNS Records and SSL/TLS Certificates](getting_started_part_three.md) - [Part 4: Creating and tweaking `.gitlab-ci.yml` for GitLab Pages](getting_started_part_four.md) -## GitLab Pages form A to Z +## GitLab Pages from A to Z This is a comprehensive guide, made for those who want to publish a website with GitLab Pages but aren't familiar with diff --git a/doc/user/project/pages/getting_started_part_three.md b/doc/user/project/pages/getting_started_part_three.md index 80f16e43e20..53fd1786cfa 100644 --- a/doc/user/project/pages/getting_started_part_three.md +++ b/doc/user/project/pages/getting_started_part_three.md @@ -1,8 +1,9 @@ # GitLab Pages from A to Z: Part 3 -> **Type**: user guide || +> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide || > **Level**: beginner || -> **Author**: [Marcia Ramos](https://gitlab.com/marcia) +> **Author**: [Marcia Ramos](https://gitlab.com/marcia) || +> **Publication date:** 2017/02/22 - [Part 1: Static sites and GitLab Pages domains](getting_started_part_one.md) - [Part 2: Quick start guide - Setting up GitLab Pages](getting_started_part_two.md) diff --git a/doc/user/project/pages/getting_started_part_two.md b/doc/user/project/pages/getting_started_part_two.md index 578ad13f5df..c91e2d8c261 100644 --- a/doc/user/project/pages/getting_started_part_two.md +++ b/doc/user/project/pages/getting_started_part_two.md @@ -1,8 +1,9 @@ # GitLab Pages from A to Z: Part 2 -> **Type**: user guide || +> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide || > **Level**: beginner || -> **Author**: [Marcia Ramos](https://gitlab.com/marcia) +> **Author**: [Marcia Ramos](https://gitlab.com/marcia) || +> **Publication date:** 2017/02/22 - [Part 1: Static sites and GitLab Pages domains](getting_started_part_one.md) - **Part 2: Quick start guide - Setting up GitLab Pages** diff --git a/features/profile/active_tab.feature b/features/profile/active_tab.feature index 788b7895d72..21d7d6c3800 100644 --- a/features/profile/active_tab.feature +++ b/features/profile/active_tab.feature @@ -23,7 +23,7 @@ Feature: Profile Active Tab Then the active main tab should be Preferences And no other main tabs should be active - Scenario: On Profile Audit Log - Given I visit Audit Log page - Then the active main tab should be Audit Log + Scenario: On Profile Authentication log + Given I visit Authentication log page + Then the active main tab should be Authentication log And no other main tabs should be active diff --git a/features/profile/profile.feature b/features/profile/profile.feature index 70f47c97173..3263d3e212b 100644 --- a/features/profile/profile.feature +++ b/features/profile/profile.feature @@ -63,7 +63,7 @@ Feature: Profile Given I logout And I sign in via the UI And I have activity - When I visit Audit Log page + When I visit Authentication log page Then I should see my activity Scenario: I visit my user page diff --git a/features/project/commits/revert.feature b/features/project/commits/revert.feature index 7a2effafe03..7ee1d717d80 100644 --- a/features/project/commits/revert.feature +++ b/features/project/commits/revert.feature @@ -5,12 +5,14 @@ Feature: Revert Commits And I own a project And I visit my project's commits page + @javascript Scenario: I revert a commit Given I click on commit link And I click on the revert button And I revert the changes directly Then I should see the revert commit notice + @javascript Scenario: I revert a commit that was previously reverted Given I click on commit link And I click on the revert button @@ -21,6 +23,7 @@ Feature: Revert Commits And I revert the changes directly Then I should see a revert error + @javascript Scenario: I revert a commit in a new merge request Given I click on commit link And I click on the revert button diff --git a/features/project/merge_requests.feature b/features/project/merge_requests.feature index bcde497553b..a8c528d3d6f 100644 --- a/features/project/merge_requests.feature +++ b/features/project/merge_requests.feature @@ -26,11 +26,13 @@ Feature: Project Merge Requests When I visit project "Shop" merge requests page Then I should see "feature_conflict" branch + @javascript Scenario: I should not see the numbers of diverged commits if the branch is rebased on the target Given project "Shop" have "Bug NS-07" open merge request with rebased branch When I visit merge request page "Bug NS-07" Then I should not see the diverged commits count + @javascript Scenario: I should see the numbers of diverged commits if the branch diverged from the target Given project "Shop" have "Bug NS-08" open merge request with diverged branch When I visit merge request page "Bug NS-08" @@ -46,21 +48,25 @@ Feature: Project Merge Requests Then I should see "Feature NS-03" in merge requests And I should see "Bug NS-04" in merge requests + @javascript Scenario: I visit an open merge request page Given I click link "Bug NS-04" Then I should see merge request "Bug NS-04" + @javascript Scenario: I visit a merged merge request page Given project "Shop" have "Feature NS-05" merged merge request And I click link "Merged" And I click link "Feature NS-05" Then I should see merge request "Feature NS-05" + @javascript Scenario: I close merge request page Given I click link "Bug NS-04" And I click link "Close" Then I should see closed merge request "Bug NS-04" + @javascript Scenario: I reopen merge request page Given I click link "Bug NS-04" And I click link "Close" @@ -176,6 +182,7 @@ Feature: Project Merge Requests # Markdown + @javascript Scenario: Headers inside the description should have ids generated for them. When I visit merge request page "Bug NS-04" Then Header "Description header" should have correct id and link diff --git a/features/project/merge_requests/accept.feature b/features/project/merge_requests/accept.feature index 330ec8ae0fe..c45ed9ea68b 100644 --- a/features/project/merge_requests/accept.feature +++ b/features/project/merge_requests/accept.feature @@ -7,7 +7,6 @@ Feature: Project Merge Requests Acceptance @javascript Scenario: Accepting the Merge Request and removing the source branch Given I am on the Merge Request detail page - When I click on "Remove source branch" option And I click on Accept Merge Request Then I should see merge request merged And I should not see the Remove Source Branch button @@ -15,7 +14,6 @@ Feature: Project Merge Requests Acceptance @javascript Scenario: Accepting the Merge Request when URL has an anchor Given I am on the Merge Request detail with note anchor page - When I click on "Remove source branch" option And I click on Accept Merge Request Then I should see merge request merged And I should not see the Remove Source Branch button @@ -23,6 +21,7 @@ Feature: Project Merge Requests Acceptance @javascript Scenario: Accepting the Merge Request without removing the source branch Given I am on the Merge Request detail page + When I click on "Remove source branch" option When I click on Accept Merge Request Then I should see merge request merged And I should see the Remove Source Branch button diff --git a/features/project/project.feature b/features/project/project.feature index aa22401c88e..23817ef3ac9 100644 --- a/features/project/project.feature +++ b/features/project/project.feature @@ -18,6 +18,7 @@ Feature: Project Then I should see the default project avatar And I should not see the "Remove avatar" button + @javascript Scenario: I should have readme on page And I visit project "Shop" page Then I should see project "Shop" README diff --git a/features/project/source/markdown_render.feature b/features/project/source/markdown_render.feature index fd583618dcf..fe4466ad241 100644 --- a/features/project/source/markdown_render.feature +++ b/features/project/source/markdown_render.feature @@ -19,12 +19,14 @@ Feature: Project Source Markdown Render And I click on Gitlab API in README Then I should see correct document rendered + @javascript Scenario: I view README in markdown branch Then I should see files from repository in markdown And I should see rendered README which contains correct links And I click on Rake tasks in README Then I should see correct directory rendered + @javascript Scenario: I view README in markdown branch to see reference links to directory Then I should see files from repository in markdown And I should see rendered README which contains correct links @@ -74,6 +76,7 @@ Feature: Project Source Markdown Render And I click on Gitlab API in README Then I should see correct document rendered for markdown branch + @javascript Scenario: I browse directory from markdown branch When I visit markdown branch Then I should see files from repository in markdown branch diff --git a/features/search.feature b/features/search.feature index 818ef436db6..f894b6b84a1 100644 --- a/features/search.feature +++ b/features/search.feature @@ -9,6 +9,7 @@ Feature: Search Given I search for "Sho" Then I should see "Shop" project link + @javascript Scenario: I should see issues I am looking for And project has issues When I search for "Foo" @@ -16,6 +17,7 @@ Feature: Search Then I should see "Foo" link in the search results And I should not see "Bar" link in the search results + @javascript Scenario: I should see merge requests I am looking for And project has merge requests When I search for "Foo" @@ -23,6 +25,7 @@ Feature: Search Then I should see "Foo" link in the search results And I should not see "Bar" link in the search results + @javascript Scenario: I should see milestones I am looking for And project has milestones When I search for "Foo" @@ -78,6 +81,7 @@ Feature: Search And I search for "Sho" Then I should see "Shop" project link + @javascript Scenario: I logout and should see issues I am looking for Given project "Shop" is public And I logout directly diff --git a/features/steps/dashboard/todos.rb b/features/steps/dashboard/todos.rb index b56558ba0d2..14c13c4818a 100644 --- a/features/steps/dashboard/todos.rb +++ b/features/steps/dashboard/todos.rb @@ -55,7 +55,7 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps merge_request_reference = merge_request.to_reference(full: true) issue_reference = issue.to_reference(full: true) - click_link 'Mark all as done' + find('.js-todos-mark-all').trigger('click') page.within('.todos-count') { expect(page).to have_content '0' } expect(page).to have_content 'To do 0' @@ -69,7 +69,7 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps end step 'I should see the todo marked as done' do - click_link 'Done 1' + find('.todos-done a').trigger('click') expect(page).to have_link project.name_with_namespace should_see_todo(1, "John Doe assigned you merge request #{merge_request.to_reference(full: true)}", merge_request.title, state: :done_irreversible) @@ -79,7 +79,7 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps merge_request_reference = merge_request.to_reference(full: true) issue_reference = issue.to_reference(full: true) - click_link 'Done 4' + find('.todos-done a').trigger('click') expect(page).to have_link project.name_with_namespace should_see_todo(1, "John Doe assigned you merge request #{merge_request_reference}", merge_request.title, state: :done_irreversible) diff --git a/features/steps/explore/projects.rb b/features/steps/explore/projects.rb index 7dc33ab5683..b2194275751 100644 --- a/features/steps/explore/projects.rb +++ b/features/steps/explore/projects.rb @@ -101,7 +101,7 @@ class Spinach::Features::ExploreProjects < Spinach::FeatureSteps create(:merge_request, title: "Bug fix for public project", source_project: public_project, - target_project: public_project, + target_project: public_project ) end diff --git a/features/steps/profile/active_tab.rb b/features/steps/profile/active_tab.rb index 4724a326277..069d4e6a23d 100644 --- a/features/steps/profile/active_tab.rb +++ b/features/steps/profile/active_tab.rb @@ -19,7 +19,7 @@ class Spinach::Features::ProfileActiveTab < Spinach::FeatureSteps ensure_active_main_tab('Preferences') end - step 'the active main tab should be Audit Log' do - ensure_active_main_tab('Audit Log') + step 'the active main tab should be Authentication log' do + ensure_active_main_tab('Authentication log') end end diff --git a/features/steps/project/builds/artifacts.rb b/features/steps/project/builds/artifacts.rb index eec375b0532..89132ff068f 100644 --- a/features/steps/project/builds/artifacts.rb +++ b/features/steps/project/builds/artifacts.rb @@ -25,7 +25,7 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps step 'I should see the build header' do page.within('.build-header') do - expect(page).to have_content "Job ##{@build.id} in pipeline ##{@pipeline.id} for commit #{@pipeline.short_sha}" + expect(page).to have_content "Job ##{@build.id} in pipeline ##{@pipeline.id} for #{@pipeline.short_sha}" end end diff --git a/features/steps/project/commits/revert.rb b/features/steps/project/commits/revert.rb index c9746407344..114de129d19 100644 --- a/features/steps/project/commits/revert.rb +++ b/features/steps/project/commits/revert.rb @@ -10,6 +10,7 @@ class Spinach::Features::RevertCommits < Spinach::FeatureSteps end step 'I click on the revert button' do + find(".header-action-buttons .dropdown").click find("a[href='#modal-revert-commit']").click end diff --git a/features/steps/project/forked_merge_requests.rb b/features/steps/project/forked_merge_requests.rb index 8081b764be6..29055373a57 100644 --- a/features/steps/project/forked_merge_requests.rb +++ b/features/steps/project/forked_merge_requests.rb @@ -4,6 +4,8 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps include SharedNote include SharedPaths include Select2Helper + include WaitForVueResource + include WaitForAjax step 'I am a member of project "Shop"' do @project = ::Project.find_by(name: "Shop") @@ -31,6 +33,8 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps expect(page).to have_content @project.path_with_namespace expect(page).to have_content @merge_request.source_branch expect(page).to have_content @merge_request.target_branch + + wait_for_vue_resource end step 'I fill out a "Merge Request On Forked Project" merge request' do @@ -44,6 +48,7 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps first('.dropdown-target-project a', text: @project.path_with_namespace) first('.js-source-branch').click + wait_for_ajax first('.dropdown-source-branch .dropdown-content a', text: 'fix').click click_button "Compare branches and continue" @@ -59,31 +64,6 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps click_button "Submit merge request" end - step 'I follow the target commit link' do - commit = @project.repository.commit - click_link commit.short_id(8) - end - - step 'I should see the commit under the forked from project' do - commit = @project.repository.commit - expect(page).to have_content(commit.message) - end - - step 'I click "Create Merge Request on fork" link' do - click_link "Create Merge Request on fork" - end - - step 'I see prefilled new Merge Request page for the forked project' do - expect(current_path).to eq new_namespace_project_merge_request_path(@forked_project.namespace, @forked_project) - expect(find("#merge_request_source_project_id").value).to eq @forked_project.id.to_s - expect(find("#merge_request_target_project_id").value).to eq @project.id.to_s - expect(find("#merge_request_source_branch").value).to have_content "new_design" - expect(find("#merge_request_target_branch").value).to have_content "master" - expect(find("#merge_request_title").value).to eq "New Design" - verify_commit_link(".mr_target_commit", @project) - verify_commit_link(".mr_source_commit", @forked_project) - end - step 'I update the merge request title' do fill_in "merge_request_title", with: "An Edited Forked Merge Request" end @@ -152,10 +132,4 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps expect(page).to have_content @project.users.first.name end end - - # Verify a link is generated against the correct project - def verify_commit_link(container_div, container_project) - # This should force a wait for the javascript to execute - expect(find(:div, container_div).find(".commit_short_id")['href']).to have_content "#{container_project.path_with_namespace}/commit" - end end diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index 4b7d6cd840b..8133760e619 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -8,6 +8,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps include SharedDiffNote include SharedUser include WaitForAjax + include WaitForVueResource after do wait_for_ajax if javascript_test? @@ -32,7 +33,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps end step 'I click link "Merged"' do - click_link "Merged" + find('#state-merged').trigger('click') end step 'I click link "Closed"' do @@ -45,19 +46,23 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps page.within '.merge-request' do expect(page).to have_content "Wiki Feature" end + wait_for_vue_resource end step 'I should see closed merge request "Bug NS-04"' do expect(page).to have_content "Bug NS-04" expect(page).to have_content "Closed by" + wait_for_vue_resource end step 'I should see merge request "Bug NS-04"' do expect(page).to have_content "Bug NS-04" + wait_for_vue_resource end step 'I should see merge request "Feature NS-05"' do expect(page).to have_content "Feature NS-05" + wait_for_vue_resource end step 'I should not see "master" branch' do @@ -326,7 +331,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I click on the Discussion tab' do page.within '.merge-request-tabs' do - click_link 'Discussion' + find('.notes-tab').trigger('click') end # Waits for load @@ -358,10 +363,12 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I should see a badge of "1" next to the discussion link' do expect_discussion_badge_to_have_counter("1") + wait_for_vue_resource end step 'I should see a badge of "0" next to the discussion link' do expect_discussion_badge_to_have_counter("0") + wait_for_vue_resource end step 'I should see a discussion has started on commit diff' do @@ -369,6 +376,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps page.should have_content "#{current_user.name} #{current_user.to_reference} started a discussion on commit" page.should have_content sample_commit.line_code_path page.should have_content "Line is wrong" + wait_for_vue_resource end end @@ -376,16 +384,17 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps page.within(".notes .discussion") do page.should have_content "#{current_user.name} #{current_user.to_reference} started a discussion on commit" page.should have_content "One comment to rule them all" + wait_for_vue_resource end end step 'merge request is mergeable' do - expect(page).to have_button 'Accept merge request' + expect(page).to have_button 'Merge' end step 'I modify merge commit message' do click_button "Modify commit message" - fill_in 'commit_message', with: 'wow such merge' + fill_in 'Commit message', with: 'wow such merge' end step 'merge request "Bug NS-05" is mergeable' do @@ -394,24 +403,26 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I accept this merge request' do page.within '.mr-state-widget' do - click_button "Accept merge request" + click_button "Merge" end end step 'I should see merged request' do page.within '.status-box' do expect(page).to have_content "Merged" + wait_for_vue_resource end end step 'I click link "Reopen"' do - first(:css, '.reopen-mr-link').click + first(:css, '.reopen-mr-link').trigger('click') end step 'I should see reopened merge request "Bug NS-04"' do page.within '.status-box' do expect(page).to have_content "Open" end + wait_for_vue_resource end step 'I click link "Hide inline discussion" of the third file' do @@ -435,6 +446,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I should see a comment like "Line is wrong" in the third file' do page.within '.files>div:nth-child(3) .note-body > .note-text' do expect(page).to have_visible_content "Line is wrong" + wait_for_vue_resource end end @@ -502,6 +514,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I should see comments on the side-by-side diff page' do page.within '.files>div:nth-child(2) .parallel .note-body > .note-text' do expect(page).to have_visible_content "Line is correct" + wait_for_vue_resource end end @@ -544,6 +557,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps project = merge_request.source_project project.enable_ci pipeline = create :ci_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch + merge_request.update(head_pipeline: pipeline) create :ci_build, pipeline: pipeline end @@ -557,12 +571,16 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps page.within ".mr-source-target" do expect(page).to have_content /([0-9]+ commits behind)/ end + + wait_for_vue_resource end step 'I should not see the diverged commits count' do page.within ".mr-source-target" do expect(page).not_to have_content /([0-9]+ commit[s]? behind)/ end + + wait_for_vue_resource end def merge_request diff --git a/features/steps/project/merge_requests/acceptance.rb b/features/steps/project/merge_requests/acceptance.rb index 7521a9439e3..3c976f675a2 100644 --- a/features/steps/project/merge_requests/acceptance.rb +++ b/features/steps/project/merge_requests/acceptance.rb @@ -1,7 +1,7 @@ class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps include LoginHelpers include GitlabRoutingHelper - include WaitForAjax + include WaitForVueResource step 'I am on the Merge Request detail page' do visit merge_request_path(@merge_request) @@ -12,27 +12,27 @@ class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps end step 'I click on "Remove source branch" option' do - check('Remove source branch') + uncheck('Remove source branch') end step 'I click on Accept Merge Request' do - click_button('Accept merge request') + click_button('Merge') end step 'I should see the Remove Source Branch button' do - expect(page).to have_link('Remove source branch') + expect(page).to have_selector('.js-remove-branch-button') - # Wait for AJAX requests to complete so they don't blow up if they are + # Wait for View Resource requests to complete so they don't blow up if they are # only handled after `DatabaseCleaner` has already run - wait_for_ajax + wait_for_vue_resource end step 'I should not see the Remove Source Branch button' do - expect(page).not_to have_link('Remove source branch') + expect(page).not_to have_selector('.js-remove-branch-button') - # Wait for AJAX requests to complete so they don't blow up if they are + # Wait for View Resource requests to complete so they don't blow up if they are # only handled after `DatabaseCleaner` has already run - wait_for_ajax + wait_for_vue_resource end step 'There is an open Merge Request' do diff --git a/features/steps/project/merge_requests/revert.rb b/features/steps/project/merge_requests/revert.rb index 1149c1c2426..aa76d6f8c48 100644 --- a/features/steps/project/merge_requests/revert.rb +++ b/features/steps/project/merge_requests/revert.rb @@ -1,6 +1,7 @@ class Spinach::Features::RevertMergeRequests < Spinach::FeatureSteps include LoginHelpers include GitlabRoutingHelper + include WaitForVueResource step 'I click on the revert button' do find("a[href='#modal-revert-commit']").click @@ -15,6 +16,7 @@ class Spinach::Features::RevertMergeRequests < Spinach::FeatureSteps step 'I should see the revert merge request notice' do page.should have_content('The merge request has been successfully reverted.') + wait_for_vue_resource end step 'I should not see the revert button' do @@ -26,7 +28,7 @@ class Spinach::Features::RevertMergeRequests < Spinach::FeatureSteps end step 'I click on Accept Merge Request' do - click_button('Accept merge request') + click_button('Merge') end step 'I am signed in as a developer of the project' do diff --git a/features/steps/project/project.rb b/features/steps/project/project.rb index 280d70925f7..9c2196a8ef7 100644 --- a/features/steps/project/project.rb +++ b/features/steps/project/project.rb @@ -2,6 +2,7 @@ class Spinach::Features::Project < Spinach::FeatureSteps include SharedAuthentication include SharedProject include SharedPaths + include WaitForAjax step 'change project settings' do fill_in 'project_name_edit', with: 'NewName' @@ -86,6 +87,7 @@ class Spinach::Features::Project < Spinach::FeatureSteps end step 'I should see project "Shop" README' do + wait_for_ajax page.within('.readme-holder') do expect(page).to have_content 'testme' end diff --git a/features/steps/project/source/markdown_render.rb b/features/steps/project/source/markdown_render.rb index abdbd795cd5..ada0ff20585 100644 --- a/features/steps/project/source/markdown_render.rb +++ b/features/steps/project/source/markdown_render.rb @@ -120,6 +120,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps When 'I visit markdown branch' do visit namespace_project_tree_path(@project.namespace, @project, "markdown") + wait_for_ajax end When 'I visit markdown branch "README.md" blob' do diff --git a/features/steps/search.rb b/features/steps/search.rb index f885baf8453..16c4a5ab2e4 100644 --- a/features/steps/search.rb +++ b/features/steps/search.rb @@ -10,12 +10,12 @@ class Spinach::Features::Search < Spinach::FeatureSteps step 'I search for "Foo"' do fill_in "dashboard_search", with: "Foo" - click_button "Search" + find('.btn-search').trigger('click') end step 'I search for "rspec"' do fill_in "dashboard_search", with: "rspec" - click_button "Search" + find('.btn-search').trigger('click') end step 'I search for "rspec" on project page' do @@ -25,7 +25,7 @@ class Spinach::Features::Search < Spinach::FeatureSteps step 'I search for "Wiki content"' do fill_in "dashboard_search", with: "content" - click_button "Search" + find('.btn-search').trigger('click') end step 'I click "Issues" link' do @@ -35,7 +35,7 @@ class Spinach::Features::Search < Spinach::FeatureSteps end step 'I click project "Shop" link' do - click_button 'Project' + find('.js-search-project-dropdown').trigger('click') page.within '.project-filter' do click_link project.name_with_namespace end diff --git a/features/steps/shared/paths.rb b/features/steps/shared/paths.rb index d5b3bb34d7a..bef3eac4d26 100644 --- a/features/steps/shared/paths.rb +++ b/features/steps/shared/paths.rb @@ -2,6 +2,7 @@ module SharedPaths include Spinach::DSL include RepoHelpers include DashboardHelper + include WaitForVueResource step 'I visit new project page' do visit new_project_path @@ -151,7 +152,7 @@ module SharedPaths visit profile_preferences_path end - step 'I visit Audit Log page' do + step 'I visit Authentication log page' do visit audit_log_profile_path end @@ -377,23 +378,28 @@ module SharedPaths step 'I visit merge request page "Bug NS-04"' do visit merge_request_path("Bug NS-04") + wait_for_vue_resource end step 'I visit merge request page "Bug NS-05"' do visit merge_request_path("Bug NS-05") + wait_for_vue_resource end step 'I visit merge request page "Bug NS-07"' do visit merge_request_path("Bug NS-07") + wait_for_vue_resource end step 'I visit merge request page "Bug NS-08"' do visit merge_request_path("Bug NS-08") + wait_for_vue_resource end step 'I visit merge request page "Bug CO-01"' do mr = MergeRequest.find_by(title: "Bug CO-01") visit namespace_project_merge_request_path(mr.target_project.namespace, mr.target_project, mr) + wait_for_vue_resource end step 'I visit project "Shop" merge requests page' do diff --git a/features/support/env.rb b/features/support/env.rb index 92d13bea4b6..23a1f702068 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -10,7 +10,7 @@ if ENV['CI'] Knapsack::Adapters::SpinachAdapter.bind end -%w(select2_helper test_env repo_helpers wait_for_ajax wait_for_requests sidekiq).each do |f| +%w(select2_helper test_env repo_helpers wait_for_ajax wait_for_requests sidekiq wait_for_vue_resource).each do |f| require Rails.root.join('spec', 'support', f) end @@ -30,8 +30,8 @@ Spinach.hooks.before_run do include FactoryGirl::Syntax::Methods end -Spinach.hooks.after_feature do |feature_data| - if feature_data.scenarios.flat_map(&:tags).include?('javascript') +Spinach.hooks.after_scenario do |scenario_data, step_definitions| + if scenario_data.tags.include?('javascript') include WaitForRequests wait_for_requests_complete end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index f8f5548d23d..3fc2b453eb6 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -5,7 +5,10 @@ module API end class UserBasic < UserSafe - expose :id, :state, :avatar_url + expose :id, :state + expose :avatar_url do |user, options| + user.avatar_url(only_path: false) + end expose :web_url do |user, options| Gitlab::Routing.url_helpers.user_url(user) @@ -50,7 +53,7 @@ module API end class Hook < Grape::Entity - expose :id, :url, :created_at, :push_events, :tag_push_events + expose :id, :url, :created_at, :push_events, :tag_push_events, :repository_update_events expose :enable_ssl_verification end @@ -97,7 +100,9 @@ module API expose :creator_id expose :namespace, using: 'API::Entities::Namespace' expose :forked_from_project, using: Entities::BasicProjectDetails, if: lambda{ |project, options| project.forked? } - expose :avatar_url + expose :avatar_url do |user, options| + user.avatar_url(only_path: false) + end expose :star_count, :forks_count expose :open_issues_count, if: lambda { |project, options| project.feature_available?(:issues, options[:current_user]) && project.default_issues_tracker? } expose :runners_token, if: lambda { |_project, options| options[:user_can_admin_project] } @@ -141,7 +146,9 @@ module API class Group < Grape::Entity expose :id, :name, :path, :description, :visibility expose :lfs_enabled?, as: :lfs_enabled - expose :avatar_url + expose :avatar_url do |user, options| + user.avatar_url(only_path: false) + end expose :web_url expose :request_access_enabled expose :full_name, :full_path diff --git a/lib/api/groups.rb b/lib/api/groups.rb index 09d105f6b4c..3da7d735da8 100644 --- a/lib/api/groups.rb +++ b/lib/api/groups.rb @@ -24,7 +24,7 @@ module API def present_groups(groups, options = {}) options = options.reverse_merge( with: Entities::Group, - current_user: current_user, + current_user: current_user ) groups = groups.with_statistics if options[:statistics] @@ -52,7 +52,7 @@ module API elsif current_user.admin Group.all elsif params[:all_available] - GroupsFinder.new.execute(current_user) + GroupsFinder.new(current_user).execute else current_user.groups end diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 86bf567fe69..226a7ddd50e 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -91,8 +91,8 @@ module API end def find_project_snippet(id) - finder_params = { filter: :by_project, project: user_project } - SnippetsFinder.new.execute(current_user, finder_params).find(id) + finder_params = { project: user_project } + SnippetsFinder.new(current_user, finder_params).execute.find(id) end def find_merge_request_with_access(iid, access_level = :read_merge_request) @@ -301,7 +301,7 @@ module API UploadedFile.new( file_path, params["#{field}.name"], - params["#{field}.type"] || 'application/octet-stream', + params["#{field}.type"] || 'application/octet-stream' ) end diff --git a/lib/api/helpers/common_helpers.rb b/lib/api/helpers/common_helpers.rb index 6236fdd43ca..322624c6092 100644 --- a/lib/api/helpers/common_helpers.rb +++ b/lib/api/helpers/common_helpers.rb @@ -2,11 +2,11 @@ module API module Helpers module CommonHelpers def convert_parameters_from_legacy_format(params) - if params[:assignee_id].present? - params[:assignee_ids] = [params.delete(:assignee_id)] + params.tap do |params| + if params[:assignee_id].present? + params[:assignee_ids] = [params.delete(:assignee_id)] + end end - - params end end end diff --git a/lib/api/internal.rb b/lib/api/internal.rb index 2a11790b215..96aaaf868ea 100644 --- a/lib/api/internal.rb +++ b/lib/api/internal.rb @@ -90,7 +90,7 @@ module API { api_version: API.version, gitlab_version: Gitlab::VERSION, - gitlab_rev: Gitlab::REVISION, + gitlab_rev: Gitlab::REVISION } end diff --git a/lib/api/project_snippets.rb b/lib/api/project_snippets.rb index cfee38a9baf..98bc9c28527 100644 --- a/lib/api/project_snippets.rb +++ b/lib/api/project_snippets.rb @@ -17,8 +17,7 @@ module API end def snippets_for_current_user - finder_params = { filter: :by_project, project: user_project } - SnippetsFinder.new.execute(current_user, finder_params) + SnippetsFinder.new(current_user, project: user_project).execute end end diff --git a/lib/api/projects.rb b/lib/api/projects.rb index 9a6cb43abf7..ed5004e8d1a 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -69,7 +69,7 @@ module API options = options.reverse_merge( with: Entities::Project, current_user: current_user, - simple: params[:simple], + simple: params[:simple] ) projects = filter_projects(projects) @@ -226,7 +226,7 @@ module API :shared_runners_enabled, :snippets_enabled, :visibility, - :wiki_enabled, + :wiki_enabled ] optional :name, type: String, desc: 'The name of the project' optional :default_branch, type: String, desc: 'The default branch of the project' diff --git a/lib/api/services.rb b/lib/api/services.rb index 23ef62c2258..cb07df9e249 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -356,7 +356,7 @@ module API name: :ca_pem, type: String, desc: 'A custom certificate authority bundle to verify the Kubernetes cluster with (PEM format)' - }, + } ], 'mattermost-slash-commands' => [ { @@ -559,7 +559,7 @@ module API SlackService, MattermostService, MicrosoftTeamsService, - TeamcityService, + TeamcityService ] if Rails.env.development? @@ -577,7 +577,7 @@ module API service_classes += [ MockCiService, MockDeploymentService, - MockMonitoringService, + MockMonitoringService ] end diff --git a/lib/api/snippets.rb b/lib/api/snippets.rb index b93fdc62808..53f5953a8fb 100644 --- a/lib/api/snippets.rb +++ b/lib/api/snippets.rb @@ -8,11 +8,11 @@ module API resource :snippets do helpers do def snippets_for_current_user - SnippetsFinder.new.execute(current_user, filter: :by_user, user: current_user) + SnippetsFinder.new(current_user, author: current_user).execute end def public_snippets - SnippetsFinder.new.execute(current_user, filter: :public) + SnippetsFinder.new(current_user, visibility: Snippet::PUBLIC).execute end end diff --git a/lib/api/subscriptions.rb b/lib/api/subscriptions.rb index dbe54d3cd31..91567909998 100644 --- a/lib/api/subscriptions.rb +++ b/lib/api/subscriptions.rb @@ -5,7 +5,7 @@ module API subscribable_types = { 'merge_requests' => proc { |id| find_merge_request_with_access(id, :update_merge_request) }, 'issues' => proc { |id| find_project_issue(id) }, - 'labels' => proc { |id| find_project_label(id) }, + 'labels' => proc { |id| find_project_label(id) } } params do diff --git a/lib/api/users.rb b/lib/api/users.rb index 40acaebf670..3d83720b7b9 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -56,16 +56,7 @@ module API authenticated_as_admin! if params[:external].present? || (params[:extern_uid].present? && params[:provider].present?) - users = User.all - users = User.where(username: params[:username]) if params[:username] - users = users.active if params[:active] - users = users.search(params[:search]) if params[:search].present? - users = users.blocked if params[:blocked] - - if current_user.admin? - users = users.joins(:identities).merge(Identity.with_extern_uid(params[:provider], params[:extern_uid])) if params[:extern_uid] && params[:provider] - users = users.external if params[:external] - end + users = UsersFinder.new(current_user, params).execute entity = current_user.admin? ? Entities::UserPublic : Entities::UserBasic present paginate(users), with: entity diff --git a/lib/api/v3/entities.rb b/lib/api/v3/entities.rb index 7c8be7e51db..56a9b019f1b 100644 --- a/lib/api/v3/entities.rb +++ b/lib/api/v3/entities.rb @@ -69,7 +69,9 @@ module API expose :creator_id expose :namespace, using: 'API::Entities::Namespace' expose :forked_from_project, using: ::API::Entities::BasicProjectDetails, if: lambda{ |project, options| project.forked? } - expose :avatar_url + expose :avatar_url do |user, options| + user.avatar_url(only_path: false) + end expose :star_count, :forks_count expose :open_issues_count, if: lambda { |project, options| project.feature_available?(:issues, options[:current_user]) && project.default_issues_tracker? } expose :runners_token, if: lambda { |_project, options| options[:user_can_admin_project] } @@ -129,7 +131,9 @@ module API class Group < Grape::Entity expose :id, :name, :path, :description, :visibility_level expose :lfs_enabled?, as: :lfs_enabled - expose :avatar_url + expose :avatar_url do |user, options| + user.avatar_url(only_path: false) + end expose :web_url expose :request_access_enabled expose :full_name, :full_path diff --git a/lib/api/v3/groups.rb b/lib/api/v3/groups.rb index 63d464b926b..6187445fc8d 100644 --- a/lib/api/v3/groups.rb +++ b/lib/api/v3/groups.rb @@ -20,7 +20,7 @@ module API def present_groups(groups, options = {}) options = options.reverse_merge( with: Entities::Group, - current_user: current_user, + current_user: current_user ) groups = groups.with_statistics if options[:statistics] @@ -45,7 +45,7 @@ module API groups = if current_user.admin Group.all elsif params[:all_available] - GroupsFinder.new.execute(current_user) + GroupsFinder.new(current_user).execute else current_user.groups end diff --git a/lib/api/v3/project_snippets.rb b/lib/api/v3/project_snippets.rb index fc065a22d74..c41fee32610 100644 --- a/lib/api/v3/project_snippets.rb +++ b/lib/api/v3/project_snippets.rb @@ -18,8 +18,7 @@ module API end def snippets_for_current_user - finder_params = { filter: :by_project, project: user_project } - SnippetsFinder.new.execute(current_user, finder_params) + SnippetsFinder.new(current_user, project: user_project).execute end end diff --git a/lib/api/v3/projects.rb b/lib/api/v3/projects.rb index 06cc704afc6..164612cb8dd 100644 --- a/lib/api/v3/projects.rb +++ b/lib/api/v3/projects.rb @@ -88,7 +88,7 @@ module API options = options.reverse_merge( with: ::API::V3::Entities::Project, current_user: current_user, - simple: params[:simple], + simple: params[:simple] ) projects = filter_projects(projects) diff --git a/lib/api/v3/services.rb b/lib/api/v3/services.rb index 61629a04174..118c6df6549 100644 --- a/lib/api/v3/services.rb +++ b/lib/api/v3/services.rb @@ -377,7 +377,7 @@ module API name: :ca_pem, type: String, desc: 'A custom certificate authority bundle to verify the Kubernetes cluster with (PEM format)' - }, + } ], 'mattermost-slash-commands' => [ { diff --git a/lib/api/v3/snippets.rb b/lib/api/v3/snippets.rb index 07dac7e9904..0762fc02d70 100644 --- a/lib/api/v3/snippets.rb +++ b/lib/api/v3/snippets.rb @@ -8,11 +8,11 @@ module API resource :snippets do helpers do def snippets_for_current_user - SnippetsFinder.new.execute(current_user, filter: :by_user, user: current_user) + SnippetsFinder.new(current_user, author: current_user).execute end def public_snippets - SnippetsFinder.new.execute(current_user, filter: :public) + SnippetsFinder.new(current_user, visibility: Snippet::PUBLIC).execute end end diff --git a/lib/api/v3/subscriptions.rb b/lib/api/v3/subscriptions.rb index 068750ec077..690768db82f 100644 --- a/lib/api/v3/subscriptions.rb +++ b/lib/api/v3/subscriptions.rb @@ -7,7 +7,7 @@ module API 'merge_request' => proc { |id| find_merge_request_with_access(id, :update_merge_request) }, 'merge_requests' => proc { |id| find_merge_request_with_access(id, :update_merge_request) }, 'issues' => proc { |id| find_project_issue(id) }, - 'labels' => proc { |id| find_project_label(id) }, + 'labels' => proc { |id| find_project_label(id) } } params do diff --git a/lib/banzai/filter/external_link_filter.rb b/lib/banzai/filter/external_link_filter.rb index d67d466bce8..7d15a0f6d44 100644 --- a/lib/banzai/filter/external_link_filter.rb +++ b/lib/banzai/filter/external_link_filter.rb @@ -2,16 +2,17 @@ module Banzai module Filter # HTML Filter to modify the attributes of external links class ExternalLinkFilter < HTML::Pipeline::Filter + SCHEMES = ['http', 'https', nil].freeze + def call links.each do |node| - href = href_to_lowercase_scheme(node["href"].to_s) + uri = uri(node['href'].to_s) + next unless uri - unless node["href"].to_s == href - node.set_attribute('href', href) - end + node.set_attribute('href', uri.to_s) - if href =~ %r{\A(https?:)?//[^/]} && external_url?(href) - node.set_attribute('rel', 'nofollow noreferrer') + if SCHEMES.include?(uri.scheme) && external_url?(uri) + node.set_attribute('rel', 'nofollow noreferrer noopener') node.set_attribute('target', '_blank') end end @@ -21,27 +22,26 @@ module Banzai private + def uri(href) + URI.parse(href) + rescue URI::InvalidURIError + nil + end + def links query = 'descendant-or-self::a[@href and not(@href = "")]' doc.xpath(query) end - def href_to_lowercase_scheme(href) - scheme_match = href.match(/\A(\w+):\/\//) - - if scheme_match - scheme_match.to_s.downcase + scheme_match.post_match - else - href - end - end + def external_url?(uri) + # Relative URLs miss a hostname + return false unless uri.hostname - def external_url?(url) - !url.start_with?(internal_url) + uri.hostname != internal_url.hostname end def internal_url - @internal_url ||= Gitlab.config.gitlab.url + @internal_url ||= URI.parse(Gitlab.config.gitlab.url) end end end diff --git a/lib/banzai/pipeline/markup_pipeline.rb b/lib/banzai/pipeline/markup_pipeline.rb new file mode 100644 index 00000000000..c56d908009f --- /dev/null +++ b/lib/banzai/pipeline/markup_pipeline.rb @@ -0,0 +1,13 @@ +module Banzai + module Pipeline + class MarkupPipeline < BasePipeline + def self.filters + @filters ||= FilterArray[ + Filter::SanitizationFilter, + Filter::ExternalLinkFilter, + Filter::PlantumlFilter + ] + end + end + end +end diff --git a/lib/ci/ansi2html.rb b/lib/ci/ansi2html.rb index b439b0ee29b..55402101e43 100644 --- a/lib/ci/ansi2html.rb +++ b/lib/ci/ansi2html.rb @@ -20,7 +20,7 @@ module Ci italic: 0x02, underline: 0x04, conceal: 0x08, - cross: 0x10, + cross: 0x10 }.freeze def self.convert(ansi, state = nil) diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 15a461a16dd..b06474cda7f 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -70,7 +70,7 @@ module Ci cache: job[:cache], dependencies: job[:dependencies], after_script: job[:after_script], - environment: job[:environment], + environment: job[:environment] }.compact } end diff --git a/lib/github/import.rb b/lib/github/import.rb index 06beb607a3e..9c7eb965f93 100644 --- a/lib/github/import.rb +++ b/lib/github/import.rb @@ -1,4 +1,5 @@ require_relative 'error' + module Github class Import include Gitlab::ShellAdapter @@ -6,6 +7,7 @@ module Github class MergeRequest < ::MergeRequest self.table_name = 'merge_requests' + self.reset_callbacks :create self.reset_callbacks :save self.reset_callbacks :commit self.reset_callbacks :update @@ -16,6 +18,7 @@ module Github self.table_name = 'issues' self.reset_callbacks :save + self.reset_callbacks :create self.reset_callbacks :commit self.reset_callbacks :update self.reset_callbacks :validate @@ -79,7 +82,7 @@ module Github def fetch_repository begin project.create_repository unless project.repository.exists? - project.repository.add_remote('github', "https://{options.fetch(:token)}@github.com/#{repo}.git") + project.repository.add_remote('github', "https://#{options.fetch(:token)}@github.com/#{repo}.git") project.repository.set_remote_as_mirror('github') project.repository.fetch_remote('github', forced: true) rescue Gitlab::Shell::Error => e diff --git a/lib/gitlab/access.rb b/lib/gitlab/access.rb index 8c28009b9c6..4714ab18cc1 100644 --- a/lib/gitlab/access.rb +++ b/lib/gitlab/access.rb @@ -32,7 +32,7 @@ module Gitlab "Guest" => GUEST, "Reporter" => REPORTER, "Developer" => DEVELOPER, - "Master" => MASTER, + "Master" => MASTER } end @@ -47,7 +47,7 @@ module Gitlab guest: GUEST, reporter: REPORTER, developer: DEVELOPER, - master: MASTER, + master: MASTER } end @@ -60,7 +60,7 @@ module Gitlab "Not protected: Both developers and masters can push new commits, force push, or delete the branch." => PROTECTION_NONE, "Protected against pushes: Developers cannot push new commits, but are allowed to accept merge requests to the branch." => PROTECTION_DEV_CAN_MERGE, "Partially protected: Developers can push new commits, but cannot force push or delete the branch. Masters can do all of those." => PROTECTION_DEV_CAN_PUSH, - "Fully protected: Developers cannot push new commits, force push, or delete the branch. Only masters can do any of those." => PROTECTION_FULL, + "Fully protected: Developers cannot push new commits, force push, or delete the branch. Only masters can do any of those." => PROTECTION_FULL } end diff --git a/lib/gitlab/asciidoc.rb b/lib/gitlab/asciidoc.rb index fba80c7132e..96d38f6daa0 100644 --- a/lib/gitlab/asciidoc.rb +++ b/lib/gitlab/asciidoc.rb @@ -15,17 +15,17 @@ module Gitlab # # input - the source text in Asciidoc format # - def self.render(input) + def self.render(input, context) asciidoc_opts = { safe: :secure, backend: :gitlab_html5, attributes: DEFAULT_ADOC_ATTRS } + context[:pipeline] = :markup + plantuml_setup html = ::Asciidoctor.convert(input, asciidoc_opts) - - filter = Banzai::Filter::SanitizationFilter.new(html) - html = filter.call.to_s + html = Banzai.render(html, context) html.html_safe end diff --git a/lib/gitlab/chat_commands/command.rb b/lib/gitlab/chat_commands/command.rb index f34ed0f4cf2..3e0c30c33b7 100644 --- a/lib/gitlab/chat_commands/command.rb +++ b/lib/gitlab/chat_commands/command.rb @@ -5,7 +5,7 @@ module Gitlab Gitlab::ChatCommands::IssueShow, Gitlab::ChatCommands::IssueNew, Gitlab::ChatCommands::IssueSearch, - Gitlab::ChatCommands::Deploy, + Gitlab::ChatCommands::Deploy ].freeze def execute diff --git a/lib/gitlab/ci/config/entry/legacy_validation_helpers.rb b/lib/gitlab/ci/config/entry/legacy_validation_helpers.rb index 9b9a0a8125a..a78a85397bd 100644 --- a/lib/gitlab/ci/config/entry/legacy_validation_helpers.rb +++ b/lib/gitlab/ci/config/entry/legacy_validation_helpers.rb @@ -21,7 +21,13 @@ module Gitlab def validate_variables(variables) variables.is_a?(Hash) && - variables.all? { |key, value| validate_string(key) && validate_string(value) } + variables.flatten.all? do |value| + validate_string(value) || validate_integer(value) + end + end + + def validate_integer(value) + value.is_a?(Integer) end def validate_string(value) diff --git a/lib/gitlab/ci/config/entry/variables.rb b/lib/gitlab/ci/config/entry/variables.rb index c3b0e651c3a..8acab605c91 100644 --- a/lib/gitlab/ci/config/entry/variables.rb +++ b/lib/gitlab/ci/config/entry/variables.rb @@ -15,6 +15,10 @@ module Gitlab def self.default {} end + + def value + Hash[@config.map { |key, value| [key.to_s, value.to_s] }] + end end end end diff --git a/lib/gitlab/ci/cron_parser.rb b/lib/gitlab/ci/cron_parser.rb index dad8c3cdf5b..551483d0aaa 100644 --- a/lib/gitlab/ci/cron_parser.rb +++ b/lib/gitlab/ci/cron_parser.rb @@ -11,7 +11,7 @@ module Gitlab def next_time_from(time) @cron_line ||= try_parse_cron(@cron, @cron_timezone) - @cron_line.next_time(time).in_time_zone(Time.zone) if @cron_line.present? + @cron_line.next_time(time).utc.in_time_zone(Time.zone) if @cron_line.present? end def cron_valid? diff --git a/lib/gitlab/conflict/file_collection.rb b/lib/gitlab/conflict/file_collection.rb index 990b719ecfd..6e73361cad1 100644 --- a/lib/gitlab/conflict/file_collection.rb +++ b/lib/gitlab/conflict/file_collection.rb @@ -3,16 +3,33 @@ module Gitlab class FileCollection ConflictSideMissing = Class.new(StandardError) - attr_reader :merge_request, :our_commit, :their_commit + attr_reader :merge_request, :our_commit, :their_commit, :project - def initialize(merge_request) - @merge_request = merge_request - @our_commit = merge_request.source_branch_head.raw.raw_commit - @their_commit = merge_request.target_branch_head.raw.raw_commit - end + delegate :repository, to: :project + + class << self + # We can only write when getting the merge index from the source + # project, because we will write to that project. We don't use this all + # the time because this fetches a ref into the source project, which + # isn't needed for reading. + def for_resolution(merge_request) + project = merge_request.source_project + + new(merge_request, project).tap do |file_collection| + project. + repository. + with_repo_branch_commit(merge_request.target_project.repository, merge_request.target_branch) do + + yield file_collection + end + end + end - def repository - merge_request.project.repository + # We don't need to do `with_repo_branch_commit` here, because the target + # project always fetches source refs when creating merge request diffs. + def read_only(merge_request) + new(merge_request, merge_request.target_project) + end end def merge_index @@ -55,6 +72,15 @@ Merge branch '#{merge_request.target_branch}' into '#{merge_request.source_branc #{conflict_filenames.join("\n")} EOM end + + private + + def initialize(merge_request, project) + @merge_request = merge_request + @our_commit = merge_request.source_branch_head.raw.raw_commit + @their_commit = merge_request.target_branch_head.raw.raw_commit + @project = project + end end end end diff --git a/lib/gitlab/cycle_analytics/permissions.rb b/lib/gitlab/cycle_analytics/permissions.rb index bef3b95ff1b..1e11e84a9cb 100644 --- a/lib/gitlab/cycle_analytics/permissions.rb +++ b/lib/gitlab/cycle_analytics/permissions.rb @@ -7,7 +7,7 @@ module Gitlab test: :read_build, review: :read_merge_request, staging: :read_build, - production: :read_issue, + production: :read_issue }.freeze def self.get(*args) diff --git a/lib/gitlab/data_builder/build.rb b/lib/gitlab/data_builder/build.rb index f78106f5b10..8e74e18a311 100644 --- a/lib/gitlab/data_builder/build.rb +++ b/lib/gitlab/data_builder/build.rb @@ -36,7 +36,7 @@ module Gitlab user: { id: user.try(:id), name: user.try(:name), - email: user.try(:email), + email: user.try(:email) }, commit: { @@ -49,7 +49,7 @@ module Gitlab status: commit.status, duration: commit.duration, started_at: commit.started_at, - finished_at: commit.finished_at, + finished_at: commit.finished_at }, repository: { @@ -60,7 +60,7 @@ module Gitlab git_http_url: project.http_url_to_repo, git_ssh_url: project.ssh_url_to_repo, visibility_level: project.visibility_level - }, + } } data diff --git a/lib/gitlab/data_builder/push.rb b/lib/gitlab/data_builder/push.rb index 1ff34553f0a..e81d19a7a2e 100644 --- a/lib/gitlab/data_builder/push.rb +++ b/lib/gitlab/data_builder/push.rb @@ -11,6 +11,7 @@ module Gitlab # ref: String, # user_id: String, # user_name: String, + # user_username: String, # user_email: String # project_id: String, # repository: { @@ -51,6 +52,7 @@ module Gitlab message: message, user_id: user.id, user_name: user.name, + user_username: user.username, user_email: user.email, user_avatar: user.avatar_url, project_id: project.id, diff --git a/lib/gitlab/data_builder/repository.rb b/lib/gitlab/data_builder/repository.rb new file mode 100644 index 00000000000..b42dc052949 --- /dev/null +++ b/lib/gitlab/data_builder/repository.rb @@ -0,0 +1,35 @@ +module Gitlab + module DataBuilder + module Repository + extend self + + # Produce a hash of post-receive data + def update(project, user, changes, refs) + { + event_name: 'repository_update', + + user_id: user.id, + user_name: user.name, + user_email: user.email, + user_avatar: user.avatar_url, + + project_id: project.id, + project: project.hook_attrs, + + changes: changes, + + refs: refs + } + end + + # Produce a hash of partial data for a single change + def single_change(oldrev, newrev, ref) + { + before: oldrev, + after: newrev, + ref: ref + } + end + end + end +end diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index 298b1a1f4e6..e76c9abbe04 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -278,6 +278,19 @@ module Gitlab raise 'rename_column_concurrently can not be run inside a transaction' end + old_col = column_for(table, old) + new_type = type || old_col.type + + add_column(table, new, new_type, + limit: old_col.limit, + precision: old_col.precision, + scale: old_col.scale) + + # We set the default value _after_ adding the column so we don't end up + # updating any existing data with the default value. This isn't + # necessary since we copy over old values further down. + change_column_default(table, new, old_col.default) if old_col.default + trigger_name = rename_trigger_name(table, old, new) quoted_table = quote_table_name(table) quoted_old = quote_column_name(old) @@ -291,18 +304,10 @@ module Gitlab quoted_old, quoted_new) end - old_col = column_for(table, old) - new_type = type || old_col.type - - add_column(table, new, new_type, - limit: old_col.limit, - default: old_col.default, - null: old_col.null, - precision: old_col.precision, - scale: old_col.scale) - update_column_in_batches(table, new, Arel::Table.new(table)[old]) + change_column_null(table, new, false) unless old_col.null + copy_indexes(table, old, new) copy_foreign_keys(table, old, new) end diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base.rb index de4e6e7c404..5397877b5d5 100644 --- a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base.rb +++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base.rb @@ -15,7 +15,7 @@ module Gitlab end def path_patterns - @path_patterns ||= paths.map { |path| "%#{path}" } + @path_patterns ||= paths.flat_map { |path| ["%/#{path}", path] } end def rename_path_for_routable(routable) diff --git a/lib/gitlab/dependency_linker.rb b/lib/gitlab/dependency_linker.rb new file mode 100644 index 00000000000..c45ae8feb2c --- /dev/null +++ b/lib/gitlab/dependency_linker.rb @@ -0,0 +1,18 @@ +module Gitlab + module DependencyLinker + LINKERS = [ + GemfileLinker + ].freeze + + def self.linker(blob_name) + LINKERS.find { |linker| linker.support?(blob_name) } + end + + def self.link(blob_name, plain_text, highlighted_text) + linker = linker(blob_name) + return highlighted_text unless linker + + linker.link(plain_text, highlighted_text) + end + end +end diff --git a/lib/gitlab/dependency_linker/base_linker.rb b/lib/gitlab/dependency_linker/base_linker.rb new file mode 100644 index 00000000000..5f4027e7e81 --- /dev/null +++ b/lib/gitlab/dependency_linker/base_linker.rb @@ -0,0 +1,109 @@ +module Gitlab + module DependencyLinker + class BaseLinker + class_attribute :file_type + + def self.support?(blob_name) + Gitlab::FileDetector.type_of(blob_name) == file_type + end + + def self.link(*args) + new(*args).link + end + + attr_accessor :plain_text, :highlighted_text + + def initialize(plain_text, highlighted_text) + @plain_text = plain_text + @highlighted_text = highlighted_text + end + + def link + link_dependencies + + highlighted_lines.join.html_safe + end + + private + + def package_url(name) + raise NotImplementedError + end + + def link_dependencies + raise NotImplementedError + end + + def package_link(name, url = package_url(name)) + return name unless url + + %{<a href="#{ERB::Util.html_escape_once(url)}" rel="noopener noreferrer" target="_blank">#{ERB::Util.html_escape_once(name)}</a>} + end + + # Links package names in a method call or assignment string argument. + # + # Example: + # link_method_call("gem") + # # Will link `package` in `gem "package"`, `gem("package")` and `gem = "package"` + # + # link_method_call("gem", "specific_package") + # # Will link `specific_package` in `gem "specific_package"` + # + # link_method_call("github", /[^\/]+\/[^\/]+/) + # # Will link `user/repo` in `github "user/repo"`, but not `github "package"` + # + # link_method_call(%w[add_dependency add_development_dependency]) + # # Will link `spec.add_dependency "package"` and `spec.add_development_dependency "package"` + # + # link_method_call("name") + # # Will link `package` in `self.name = "package"` + def link_method_call(method_names, value = nil, &url_proc) + value = + case value + when String + Regexp.escape(value) + when nil + /[^'"]+/ + else + value + end + + method_names = Array(method_names).map { |name| Regexp.escape(name) } + + regex = %r{ + #{Regexp.union(method_names)} # Method name + \s* # Whitespace + [(=]? # Opening brace or equals sign + \s* # Whitespace + ['"](?<name>#{value})['"] # Package name in quotes + }x + + link_regex(regex, &url_proc) + end + + # Links package names based on regex. + # + # Example: + # link_regex(/(github:|:github =>)\s*['"](?<name>[^'"]+)['"]/) + # # Will link `user/repo` in `github: "user/repo"` or `:github => "user/repo"` + def link_regex(regex) + highlighted_lines.map!.with_index do |rich_line, i| + marker = StringRegexMarker.new(plain_lines[i], rich_line.html_safe) + + marker.mark(regex, group: :name) do |text, left:, right:| + url = block_given? ? yield(text) : package_url(text) + package_link(text, url) + end + end + end + + def plain_lines + @plain_lines ||= plain_text.lines + end + + def highlighted_lines + @highlighted_lines ||= highlighted_text.lines + end + end + end +end diff --git a/lib/gitlab/dependency_linker/gemfile_linker.rb b/lib/gitlab/dependency_linker/gemfile_linker.rb new file mode 100644 index 00000000000..9b82e126528 --- /dev/null +++ b/lib/gitlab/dependency_linker/gemfile_linker.rb @@ -0,0 +1,29 @@ +module Gitlab + module DependencyLinker + class GemfileLinker < BaseLinker + self.file_type = :gemfile + + private + + def link_dependencies + # Link `gem "package_name"` to https://rubygems.org/gems/package_name + link_method_call("gem") + + # Link `github: "user/repo"` to https://github.com/user/repo + link_regex(/(github:|:github\s*=>)\s*['"](?<name>[^'"]+)['"]/) do |name| + "https://github.com/#{name}" + end + + # Link `git: "https://gitlab.example.com/user/repo"` to https://gitlab.example.com/user/repo + link_regex(%r{(git:|:git\s*=>)\s*['"](?<name>https?://[^'"]+)['"]}) { |url| url } + + # Link `source "https://rubygems.org"` to https://rubygems.org + link_method_call("source", %r{https?://[^'"]+}) { |url| url } + end + + def package_url(name) + "https://rubygems.org/gems/#{name}" + end + end + end +end diff --git a/lib/gitlab/diff/inline_diff_markdown_marker.rb b/lib/gitlab/diff/inline_diff_markdown_marker.rb new file mode 100644 index 00000000000..c2a2eb15931 --- /dev/null +++ b/lib/gitlab/diff/inline_diff_markdown_marker.rb @@ -0,0 +1,17 @@ +module Gitlab + module Diff + class InlineDiffMarkdownMarker < Gitlab::StringRangeMarker + MARKDOWN_SYMBOLS = { + addition: "+", + deletion: "-" + }.freeze + + def mark(line_inline_diffs, mode: nil) + super(line_inline_diffs) do |text, left:, right:| + symbol = MARKDOWN_SYMBOLS[mode] + "{#{symbol}#{text}#{symbol}}" + end + end + end + end +end diff --git a/lib/gitlab/diff/inline_diff_marker.rb b/lib/gitlab/diff/inline_diff_marker.rb index 736933b1c4b..919965100ae 100644 --- a/lib/gitlab/diff/inline_diff_marker.rb +++ b/lib/gitlab/diff/inline_diff_marker.rb @@ -1,137 +1,21 @@ module Gitlab module Diff - class InlineDiffMarker - MARKDOWN_SYMBOLS = { - addition: "+", - deletion: "-" - }.freeze - - attr_accessor :raw_line, :rich_line - - def initialize(raw_line, rich_line = raw_line) - @raw_line = raw_line - @rich_line = ERB::Util.html_escape(rich_line) - end - - def mark(line_inline_diffs, mode: nil, markdown: false) - return rich_line unless line_inline_diffs - - marker_ranges = [] - line_inline_diffs.each do |inline_diff_range| - # Map the inline-diff range based on the raw line to character positions in the rich line - inline_diff_positions = position_mapping[inline_diff_range].flatten - # Turn the array of character positions into ranges - marker_ranges.concat(collapse_ranges(inline_diff_positions)) - end - - offset = 0 - - # Mark each range - marker_ranges.each_with_index do |range, index| - before_content = - if markdown - "{#{MARKDOWN_SYMBOLS[mode]}" - else - "<span class='#{html_class_names(marker_ranges, mode, index)}'>" - end - after_content = - if markdown - "#{MARKDOWN_SYMBOLS[mode]}}" - else - "</span>" - end - offset = insert_around_range(rich_line, range, before_content, after_content, offset) + class InlineDiffMarker < Gitlab::StringRangeMarker + def mark(line_inline_diffs, mode: nil) + super(line_inline_diffs) do |text, left:, right:| + %{<span class="#{html_class_names(left, right, mode)}">#{text}</span>} end - - rich_line.html_safe end private - def html_class_names(marker_ranges, mode, index) + def html_class_names(left, right, mode) class_names = ["idiff"] - class_names << "left" if index == 0 - class_names << "right" if index == marker_ranges.length - 1 + class_names << "left" if left + class_names << "right" if right class_names << mode if mode class_names.join(" ") end - - # Mapping of character positions in the raw line, to the rich (highlighted) line - def position_mapping - @position_mapping ||= begin - mapping = [] - rich_pos = 0 - (0..raw_line.length).each do |raw_pos| - rich_char = rich_line[rich_pos] - - # The raw and rich lines are the same except for HTML tags, - # so skip over any `<...>` segment - while rich_char == '<' - until rich_char == '>' - rich_pos += 1 - rich_char = rich_line[rich_pos] - end - - rich_pos += 1 - rich_char = rich_line[rich_pos] - end - - # multi-char HTML entities in the rich line correspond to a single character in the raw line - if rich_char == '&' - multichar_mapping = [rich_pos] - until rich_char == ';' - rich_pos += 1 - multichar_mapping << rich_pos - rich_char = rich_line[rich_pos] - end - - mapping[raw_pos] = multichar_mapping - else - mapping[raw_pos] = rich_pos - end - - rich_pos += 1 - end - - mapping - end - end - - # Takes an array of integers, and returns an array of ranges covering the same integers - def collapse_ranges(positions) - return [] if positions.empty? - ranges = [] - - start = prev = positions[0] - range = start..prev - positions[1..-1].each do |pos| - if pos == prev + 1 - range = start..pos - prev = pos - else - ranges << range - start = prev = pos - range = start..prev - end - end - ranges << range - - ranges - end - - # Inserts tags around the characters identified by the given range - def insert_around_range(text, range, before, after, offset = 0) - # Just to be sure - return offset if offset + range.end + 1 > text.length - - text.insert(offset + range.begin, before) - offset += before.length - - text.insert(offset + range.end + 1, after) - offset += after.length - - offset - end end end end diff --git a/lib/gitlab/ee_compat_check.rb b/lib/gitlab/ee_compat_check.rb index 496ee0bdcb0..06438d2df41 100644 --- a/lib/gitlab/ee_compat_check.rb +++ b/lib/gitlab/ee_compat_check.rb @@ -309,6 +309,17 @@ module Gitlab U lib/gitlab/ee_compat_check.rb Resolve them, stage the changes and commit them. + + If the patch couldn't be applied cleanly, use the following command: + + # In the EE repo + $ git apply --reject path/to/#{ce_branch}.patch + + This option makes git apply the parts of the patch that are applicable, + and leave the rejected hunks in corresponding `.rej` files. + You can then resolve the conflicts highlighted in `.rej` by + manually applying the correct diff from the `.rej` file to the file with conflicts. + When finished, you can delete the `.rej` files and commit your changes. ⚠️ Don't forget to push your branch to gitlab-ee: diff --git a/lib/gitlab/etag_caching/router.rb b/lib/gitlab/etag_caching/router.rb index 692c909d838..ba31041d0c1 100644 --- a/lib/gitlab/etag_caching/router.rb +++ b/lib/gitlab/etag_caching/router.rb @@ -7,8 +7,8 @@ module Gitlab # - Don't contain a reserved word (expect for the words used in the # regex itself) # - Ending in `noteable/issue/<id>/notes` for the `issue_notes` route - # - Ending in `issues/id`/rendered_title` for the `issue_title` route - USED_IN_ROUTES = %w[noteable issue notes issues rendered_title + # - Ending in `issues/id`/realtime_changes` for the `issue_title` route + USED_IN_ROUTES = %w[noteable issue notes issues realtime_changes commit pipelines merge_requests new].freeze RESERVED_WORDS = DynamicPathValidator::WILDCARD_ROUTES - USED_IN_ROUTES RESERVED_WORDS_REGEX = Regexp.union(*RESERVED_WORDS) @@ -18,7 +18,7 @@ module Gitlab 'issue_notes' ), Gitlab::EtagCaching::Router::Route.new( - %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/issues/\d+/rendered_title\z), + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/issues/\d+/realtime_changes\z), 'issue_title' ), Gitlab::EtagCaching::Router::Route.new( @@ -40,7 +40,7 @@ module Gitlab Gitlab::EtagCaching::Router::Route.new( %r(^(?!.*(#{RESERVED_WORDS})).*/pipelines/\d+\.json\z), 'project_pipeline' - ), + ) ].freeze def self.match(env) diff --git a/lib/gitlab/file_detector.rb b/lib/gitlab/file_detector.rb index c9ca4cadd1c..c6a89597b23 100644 --- a/lib/gitlab/file_detector.rb +++ b/lib/gitlab/file_detector.rb @@ -12,8 +12,10 @@ module Gitlab version: 'version', gitignore: '.gitignore', koding: '.koding.yml', + gemfile: /\A(Gemfile|gems\.rb)\z/, gitlab_ci: '.gitlab-ci.yml', - avatar: /\Alogo\.(png|jpg|gif)\z/ + avatar: /\Alogo\.(png|jpg|gif)\z/, + route_map: 'route-map.yml' }.freeze # Returns an Array of file types based on the given paths. diff --git a/lib/gitlab/file_finder.rb b/lib/gitlab/file_finder.rb new file mode 100644 index 00000000000..093d9ed8092 --- /dev/null +++ b/lib/gitlab/file_finder.rb @@ -0,0 +1,32 @@ +# This class finds files in a repository by name and content +# the result is joined and sorted by file name +module Gitlab + class FileFinder + BATCH_SIZE = 100 + + attr_reader :project, :ref + + def initialize(project, ref) + @project = project + @ref = ref + end + + def find(query) + blobs = project.repository.search_files_by_content(query, ref).first(BATCH_SIZE) + found_file_names = Set.new + + results = blobs.map do |blob| + blob = Gitlab::ProjectSearchResults.parse_search_result(blob) + found_file_names << blob.filename + + [blob.filename, blob] + end + + project.repository.search_files_by_name(query, ref).first(BATCH_SIZE).each do |filename| + results << [filename, OpenStruct.new(ref: ref)] unless found_file_names.include?(filename) + end + + results.sort_by(&:first) + end + end +end diff --git a/lib/gitlab/git/blob.rb b/lib/gitlab/git/blob.rb index 12458f9f410..c1b31618e0d 100644 --- a/lib/gitlab/git/blob.rb +++ b/lib/gitlab/git/blob.rb @@ -90,7 +90,7 @@ module Gitlab name: blob_entry[:name], data: '', path: path, - commit_id: sha, + commit_id: sha ) end end diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index 3a73697dc5d..f9a9b767ef4 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -192,6 +192,10 @@ module Gitlab Commit.diff_from_parent(raw_commit, options) end + def deltas + @deltas ||= diff_from_parent.each_delta.map { |d| Gitlab::Git::Diff.new(d) } + end + def has_zero_stats? stats.total.zero? rescue diff --git a/lib/gitlab/git/diff.rb b/lib/gitlab/git/diff.rb index 019be151353..31d1b66b4f7 100644 --- a/lib/gitlab/git/diff.rb +++ b/lib/gitlab/git/diff.rb @@ -183,6 +183,8 @@ module Gitlab when Gitaly::CommitDiffResponse init_from_gitaly(raw_diff) prune_diff_if_eligible(collapse) + when Gitaly::CommitDelta + init_from_gitaly(raw_diff) when nil raise "Nil as raw diff passed" else @@ -278,15 +280,15 @@ module Gitlab end end - def init_from_gitaly(diff_msg) - @diff = diff_msg.raw_chunks.join - @new_path = encode!(diff_msg.to_path.dup) - @old_path = encode!(diff_msg.from_path.dup) - @a_mode = diff_msg.old_mode.to_s(8) - @b_mode = diff_msg.new_mode.to_s(8) - @new_file = diff_msg.from_id == BLANK_SHA - @renamed_file = diff_msg.from_path != diff_msg.to_path - @deleted_file = diff_msg.to_id == BLANK_SHA + def init_from_gitaly(msg) + @diff = msg.raw_chunks.join if msg.respond_to?(:raw_chunks) + @new_path = encode!(msg.to_path.dup) + @old_path = encode!(msg.from_path.dup) + @a_mode = msg.old_mode.to_s(8) + @b_mode = msg.new_mode.to_s(8) + @new_file = msg.from_id == BLANK_SHA + @renamed_file = msg.from_path != msg.to_path + @deleted_file = msg.to_id == BLANK_SHA end def prune_diff_if_eligible(collapse = false) diff --git a/lib/gitlab/git/diff_collection.rb b/lib/gitlab/git/diff_collection.rb index 4e45ec7c174..bcbad8ec829 100644 --- a/lib/gitlab/git/diff_collection.rb +++ b/lib/gitlab/git/diff_collection.rb @@ -15,7 +15,6 @@ module Gitlab @safe_max_bytes = @safe_max_files * 5120 # Average 5 KB per file @all_diffs = !!options.fetch(:all_diffs, false) @no_collapse = !!options.fetch(:no_collapse, true) - @deltas_only = !!options.fetch(:deltas_only, false) @line_count = 0 @byte_count = 0 @@ -27,8 +26,6 @@ module Gitlab if @populated # @iterator.each is slower than just iterating the array in place @array.each(&block) - elsif @deltas_only - each_delta(&block) else Gitlab::GitalyClient.migrate(:commit_raw_diffs) do each_patch(&block) @@ -81,14 +78,6 @@ module Gitlab files >= @safe_max_files || @line_count > @safe_max_lines || @byte_count >= @safe_max_bytes end - def each_delta - @iterator.each_delta.with_index do |delta, i| - diff = Gitlab::Git::Diff.new(delta) - - yield @array[i] = diff - end - end - def each_patch @iterator.each_with_index do |raw, i| # First yield cached Diff instances from @array diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 6a0f12b7e50..d380c5021ee 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -27,13 +27,15 @@ module Gitlab # Rugged repo object attr_reader :rugged + attr_reader :storage + # 'path' must be the path to a _bare_ git repository, e.g. # /path/to/my-repo.git - def initialize(repository_storage, relative_path) - @repository_storage = repository_storage + def initialize(storage, relative_path) + @storage = storage @relative_path = relative_path - storage_path = Gitlab.config.repositories.storages[@repository_storage]['path'] + storage_path = Gitlab.config.repositories.storages[@storage]['path'] @path = File.join(storage_path, @relative_path) @name = @relative_path.split("/").last @attributes = Gitlab::Git::Attributes.new(path) @@ -114,7 +116,7 @@ module Gitlab # Returns the number of valid branches def branch_count - Gitlab::GitalyClient.migrate(:branch_names) do |is_enabled| + gitaly_migrate(:branch_names) do |is_enabled| if is_enabled gitaly_ref_client.count_branch_names else @@ -133,7 +135,7 @@ module Gitlab # Returns the number of valid tags def tag_count - Gitlab::GitalyClient.migrate(:tag_names) do |is_enabled| + gitaly_migrate(:tag_names) do |is_enabled| if is_enabled gitaly_ref_client.count_tag_names else @@ -258,7 +260,7 @@ module Gitlab 'RepoPath' => path, 'ArchivePrefix' => prefix, 'ArchivePath' => archive_file_path(prefix, storage_path, format), - 'CommitId' => commit.id, + 'CommitId' => commit.id } end @@ -471,7 +473,7 @@ module Gitlab def ref_name_for_sha(ref_path, sha) # NOTE: This feature is intentionally disabled until # https://gitlab.com/gitlab-org/gitaly/issues/180 is resolved - # Gitlab::GitalyClient.migrate(:find_ref_name) do |is_enabled| + # gitaly_migrate(:find_ref_name) do |is_enabled| # if is_enabled # gitaly_ref_client.find_ref_name(sha, ref_path) # else @@ -965,11 +967,7 @@ module Gitlab end def gitaly_repository - Gitlab::GitalyClient::Util.repository(@repository_storage, @relative_path) - end - - def gitaly_channel - Gitlab::GitalyClient.get_channel(@repository_storage) + Gitlab::GitalyClient::Util.repository(@storage, @relative_path) end private @@ -1112,56 +1110,6 @@ module Gitlab end end - def archive_to_file(treeish = 'master', filename = 'archive.tar.gz', format = nil, compress_cmd = %w(gzip -n)) - git_archive_cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{path} archive) - - # Put files into a directory before archiving - prefix = "#{archive_name(treeish)}/" - git_archive_cmd << "--prefix=#{prefix}" - - # Format defaults to tar - git_archive_cmd << "--format=#{format}" if format - - git_archive_cmd += %W(-- #{treeish}) - - open(filename, 'w') do |file| - # Create a pipe to act as the '|' in 'git archive ... | gzip' - pipe_rd, pipe_wr = IO.pipe - - # Get the compression process ready to accept data from the read end - # of the pipe - compress_pid = spawn(*nice(compress_cmd), in: pipe_rd, out: file) - # The read end belongs to the compression process now; we should - # close our file descriptor for it. - pipe_rd.close - - # Start 'git archive' and tell it to write into the write end of the - # pipe. - git_archive_pid = spawn(*nice(git_archive_cmd), out: pipe_wr) - # The write end belongs to 'git archive' now; close it. - pipe_wr.close - - # When 'git archive' and the compression process are finished, we are - # done. - Process.waitpid(git_archive_pid) - raise "#{git_archive_cmd.join(' ')} failed" unless $?.success? - Process.waitpid(compress_pid) - raise "#{compress_cmd.join(' ')} failed" unless $?.success? - end - end - - def nice(cmd) - nice_cmd = %w(nice -n 20) - unless unsupported_platform? - nice_cmd += %w(ionice -c 2 -n 7) - end - nice_cmd + cmd - end - - def unsupported_platform? - %w[darwin freebsd solaris].map { |platform| RUBY_PLATFORM.include?(platform) }.any? - end - # Returns true if the index entry has the special file mode that denotes # a submodule. def submodule?(index_entry) diff --git a/lib/gitlab/git/tree.rb b/lib/gitlab/git/tree.rb index b722d8a9f56..d41256d9a84 100644 --- a/lib/gitlab/git/tree.rb +++ b/lib/gitlab/git/tree.rb @@ -35,7 +35,7 @@ module Gitlab type: entry[:type], mode: entry[:filemode].to_s(8), path: path ? File.join(path, entry[:name]) : entry[:name], - commit_id: sha, + commit_id: sha ) end end diff --git a/lib/gitlab/git_post_receive.rb b/lib/gitlab/git_post_receive.rb index 0e14253ab4e..742118b76a8 100644 --- a/lib/gitlab/git_post_receive.rb +++ b/lib/gitlab/git_post_receive.rb @@ -13,6 +13,16 @@ module Gitlab super(identifier, project, revision) end + def changes_refs + return enum_for(:changes_refs) unless block_given? + + changes.each do |change| + oldrev, newrev, ref = change.strip.split(' ') + + yield oldrev, newrev, ref + end + end + private def deserialize_changes(changes) diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb index c69676a1dac..72466700c05 100644 --- a/lib/gitlab/gitaly_client.rb +++ b/lib/gitlab/gitaly_client.rb @@ -4,56 +4,42 @@ module Gitlab module GitalyClient SERVER_VERSION_FILE = 'GITALY_SERVER_VERSION'.freeze - # This function is not thread-safe because it updates Hashes in instance variables. - def self.configure_channels - @addresses = {} - @channels = {} - Gitlab.config.repositories.storages.each do |name, params| - address = params['gitaly_address'] - unless address.present? - raise "storage #{name.inspect} is missing a gitaly_address" - end + MUTEX = Mutex.new + private_constant :MUTEX - unless URI(address).scheme.in?(%w(tcp unix)) - raise "Unsupported Gitaly address: #{address.inspect} does not use URL scheme 'tcp' or 'unix'" + def self.stub(name, storage) + MUTEX.synchronize do + @stubs ||= {} + @stubs[storage] ||= {} + @stubs[storage][name] ||= begin + klass = Gitaly.const_get(name.to_s.camelcase.to_sym).const_get(:Stub) + addr = address(storage) + addr = addr.sub(%r{^tcp://}, '') if URI(addr).scheme == 'tcp' + klass.new(addr, :this_channel_is_insecure) end - - @addresses[name] = address - @channels[name] = new_channel(address) end end - def self.new_channel(address) - address = address.sub(%r{^tcp://}, '') if URI(address).scheme == 'tcp' - # NOTE: When Gitaly runs on a Unix socket, permissions are - # handled using the file system and no additional authentication is - # required (therefore the :this_channel_is_insecure flag) - # TODO: Add authentication support when Gitaly is running on a TCP socket. - GRPC::Core::Channel.new(address, {}, :this_channel_is_insecure) + def self.clear_stubs! + MUTEX.synchronize do + @stubs = nil + end end - def self.get_channel(storage) - if !Rails.env.production? && @channels.nil? - # In development mode the Rails auto-loader may reset the instance - # variables of this class. What we do here is not thread-safe. In normal - # circumstances (including production) these instance variables have - # been initialized from config/initializers. - configure_channels - end + def self.address(storage) + params = Gitlab.config.repositories.storages[storage] + raise "storage not found: #{storage.inspect}" if params.nil? - @channels[storage] - end + address = params['gitaly_address'] + unless address.present? + raise "storage #{storage.inspect} is missing a gitaly_address" + end - def self.get_address(storage) - if !Rails.env.production? && @addresses.nil? - # In development mode the Rails auto-loader may reset the instance - # variables of this class. What we do here is not thread-safe. In normal - # circumstances (including development) these instance variables have - # been initialized from config/initializers. - configure_channels + unless URI(address).scheme.in?(%w(tcp unix)) + raise "Unsupported Gitaly address: #{address.inspect} does not use URL scheme 'tcp' or 'unix'" end - @addresses[storage] + address end def self.enabled? diff --git a/lib/gitlab/gitaly_client/commit.rb b/lib/gitlab/gitaly_client/commit.rb index 0b001a9903d..4491903d788 100644 --- a/lib/gitlab/gitaly_client/commit.rb +++ b/lib/gitlab/gitaly_client/commit.rb @@ -5,41 +5,55 @@ module Gitlab # See http://stackoverflow.com/a/40884093/1856239 and https://github.com/git/git/blob/3ad8b5bf26362ac67c9020bf8c30eee54a84f56d/cache.h#L1011-L1012 EMPTY_TREE_ID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'.freeze - attr_accessor :stub - def initialize(repository) @gitaly_repo = repository.gitaly_repository - @stub = Gitaly::Commit::Stub.new(nil, nil, channel_override: repository.gitaly_channel) + @repository = repository end def is_ancestor(ancestor_id, child_id) + stub = GitalyClient.stub(:commit, @repository.storage) request = Gitaly::CommitIsAncestorRequest.new( repository: @gitaly_repo, ancestor_id: ancestor_id, child_id: child_id ) - @stub.commit_is_ancestor(request).value + stub.commit_is_ancestor(request).value + end + + def diff_from_parent(commit, options = {}) + request_params = commit_diff_request_params(commit, options) + request_params[:ignore_whitespace_change] = options.fetch(:ignore_whitespace_change, false) + + response = diff_service_stub.commit_diff(Gitaly::CommitDiffRequest.new(request_params)) + Gitlab::Git::DiffCollection.new(response, options) end - class << self - def diff_from_parent(commit, options = {}) - repository = commit.project.repository - gitaly_repo = repository.gitaly_repository - stub = Gitaly::Diff::Stub.new(nil, nil, channel_override: repository.gitaly_channel) - parent = commit.parents[0] - parent_id = parent ? parent.id : EMPTY_TREE_ID - request = Gitaly::CommitDiffRequest.new( - repository: gitaly_repo, - left_commit_id: parent_id, - right_commit_id: commit.id, - ignore_whitespace_change: options.fetch(:ignore_whitespace_change, false), - paths: options.fetch(:paths, []), - ) - - Gitlab::Git::DiffCollection.new(stub.commit_diff(request), options) + def commit_deltas(commit) + request_params = commit_diff_request_params(commit) + + response = diff_service_stub.commit_delta(Gitaly::CommitDeltaRequest.new(request_params)) + response.flat_map do |msg| + msg.deltas.map { |d| Gitlab::Git::Diff.new(d) } end end + + private + + def commit_diff_request_params(commit, options = {}) + parent_id = commit.parents[0]&.id || EMPTY_TREE_ID + + { + repository: @gitaly_repo, + left_commit_id: parent_id, + right_commit_id: commit.id, + paths: options.fetch(:paths, []) + } + end + + def diff_service_stub + GitalyClient.stub(:diff, @repository.storage) + end end end end diff --git a/lib/gitlab/gitaly_client/notifications.rb b/lib/gitlab/gitaly_client/notifications.rb index a94a54883db..719554eac52 100644 --- a/lib/gitlab/gitaly_client/notifications.rb +++ b/lib/gitlab/gitaly_client/notifications.rb @@ -6,7 +6,7 @@ module Gitlab # 'repository' is a Gitlab::Git::Repository def initialize(repository) @gitaly_repo = repository.gitaly_repository - @stub = Gitaly::Notifications::Stub.new(nil, nil, channel_override: repository.gitaly_channel) + @stub = GitalyClient.stub(:notifications, repository.storage) end def post_receive diff --git a/lib/gitlab/gitaly_client/ref.rb b/lib/gitlab/gitaly_client/ref.rb index f6c77ef1a3e..bf04e1fa50b 100644 --- a/lib/gitlab/gitaly_client/ref.rb +++ b/lib/gitlab/gitaly_client/ref.rb @@ -6,7 +6,7 @@ module Gitlab # 'repository' is a Gitlab::Git::Repository def initialize(repository) @gitaly_repo = repository.gitaly_repository - @stub = Gitaly::Ref::Stub.new(nil, nil, channel_override: repository.gitaly_channel) + @stub = GitalyClient.stub(:ref, repository.storage) end def default_branch_name diff --git a/lib/gitlab/gitaly_client/util.rb b/lib/gitlab/gitaly_client/util.rb index 4acd297f5cb..86d055d3533 100644 --- a/lib/gitlab/gitaly_client/util.rb +++ b/lib/gitlab/gitaly_client/util.rb @@ -6,7 +6,7 @@ module Gitlab Gitaly::Repository.new( path: File.join(Gitlab.config.repositories.storages[repository_storage]['path'], relative_path), storage_name: repository_storage, - relative_path: relative_path, + relative_path: relative_path ) end end diff --git a/lib/gitlab/highlight.rb b/lib/gitlab/highlight.rb index d787d5db4a0..83bc230df3e 100644 --- a/lib/gitlab/highlight.rb +++ b/lib/gitlab/highlight.rb @@ -13,6 +13,8 @@ module Gitlab highlight(file_name, blob.data, repository: repository).lines.map!(&:html_safe) end + attr_reader :blob_name + def initialize(blob_name, blob_content, repository: nil) @formatter = Rouge::Formatters::HTMLGitlab @repository = repository @@ -21,16 +23,9 @@ module Gitlab end def highlight(text, continue: true, plain: false) - if plain - hl_lexer = Rouge::Lexers::PlainText - continue = false - else - hl_lexer = self.lexer - end - - @formatter.format(hl_lexer.lex(text, continue: continue), tag: hl_lexer.tag).html_safe - rescue - @formatter.format(Rouge::Lexers::PlainText.lex(text)).html_safe + highlighted_text = highlight_text(text, continue: continue, plain: plain) + highlighted_text = link_dependencies(text, highlighted_text) if blob_name + highlighted_text end def lexer @@ -50,5 +45,27 @@ module Gitlab Rouge::Lexer.find_fancy(language_name) end + + def highlight_text(text, continue: true, plain: false) + if plain + highlight_plain(text) + else + highlight_rich(text, continue: continue) + end + end + + def highlight_plain(text) + @formatter.format(Rouge::Lexers::PlainText.lex(text)).html_safe + end + + def highlight_rich(text, continue: true) + @formatter.format(lexer.lex(text, continue: continue), tag: lexer.tag).html_safe + rescue + highlight_plain(text) + end + + def link_dependencies(text, highlighted_text) + Gitlab::DependencyLinker.link(blob_name, text, highlighted_text) + end end end diff --git a/lib/gitlab/kubernetes.rb b/lib/gitlab/kubernetes.rb index 3a7af363548..4a6091488c8 100644 --- a/lib/gitlab/kubernetes.rb +++ b/lib/gitlab/kubernetes.rb @@ -38,7 +38,7 @@ module Gitlab url: container_exec_url(api_url, namespace, pod_name, container["name"]), subprotocols: ['channel.k8s.io'], headers: Hash.new { |h, k| h[k] = [] }, - created_at: created_at, + created_at: created_at } end end @@ -64,7 +64,7 @@ module Gitlab tty: true, stdin: true, stdout: true, - stderr: true, + stderr: true }.to_query + '&' + EXEC_COMMAND case url.scheme diff --git a/lib/gitlab/ldap/config.rb b/lib/gitlab/ldap/config.rb index 46deea3cc9f..6fdf68641e2 100644 --- a/lib/gitlab/ldap/config.rb +++ b/lib/gitlab/ldap/config.rb @@ -39,7 +39,7 @@ module Gitlab def adapter_options opts = base_options.merge( - encryption: encryption, + encryption: encryption ) opts.merge!(auth_options) if has_auth? diff --git a/lib/gitlab/metrics.rb b/lib/gitlab/metrics.rb index c6dfa4ad9bd..cb8db2f1e9f 100644 --- a/lib/gitlab/metrics.rb +++ b/lib/gitlab/metrics.rb @@ -49,6 +49,9 @@ module Gitlab end end end + rescue Errno::EADDRNOTAVAIL, SocketError => ex + Gitlab::EnvironmentLogger.error('Cannot resolve InfluxDB address. GitLab Performance Monitoring will not work.') + Gitlab::EnvironmentLogger.error(ex) end def self.prepare_metrics(metrics) diff --git a/lib/gitlab/other_markup.rb b/lib/gitlab/other_markup.rb index c2adc9aa10b..31a24460f0f 100644 --- a/lib/gitlab/other_markup.rb +++ b/lib/gitlab/other_markup.rb @@ -5,12 +5,12 @@ module Gitlab # # input - the source text in a markup format # - def self.render(file_name, input) + def self.render(file_name, input, context) html = GitHub::Markup.render(file_name, input). force_encoding(input.encoding) + context[:pipeline] = :markup - filter = Banzai::Filter::SanitizationFilter.new(html) - html = filter.call.to_s + html = Banzai.render(html, context) html.html_safe end diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb index 0b8959f2fb9..561aa9e162c 100644 --- a/lib/gitlab/project_search_results.rb +++ b/lib/gitlab/project_search_results.rb @@ -82,26 +82,14 @@ module Gitlab private def blobs - @blobs ||= begin - blobs = project.repository.search_files_by_content(query, repository_ref).first(100) - found_file_names = Set.new - - results = blobs.map do |blob| - blob = self.class.parse_search_result(blob) - found_file_names << blob.filename - - [blob.filename, blob] - end - - project.repository.search_files_by_name(query, repository_ref).first(100).each do |filename| - results << [filename, nil] unless found_file_names.include?(filename) - end + return [] unless Ability.allowed?(@current_user, :download_code, @project) - results.sort_by(&:first) - end + @blobs ||= Gitlab::FileFinder.new(project, repository_ref).find(query) end def wiki_blobs + return [] unless Ability.allowed?(@current_user, :read_wiki, @project) + @wiki_blobs ||= begin if project.wiki_enabled? && query.present? project_wiki = ProjectWiki.new(project) diff --git a/lib/gitlab/prometheus/queries/base_query.rb b/lib/gitlab/prometheus/queries/base_query.rb new file mode 100644 index 00000000000..2a2eb4ae57f --- /dev/null +++ b/lib/gitlab/prometheus/queries/base_query.rb @@ -0,0 +1,26 @@ +module Gitlab + module Prometheus + module Queries + class BaseQuery + attr_accessor :client + delegate :query_range, :query, to: :client, prefix: true + + def raw_memory_usage_query(environment_slug) + %{avg(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}) / 2^20} + end + + def raw_cpu_usage_query(environment_slug) + %{avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) * 100} + end + + def initialize(client) + @client = client + end + + def query(*args) + raise NotImplementedError + end + end + end + end +end diff --git a/lib/gitlab/prometheus/queries/deployment_query.rb b/lib/gitlab/prometheus/queries/deployment_query.rb new file mode 100644 index 00000000000..2cc08731f8d --- /dev/null +++ b/lib/gitlab/prometheus/queries/deployment_query.rb @@ -0,0 +1,26 @@ +module Gitlab::Prometheus::Queries + class DeploymentQuery < BaseQuery + def query(deployment_id) + deployment = Deployment.find_by(id: deployment_id) + environment_slug = deployment.environment.slug + + memory_query = raw_memory_usage_query(environment_slug) + memory_avg_query = %{avg(avg_over_time(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}[30m]))} + cpu_query = raw_cpu_usage_query(environment_slug) + cpu_avg_query = %{avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[30m])) * 100} + + timeframe_start = (deployment.created_at - 30.minutes).to_f + timeframe_end = (deployment.created_at + 30.minutes).to_f + + { + memory_values: client_query_range(memory_query, start: timeframe_start, stop: timeframe_end), + memory_before: client_query(memory_avg_query, time: deployment.created_at.to_f), + memory_after: client_query(memory_avg_query, time: timeframe_end), + + cpu_values: client_query_range(cpu_query, start: timeframe_start, stop: timeframe_end), + cpu_before: client_query(cpu_avg_query, time: deployment.created_at.to_f), + cpu_after: client_query(cpu_avg_query, time: timeframe_end) + } + end + end +end diff --git a/lib/gitlab/prometheus/queries/environment_query.rb b/lib/gitlab/prometheus/queries/environment_query.rb new file mode 100644 index 00000000000..01d756d7284 --- /dev/null +++ b/lib/gitlab/prometheus/queries/environment_query.rb @@ -0,0 +1,20 @@ +module Gitlab::Prometheus::Queries + class EnvironmentQuery < BaseQuery + def query(environment_id) + environment = Environment.find_by(id: environment_id) + environment_slug = environment.slug + timeframe_start = 8.hours.ago.to_f + timeframe_end = Time.now.to_f + + memory_query = raw_memory_usage_query(environment_slug) + cpu_query = raw_cpu_usage_query(environment_slug) + + { + memory_values: client_query_range(memory_query, start: timeframe_start, stop: timeframe_end), + memory_current: client_query(memory_query, time: timeframe_end), + cpu_values: client_query_range(cpu_query, start: timeframe_start, stop: timeframe_end), + cpu_current: client_query(cpu_query, time: timeframe_end) + } + end + end +end diff --git a/lib/gitlab/prometheus.rb b/lib/gitlab/prometheus_client.rb index 8827507955d..5b51a1779dd 100644 --- a/lib/gitlab/prometheus.rb +++ b/lib/gitlab/prometheus_client.rb @@ -2,7 +2,7 @@ module Gitlab PrometheusError = Class.new(StandardError) # Helper methods to interact with Prometheus network services & resources - class Prometheus + class PrometheusClient attr_reader :api_url def initialize(api_url:) @@ -13,18 +13,18 @@ module Gitlab json_api_get('query', query: '1') end - def query(query) + def query(query, time: Time.now) get_result('vector') do - json_api_get('query', query: query) + json_api_get('query', query: query, time: time.to_f) end end - def query_range(query, start: 8.hours.ago) + def query_range(query, start: 8.hours.ago, stop: Time.now) get_result('matrix') do json_api_get('query_range', query: query, start: start.to_f, - end: Time.now.utc.to_f, + end: stop.to_f, step: 1.minute.to_i) end end diff --git a/lib/gitlab/sentry.rb b/lib/gitlab/sentry.rb index 117fc508135..2442c2ded3b 100644 --- a/lib/gitlab/sentry.rb +++ b/lib/gitlab/sentry.rb @@ -11,7 +11,7 @@ module Gitlab Raven.user_context( id: current_user.id, email: current_user.email, - username: current_user.username, + username: current_user.username ) end end diff --git a/lib/gitlab/string_range_marker.rb b/lib/gitlab/string_range_marker.rb new file mode 100644 index 00000000000..94fba0a221a --- /dev/null +++ b/lib/gitlab/string_range_marker.rb @@ -0,0 +1,102 @@ +module Gitlab + class StringRangeMarker + attr_accessor :raw_line, :rich_line + + def initialize(raw_line, rich_line = raw_line) + @raw_line = raw_line + @rich_line = ERB::Util.html_escape(rich_line) + end + + def mark(marker_ranges) + return rich_line unless marker_ranges + + rich_marker_ranges = [] + marker_ranges.each do |range| + # Map the inline-diff range based on the raw line to character positions in the rich line + rich_positions = position_mapping[range].flatten + # Turn the array of character positions into ranges + rich_marker_ranges.concat(collapse_ranges(rich_positions)) + end + + offset = 0 + # Mark each range + rich_marker_ranges.each_with_index do |range, i| + offset_range = (range.begin + offset)..(range.end + offset) + original_text = rich_line[offset_range] + + text = yield(original_text, left: i == 0, right: i == rich_marker_ranges.length - 1) + + rich_line[offset_range] = text + + offset += text.length - original_text.length + end + + rich_line.html_safe + end + + private + + # Mapping of character positions in the raw line, to the rich (highlighted) line + def position_mapping + @position_mapping ||= begin + mapping = [] + rich_pos = 0 + (0..raw_line.length).each do |raw_pos| + rich_char = rich_line[rich_pos] + + # The raw and rich lines are the same except for HTML tags, + # so skip over any `<...>` segment + while rich_char == '<' + until rich_char == '>' + rich_pos += 1 + rich_char = rich_line[rich_pos] + end + + rich_pos += 1 + rich_char = rich_line[rich_pos] + end + + # multi-char HTML entities in the rich line correspond to a single character in the raw line + if rich_char == '&' + multichar_mapping = [rich_pos] + until rich_char == ';' + rich_pos += 1 + multichar_mapping << rich_pos + rich_char = rich_line[rich_pos] + end + + mapping[raw_pos] = multichar_mapping + else + mapping[raw_pos] = rich_pos + end + + rich_pos += 1 + end + + mapping + end + end + + # Takes an array of integers, and returns an array of ranges covering the same integers + def collapse_ranges(positions) + return [] if positions.empty? + ranges = [] + + start = prev = positions[0] + range = start..prev + positions[1..-1].each do |pos| + if pos == prev + 1 + range = start..pos + prev = pos + else + ranges << range + start = prev = pos + range = start..prev + end + end + ranges << range + + ranges + end + end +end diff --git a/lib/gitlab/string_regex_marker.rb b/lib/gitlab/string_regex_marker.rb new file mode 100644 index 00000000000..7ebf1c0428c --- /dev/null +++ b/lib/gitlab/string_regex_marker.rb @@ -0,0 +1,13 @@ +module Gitlab + class StringRegexMarker < StringRangeMarker + def mark(regex, group: 0, &block) + regex_match = raw_line.match(regex) + return rich_line unless regex_match + + begin_index, end_index = regex_match.offset(group) + name_range = begin_index..(end_index - 1) + + super([name_range], &block) + end + end +end diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index 14d8e925d0e..4382cf7b12f 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -52,6 +52,7 @@ module Gitlab def license_usage_data usage_data = { uuid: current_application_settings.uuid, + hostname: Gitlab.config.gitlab.host, version: Gitlab::VERSION, active_user_count: User.active.count, recorded_at: Time.now, diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index 8c5ad01e8c2..fe37e4da94f 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -22,11 +22,11 @@ module Gitlab params = { GL_ID: Gitlab::GlId.gl_id(user), GL_REPOSITORY: Gitlab::GlRepository.gl_repository(project, is_wiki), - RepoPath: repo_path, + RepoPath: repo_path } if Gitlab.config.gitaly.enabled - address = Gitlab::GitalyClient.get_address(project.repository_storage) + address = Gitlab::GitalyClient.address(project.repository_storage) params[:Repository] = repository.gitaly_repository.to_h feature_enabled = case action.to_s @@ -51,7 +51,7 @@ module Gitlab { StoreLFSPath: "#{Gitlab.config.lfs.storage_path}/tmp/upload", LfsOid: oid, - LfsSize: size, + LfsSize: size } end @@ -62,7 +62,7 @@ module Gitlab def send_git_blob(repository, blob) params = { 'RepoPath' => repository.path_to_repo, - 'BlobId' => blob.id, + 'BlobId' => blob.id } [ @@ -127,7 +127,7 @@ module Gitlab 'Subprotocols' => terminal[:subprotocols], 'Url' => terminal[:url], 'Header' => terminal[:headers], - 'MaxSessionTime' => terminal[:max_session_time], + 'MaxSessionTime' => terminal[:max_session_time] } } details['Terminal']['CAPem'] = terminal[:ca_pem] if terminal.has_key?(:ca_pem) @@ -165,7 +165,7 @@ module Gitlab encoded_message, secret, true, - { iss: 'gitlab-workhorse', verify_iss: true, algorithm: 'HS256' }, + { iss: 'gitlab-workhorse', verify_iss: true, algorithm: 'HS256' } ) end diff --git a/lib/tasks/gemojione.rake b/lib/tasks/gemojione.rake index b5572a39d30..87ca39b079b 100644 --- a/lib/tasks/gemojione.rake +++ b/lib/tasks/gemojione.rake @@ -21,7 +21,7 @@ namespace :gemojione do moji: emoji_hash['moji'], description: emoji_hash['description'], unicodeVersion: Gitlab::Emoji.emoji_unicode_version(name), - digest: hash_digest, + digest: hash_digest } resultant_emoji_map[name] = entry diff --git a/lib/tasks/gitlab/info.rake b/lib/tasks/gitlab/info.rake index a2a2db487b7..e3883278886 100644 --- a/lib/tasks/gitlab/info.rake +++ b/lib/tasks/gitlab/info.rake @@ -16,6 +16,8 @@ namespace :gitlab do redis_version = run_and_match(%w(redis-cli --version), /redis-cli (\d+\.\d+\.\d+)/).to_a # check Git version git_version = run_and_match([Gitlab.config.git.bin_path, '--version'], /git version ([\d\.]+)/).to_a + # check Go version + go_version = run_and_match(%w(go version), /go version (.+)/).to_a puts "" puts "System information".color(:yellow) @@ -30,6 +32,7 @@ namespace :gitlab do puts "Redis Version:\t#{redis_version[1] || "unknown".color(:red)}" puts "Git Version:\t#{git_version[1] || "unknown".color(:red)}" puts "Sidekiq Version:#{Sidekiq::VERSION}" + puts "Go Version:\t#{go_version[1] || "unknown".color(:red)}" # check database adapter database_adapter = ActiveRecord::Base.connection.adapter_name.downcase diff --git a/lib/tasks/gitlab/update_templates.rake b/lib/tasks/gitlab/update_templates.rake index 1b04e1350ed..59c32bbe7a4 100644 --- a/lib/tasks/gitlab/update_templates.rake +++ b/lib/tasks/gitlab/update_templates.rake @@ -49,7 +49,7 @@ namespace :gitlab do Template.new( "https://gitlab.com/gitlab-org/Dockerfile.git", /(\.{1,2}|LICENSE|CONTRIBUTING.md|\.Dockerfile)\z/ - ), + ) ].freeze def vendor_directory diff --git a/lib/tasks/migrate/setup_postgresql.rake b/lib/tasks/migrate/setup_postgresql.rake index 1e00b47303d..4108cee08b4 100644 --- a/lib/tasks/migrate/setup_postgresql.rake +++ b/lib/tasks/migrate/setup_postgresql.rake @@ -4,6 +4,7 @@ require Rails.root.join('db/migrate/20151007120511_namespaces_projects_path_lowe require Rails.root.join('db/migrate/20151008110232_add_users_lower_username_email_indexes') require Rails.root.join('db/migrate/20161212142807_add_lower_path_index_to_routes') require Rails.root.join('db/migrate/20170317203554_index_routes_path_for_like') +require Rails.root.join('db/migrate/20170503185032_index_redirect_routes_path_for_like') desc 'GitLab | Sets up PostgreSQL' task setup_postgresql: :environment do diff --git a/lib/tasks/spec.rake b/lib/tasks/spec.rake index 602c60be828..2eddcb3c777 100644 --- a/lib/tasks/spec.rake +++ b/lib/tasks/spec.rake @@ -60,7 +60,7 @@ desc "GitLab | Run specs" task :spec do cmds = [ %w(rake gitlab:setup), - %w(rspec spec), + %w(rspec spec) ] run_commands(cmds) end diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index b804dc0436f..1c44ed4b77c 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -7,201 +7,201 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2017-04-12 22:37-0500\n" -"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"PO-Revision-Date: 2017-05-09 13:44+0200\n" "Language-Team: German\n" "Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"\n" +"Last-Translator: \n" +"X-Generator: Poedit 2.0.1\n" msgid "ByAuthor|by" -msgstr "" +msgstr "Von" msgid "Commit" msgid_plural "Commits" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "Commit" +msgstr[1] "Commits" msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." -msgstr "" +msgstr "Cycle Analytics liefern einen Überblick darüber, wie viel Zeit in Ihrem Projekt von einer Idee bis zum Produktivdeployment vergeht." msgid "CycleAnalyticsStage|Code" -msgstr "" +msgstr "Code" msgid "CycleAnalyticsStage|Issue" -msgstr "" +msgstr "Issue" msgid "CycleAnalyticsStage|Plan" -msgstr "" +msgstr "Planung" msgid "CycleAnalyticsStage|Production" -msgstr "" +msgstr "Produktiv" msgid "CycleAnalyticsStage|Review" -msgstr "" +msgstr "Review" msgid "CycleAnalyticsStage|Staging" -msgstr "" +msgstr "Staging" msgid "CycleAnalyticsStage|Test" -msgstr "" +msgstr "Test" msgid "Deploy" msgid_plural "Deploys" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "Deployment" +msgstr[1] "Deployments" msgid "FirstPushedBy|First" -msgstr "" +msgstr "Erster" msgid "FirstPushedBy|pushed by" -msgstr "" +msgstr "gepusht von" msgid "From issue creation until deploy to production" -msgstr "" +msgstr "Vom Anlegen des Issues bis zum Produktivdeployment" msgid "From merge request merge until deploy to production" -msgstr "" +msgstr "Vom Merge Request bis zum Produktivdeployment" msgid "Introducing Cycle Analytics" -msgstr "" +msgstr "Was sind Cycle Analytics?" msgid "Last %d day" msgid_plural "Last %d days" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "Letzter %d Tag" +msgstr[1] "Letzten %d Tage" msgid "Limited to showing %d event at most" msgid_plural "Limited to showing %d events at most" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "Eingeschränkt auf maximal %d Ereignis" +msgstr[1] "Eingeschränkt auf maximal %d Ereignisse" msgid "Median" -msgstr "" +msgstr "Median" msgid "New Issue" msgid_plural "New Issues" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "Neues Issue" +msgstr[1] "Neue Issues" msgid "Not available" -msgstr "" +msgstr "Nicht verfügbar" msgid "Not enough data" -msgstr "" +msgstr "Nicht genügend Daten" msgid "OpenedNDaysAgo|Opened" -msgstr "" +msgstr "Erstellt" msgid "Pipeline Health" -msgstr "" +msgstr "Pipeline Kennzahlen" msgid "ProjectLifecycle|Stage" -msgstr "" +msgstr "Phase" msgid "Read more" -msgstr "" +msgstr "Mehr" msgid "Related Commits" -msgstr "" +msgstr "Zugehörige Commits" msgid "Related Deployed Jobs" -msgstr "" +msgstr "Zugehörige Deploymentjobs" msgid "Related Issues" -msgstr "" +msgstr "Zugehörige Issues" msgid "Related Jobs" -msgstr "" +msgstr "Zugehörige Jobs" msgid "Related Merge Requests" -msgstr "" +msgstr "Zugehörige Merge Requests" msgid "Related Merged Requests" -msgstr "" +msgstr "Zugehörige abgeschlossene Merge Requests" msgid "Showing %d event" msgid_plural "Showing %d events" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "Zeige %d Ereignis" +msgstr[1] "Zeige %d Ereignisse" msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." -msgstr "" +msgstr "Die Code-Phase stellt die Zeit vom ersten Commit bis zum Erstellen eines Merge Requests dar. Sobald Sie Ihren ersten Merge Request anlegen, werden dessen Daten automatisch ergänzt." msgid "The collection of events added to the data gathered for that stage." -msgstr "" +msgstr "Ereignisse, die für diese Phase ausgewertet wurden." msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." -msgstr "" +msgstr "Die Issue-Phase stellt die Zeit vom Anlegen eines Issues bis zum Zuweisen eines Meilensteins oder Hinzufügen zum Issue Board dar. Erstellen Sie einen Issue, damit dessen Daten hier erscheinen." msgid "The phase of the development lifecycle." -msgstr "" +msgstr "Die Phase im Entwicklungsprozess." msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." -msgstr "" +msgstr "Die Planungsphase stellt die Zeit von der vorherigen Phase bis zum Pushen des ersten Commits dar. Sobald Sie den ersten Commit pushen, werden dessen Daten hier erscheinen." msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." -msgstr "" +msgstr "Die Produktiv-Phase stellt die Gesamtzeit vom Anlegen eines Issues bis zum Deployment auf dem Produktivsystem dar. Sobald Sie den vollständigen Entwicklungszyklus von einer Idee bis zum Produktivdeployment durchlaufen haben, erscheinen die zugehörigen Daten hier." msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." -msgstr "" +msgstr "Die Review-Phase stellt die Zeit vom Anlegen eines Merge Requests bis zum Mergen dar. Sobald Sie Ihren ersten Merge Request abschließen, werden dessen Daten hier automatisch angezeigt." msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." -msgstr "" +msgstr "Die Staging-Phase stellt die Zeit zwischen Mergen eines Merge Requests und dem Produktivdeployment dar. Sobald Sie das erste Produktivdeployment durchgeführt haben, werden dessen Daten hier automatisch angezeigt." msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." -msgstr "" +msgstr "Die Test-Phase stellt die Zeit dar, die GitLab CI benötigt um die Pipelines von Merge Requests abzuarbeiten. Sobald die erste Pipeline abgeschlossen ist, werden deren Daten hier automatisch angezeigt." msgid "The time taken by each data entry gathered by that stage." -msgstr "" +msgstr "Zeit die für das jeweilige Ereignis in der Phase ermittelt wurde." msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." -msgstr "" +msgstr "Der mittlere aller erfassten Werte. Zum Beispiel ist für 3, 5, 9 der Median 5. Bei 3, 5, 7, 8 ist der Median (5+7)/2 = 6." msgid "Time before an issue gets scheduled" -msgstr "" +msgstr "Zeit bis ein Issue geplant wird" msgid "Time before an issue starts implementation" -msgstr "" +msgstr "Zeit bis die Implementierung für ein Issue beginnt" msgid "Time between merge request creation and merge/close" -msgstr "" +msgstr "Zeit zwischen Anlegen und Mergen/Schließen eines Merge Requests" msgid "Time until first merge request" -msgstr "" +msgstr "Zeit bis zum ersten Merge Request" msgid "Time|hr" msgid_plural "Time|hrs" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "h" +msgstr[1] "h" msgid "Time|min" msgid_plural "Time|mins" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "min" +msgstr[1] "min" msgid "Time|s" -msgstr "" +msgstr "s" msgid "Total Time" -msgstr "" +msgstr "Gesamtzeit" msgid "Total test time for all commits/merges" -msgstr "" +msgstr "Gesamte Testlaufzeit für alle Commits/Merges" msgid "Want to see the data? Please ask an administrator for access." -msgstr "" +msgstr "Um diese Daten einsehen zu können, wenden Sie sich bitte an Ihren Administrator." msgid "We don't have enough data to show this stage." -msgstr "" +msgstr "Es liegen nicht genügend Daten vor, um diese Phase anzuzeigen." msgid "You need permission." -msgstr "" +msgstr "Sie benötigen Zugriffsrechte." msgid "day" msgid_plural "days" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "Tag" +msgstr[1] "Tage" diff --git a/scripts/trigger-build b/scripts/trigger-build new file mode 100755 index 00000000000..565bc314ef1 --- /dev/null +++ b/scripts/trigger-build @@ -0,0 +1,21 @@ +#!/usr/bin/env ruby + +require 'net/http' +require 'json' + +uri = URI('https://gitlab.com/api/v4/projects/20699/trigger/pipeline') +params = { + "ref" => ENV["OMNIBUS_BRANCH"] || "master", + "token" => ENV["BUILD_TRIGGER_TOKEN"], + "variables[GITLAB_VERSION]" => ENV["CI_COMMIT_SHA"], + "variables[ALTERNATIVE_SOURCES]" => true +} + +Dir.glob("*_VERSION").each do |version_file| + params["variables[#{version_file}]"] = File.read(version_file).strip +end + +res = Net::HTTP.post_form(uri, params) +pipeline_id = JSON.parse(res.body)['id'] + +puts "Triggered pipeline can be found at https://gitlab.com/gitlab-org/omnibus-gitlab/pipelines/#{pipeline_id}" diff --git a/spec/controllers/admin/hooks_controller_spec.rb b/spec/controllers/admin/hooks_controller_spec.rb new file mode 100644 index 00000000000..1d1070e90f4 --- /dev/null +++ b/spec/controllers/admin/hooks_controller_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' + +describe Admin::HooksController do + let(:admin) { create(:admin) } + + before do + sign_in(admin) + end + + describe 'POST #create' do + it 'sets all parameters' do + hook_params = { + enable_ssl_verification: true, + push_events: true, + tag_push_events: true, + repository_update_events: true, + token: "TEST TOKEN", + url: "http://example.com" + } + + post :create, hook: hook_params + + expect(response).to have_http_status(302) + expect(SystemHook.all.size).to eq(1) + expect(SystemHook.first).to have_attributes(hook_params) + end + end +end diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index 073b87a1cb4..15dae3231ca 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -26,6 +26,41 @@ describe GroupsController do end end + describe 'GET #subgroups' do + let!(:public_subgroup) { create(:group, :public, parent: group) } + let!(:private_subgroup) { create(:group, :private, parent: group) } + + context 'as a user' do + before do + sign_in(user) + end + + it 'shows the public subgroups' do + get :subgroups, id: group.to_param + + expect(assigns(:nested_groups)).to contain_exactly(public_subgroup) + end + + context 'being member' do + it 'shows public and private subgroups the user is member of' do + private_subgroup.add_guest(user) + + get :subgroups, id: group.to_param + + expect(assigns(:nested_groups)).to contain_exactly(public_subgroup, private_subgroup) + end + end + end + + context 'as a guest' do + it 'shows the public subgroups' do + get :subgroups, id: group.to_param + + expect(assigns(:nested_groups)).to contain_exactly(public_subgroup) + end + end + end + describe 'GET #issues' do let(:issue_1) { create(:issue, project: project) } let(:issue_2) { create(:issue, project: project) } @@ -33,7 +68,7 @@ describe GroupsController do before do create_list(:award_emoji, 3, awardable: issue_2) create_list(:award_emoji, 2, awardable: issue_1) - create_list(:award_emoji, 2, :downvote, awardable: issue_2,) + create_list(:award_emoji, 2, :downvote, awardable: issue_2) sign_in(user) end @@ -66,7 +101,7 @@ describe GroupsController do get :issues, id: redirect_route.path expect(response).to redirect_to(issues_group_path(group.to_param)) - expect(controller).to set_flash[:notice].to(/moved/) + expect(controller).to set_flash[:notice].to(group_moved_message(redirect_route, group)) end end end @@ -111,7 +146,7 @@ describe GroupsController do get :merge_requests, id: redirect_route.path expect(response).to redirect_to(merge_requests_group_path(group.to_param)) - expect(controller).to set_flash[:notice].to(/moved/) + expect(controller).to set_flash[:notice].to(group_moved_message(redirect_route, group)) end end end @@ -214,4 +249,8 @@ describe GroupsController do end end end + + def group_moved_message(redirect_route, group) + "Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path." + end end diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb index 8f915d9d210..f285e5333d6 100644 --- a/spec/controllers/projects/branches_controller_spec.rb +++ b/spec/controllers/projects/branches_controller_spec.rb @@ -213,33 +213,98 @@ describe Projects::BranchesController do sign_in(user) post :destroy, - format: :js, - id: branch, - namespace_id: project.namespace, - project_id: project + format: format, + id: branch, + namespace_id: project.namespace, + project_id: project end - context "valid branch name, valid source" do + context 'as JS' do let(:branch) { "feature" } + let(:format) { :js } - it { expect(response).to have_http_status(200) } - end + context "valid branch name, valid source" do + let(:branch) { "feature" } + + it { expect(response).to have_http_status(200) } + it { expect(response.body).to be_blank } + end + + context "valid branch name with unencoded slashes" do + let(:branch) { "improve/awesome" } + + it { expect(response).to have_http_status(200) } + it { expect(response.body).to be_blank } + end + + context "valid branch name with encoded slashes" do + let(:branch) { "improve%2Fawesome" } - context "valid branch name with unencoded slashes" do - let(:branch) { "improve/awesome" } + it { expect(response).to have_http_status(200) } + it { expect(response.body).to be_blank } + end - it { expect(response).to have_http_status(200) } + context "invalid branch name, valid ref" do + let(:branch) { "no-branch" } + + it { expect(response).to have_http_status(404) } + it { expect(response.body).to be_blank } + end end - context "valid branch name with encoded slashes" do - let(:branch) { "improve%2Fawesome" } + context 'as JSON' do + let(:branch) { "feature" } + let(:format) { :json } + + context 'valid branch name, valid source' do + let(:branch) { "feature" } - it { expect(response).to have_http_status(200) } + it 'returns JSON response with message' do + expect(json_response).to eql("message" => 'Branch was removed') + end + + it { expect(response).to have_http_status(200) } + end + + context 'valid branch name with unencoded slashes' do + let(:branch) { "improve/awesome" } + + it 'returns JSON response with message' do + expect(json_response).to eql('message' => 'Branch was removed') + end + + it { expect(response).to have_http_status(200) } + end + + context "valid branch name with encoded slashes" do + let(:branch) { 'improve%2Fawesome' } + + it 'returns JSON response with message' do + expect(json_response).to eql('message' => 'Branch was removed') + end + + it { expect(response).to have_http_status(200) } + end + + context 'invalid branch name, valid ref' do + let(:branch) { 'no-branch' } + + it 'returns JSON response with message' do + expect(json_response).to eql('message' => 'No such branch') + end + + it { expect(response).to have_http_status(404) } + end end - context "invalid branch name, valid ref" do - let(:branch) { "no-branch" } - it { expect(response).to have_http_status(404) } + context 'as HTML' do + let(:branch) { "feature" } + let(:format) { :html } + + it 'redirects to branches path' do + expect(response) + .to redirect_to(namespace_project_branches_path(project.namespace, project)) + end end end diff --git a/spec/controllers/projects/deployments_controller_spec.rb b/spec/controllers/projects/deployments_controller_spec.rb index 89692b601b2..4c69443314d 100644 --- a/spec/controllers/projects/deployments_controller_spec.rb +++ b/spec/controllers/projects/deployments_controller_spec.rb @@ -8,7 +8,7 @@ describe Projects::DeploymentsController do let(:environment) { create(:environment, name: 'production', project: project) } before do - project.add_master(user) + project.team << [user, :master] sign_in(user) end @@ -19,7 +19,7 @@ describe Projects::DeploymentsController do create(:deployment, environment: environment, created_at: 7.hours.ago) create(:deployment, environment: environment) - get :index, environment_params(after: 8.hours.ago) + get :index, deployment_params(after: 8.hours.ago) expect(response).to be_ok @@ -29,14 +29,88 @@ describe Projects::DeploymentsController do it 'returns a list with deployments information' do create(:deployment, environment: environment) - get :index, environment_params + get :index, deployment_params expect(response).to be_ok expect(response).to match_response_schema('deployments') end end - def environment_params(opts = {}) - opts.reverse_merge(namespace_id: project.namespace, project_id: project, environment_id: environment.id) + describe 'GET #metrics' do + let(:deployment) { create(:deployment, project: project, environment: environment) } + + before do + allow(controller).to receive(:deployment).and_return(deployment) + end + context 'when metrics are disabled' do + before do + allow(deployment).to receive(:has_metrics?).and_return false + end + + it 'responds with not found' do + get :metrics, deployment_params(id: deployment.id) + + expect(response).to be_not_found + end + end + + context 'when metrics are enabled' do + before do + allow(deployment).to receive(:has_metrics?).and_return true + end + + context 'when environment has no metrics' do + before do + expect(deployment).to receive(:metrics).and_return(nil) + end + + it 'returns a empty response 204 resposne' do + get :metrics, deployment_params(id: deployment.id) + expect(response).to have_http_status(204) + expect(response.body).to eq('') + end + end + + context 'when environment has some metrics' do + let(:empty_metrics) do + { + success: true, + metrics: {}, + last_update: 42 + } + end + + before do + expect(deployment).to receive(:metrics).and_return(empty_metrics) + end + + it 'returns a metrics JSON document' do + get :metrics, deployment_params(id: deployment.id) + + expect(response).to be_ok + expect(json_response['success']).to be(true) + expect(json_response['metrics']).to eq({}) + expect(json_response['last_update']).to eq(42) + end + end + + context 'when metrics service does not implement deployment metrics' do + before do + allow(deployment).to receive(:metrics).and_raise(NotImplementedError) + end + + it 'responds with not found' do + get :metrics, deployment_params(id: deployment.id) + + expect(response).to be_not_found + end + end + end + end + + def deployment_params(opts = {}) + opts.reverse_merge(namespace_id: project.namespace, + project_id: project, + environment_id: environment.id) end end diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index 5c478534ff3..c0f8c36a018 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -149,6 +149,48 @@ describe Projects::EnvironmentsController do end end + describe 'PATCH #stop' do + context 'when env not available' do + it 'returns 404' do + allow_any_instance_of(Environment).to receive(:available?) { false } + + patch :stop, environment_params(format: :json) + + expect(response).to have_http_status(404) + end + end + + context 'when stop action' do + it 'returns action url' do + action = create(:ci_build, :manual) + + allow_any_instance_of(Environment) + .to receive_messages(available?: true, stop_with_action!: action) + + patch :stop, environment_params(format: :json) + + expect(response).to have_http_status(200) + expect(json_response).to eq( + { 'redirect_url' => + "http://test.host/#{project.path_with_namespace}/builds/#{action.id}" }) + end + end + + context 'when no stop action' do + it 'returns env url' do + allow_any_instance_of(Environment) + .to receive_messages(available?: true, stop_with_action!: nil) + + patch :stop, environment_params(format: :json) + + expect(response).to have_http_status(200) + expect(json_response).to eq( + { 'redirect_url' => + "http://test.host/#{project.path_with_namespace}/environments/#{environment.id}" }) + end + end + end + describe 'GET #terminal' do context 'with valid id' do it 'responds with a status code 200' do diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 0483c6b7879..0b3492a8fed 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -59,6 +59,18 @@ describe Projects::MergeRequestsController do end end + describe 'GET commit_change_content' do + it 'renders commit_change_content template' do + get :commit_change_content, + namespace_id: project.namespace.to_param, + project_id: project, + id: merge_request.iid, + format: 'html' + + expect(response).to render_template('_commit_change_content') + end + end + shared_examples "loads labels" do |action| it "loads labels into the @labels variable" do get action, @@ -71,63 +83,47 @@ describe Projects::MergeRequestsController do end describe "GET show" do - shared_examples "export merge as" do |format| - it "does generally work" do - get(:show, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - format: format) + def go(extra_params = {}) + params = { + namespace_id: project.namespace.to_param, + project_id: project, + id: merge_request.iid + } - expect(response).to be_success - end + get :show, params.merge(extra_params) + end - it_behaves_like "loads labels", :show + it_behaves_like "loads labels", :show - it "generates it" do - expect_any_instance_of(MergeRequest).to receive(:"to_#{format}") + describe 'as html' do + it "renders merge request page" do + go(format: :html) - get(:show, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - format: format) + expect(response).to be_success end + end - it "renders it" do - get(:show, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - format: format) + describe 'as json' do + context 'with basic param' do + it 'renders basic MR entity as json' do + go(basic: true, format: :json) - expect(response.body).to eq(merge_request.send(:"to_#{format}").to_s) + expect(response).to match_response_schema('entities/merge_request_basic') + end end - it "does not escape Html" do - allow_any_instance_of(MergeRequest).to receive(:"to_#{format}"). - and_return('HTML entities &<>" ') + context 'without basic param' do + it 'renders the merge request in the json format' do + go(format: :json) - get(:show, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - format: format) - - expect(response.body).not_to include('&') - expect(response.body).not_to include('>') - expect(response.body).not_to include('<') - expect(response.body).not_to include('"') + expect(response).to match_response_schema('entities/merge_request') + end end end describe "as diff" do it "triggers workhorse to serve the request" do - get(:show, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - format: :diff) + go(format: :diff) expect(response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-diff:") end @@ -135,11 +131,7 @@ describe Projects::MergeRequestsController do describe "as patch" do it 'triggers workhorse to serve the request' do - get(:show, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - format: :patch) + go(format: :patch) expect(response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-format-patch:") end @@ -295,19 +287,18 @@ describe Projects::MergeRequestsController do namespace_id: project.namespace, project_id: project, id: merge_request.iid, - format: 'raw' + format: 'json' } end - context 'when the user does not have access' do + context 'when user cannot access' do before do - project.team.truncate - project.team << [user, :reporter] - post :merge, base_params + project.add_reporter(user) + xhr :post, :merge, base_params end - it 'returns not found' do - expect(response).to be_not_found + it 'returns 404' do + expect(response).to have_http_status(404) end end @@ -319,7 +310,7 @@ describe Projects::MergeRequestsController do end it 'returns :failed' do - expect(assigns(:status)).to eq(:failed) + expect(json_response).to eq('status' => 'failed') end end @@ -327,7 +318,7 @@ describe Projects::MergeRequestsController do before { post :merge, base_params.merge(sha: 'foo') } it 'returns :sha_mismatch' do - expect(assigns(:status)).to eq(:sha_mismatch) + expect(json_response).to eq('status' => 'sha_mismatch') end end @@ -339,7 +330,7 @@ describe Projects::MergeRequestsController do it 'returns :success' do merge_with_sha - expect(assigns(:status)).to eq(:success) + expect(json_response).to eq('status' => 'success') end it 'starts the merge immediately' do @@ -354,13 +345,14 @@ describe Projects::MergeRequestsController do end before do - create(:ci_empty_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch) + pipeline = create(:ci_empty_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch) + merge_request.update(head_pipeline: pipeline) end it 'returns :merge_when_pipeline_succeeds' do merge_when_pipeline_succeeds - expect(assigns(:status)).to eq(:merge_when_pipeline_succeeds) + expect(json_response).to eq('status' => 'merge_when_pipeline_succeeds') end it 'sets the MR to merge when the pipeline succeeds' do @@ -382,7 +374,7 @@ describe Projects::MergeRequestsController do it 'returns :merge_when_pipeline_succeeds' do merge_when_pipeline_succeeds - expect(assigns(:status)).to eq(:merge_when_pipeline_succeeds) + expect(json_response).to eq('status' => 'merge_when_pipeline_succeeds') end end end @@ -403,7 +395,7 @@ describe Projects::MergeRequestsController do it 'returns :failed' do merge_with_sha - expect(assigns(:status)).to eq(:failed) + expect(json_response).to eq('status' => 'failed') end end @@ -416,7 +408,7 @@ describe Projects::MergeRequestsController do it 'returns :success' do merge_with_sha - expect(assigns(:status)).to eq(:success) + expect(json_response).to eq('status' => 'success') end end end @@ -434,7 +426,7 @@ describe Projects::MergeRequestsController do it 'returns :success' do merge_with_sha - expect(assigns(:status)).to eq(:success) + expect(json_response).to eq('status' => 'success') end end @@ -447,7 +439,7 @@ describe Projects::MergeRequestsController do it 'returns :success' do merge_with_sha - expect(assigns(:status)).to eq(:success) + expect(json_response).to eq('status' => 'success') end end end @@ -831,18 +823,55 @@ describe Projects::MergeRequestsController do end end - context 'POST remove_wip' do - it 'removes the wip status' do + describe 'POST remove_wip' do + before do merge_request.title = merge_request.wip_title merge_request.save - post :remove_wip, - namespace_id: merge_request.project.namespace.to_param, - project_id: merge_request.project, - id: merge_request.iid + xhr :post, :remove_wip, + namespace_id: merge_request.project.namespace.to_param, + project_id: merge_request.project, + id: merge_request.iid, + format: :json + end + it 'removes the wip status' do expect(merge_request.reload.title).to eq(merge_request.wipless_title) end + + it 'renders MergeRequest as JSON' do + expect(json_response.keys).to include('id', 'iid', 'description') + end + end + + describe 'POST cancel_merge_when_pipeline_succeeds' do + subject do + xhr :post, :cancel_merge_when_pipeline_succeeds, + namespace_id: merge_request.project.namespace.to_param, + project_id: merge_request.project, + id: merge_request.iid, + format: :json + end + + it 'calls MergeRequests::MergeWhenPipelineSucceedsService' do + mwps_service = double + + allow(MergeRequests::MergeWhenPipelineSucceedsService) + .to receive(:new) + .and_return(mwps_service) + + expect(mwps_service).to receive(:cancel).with(merge_request) + + subject + end + + it { is_expected.to have_http_status(:success) } + + it 'renders MergeRequest as JSON' do + subject + + expect(json_response.keys).to include('id', 'iid', 'description') + end end describe 'GET conflict_for_path' do @@ -887,7 +916,9 @@ describe Projects::MergeRequestsController do end it 'returns the file in JSON format' do - content = merge_request_with_conflicts.conflicts.file_for_path(path, path).content + content = MergeRequests::Conflicts::ListService.new(merge_request_with_conflicts). + file_for_path(path, path). + content expect(json_response).to include('old_path' => path, 'new_path' => path, @@ -1011,11 +1042,15 @@ describe Projects::MergeRequestsController do context 'when a file has identical content to the conflict' do before do + content = MergeRequests::Conflicts::ListService.new(merge_request_with_conflicts). + file_for_path('files/ruby/popen.rb', 'files/ruby/popen.rb'). + content + resolved_files = [ { 'new_path' => 'files/ruby/popen.rb', 'old_path' => 'files/ruby/popen.rb', - 'content' => merge_request_with_conflicts.conflicts.file_for_path('files/ruby/popen.rb', 'files/ruby/popen.rb').content + 'content' => content }, { 'new_path' => 'files/ruby/regex.rb', 'old_path' => 'files/ruby/regex.rb', @@ -1121,74 +1156,6 @@ describe Projects::MergeRequestsController do end end - describe 'GET merge_widget_refresh' do - let(:params) do - { - namespace_id: project.namespace, - project_id: project, - id: merge_request.iid, - format: :raw - } - end - - before do - project.team << [user, :developer] - xhr :get, :merge_widget_refresh, params - end - - context 'when merge in progress' do - let(:merge_request) { create(:merge_request, source_project: project, in_progress_merge_commit_sha: 'sha') } - - it 'returns an OK response' do - expect(response).to have_http_status(:ok) - end - - it 'sets status to :success' do - expect(assigns(:status)).to eq(:success) - expect(response).to render_template('merge') - end - end - - context 'when merge request was merged already' do - let(:merge_request) { create(:merge_request, source_project: project, state: :merged) } - - it 'returns an OK response' do - expect(response).to have_http_status(:ok) - end - - it 'sets status to :success' do - expect(assigns(:status)).to eq(:success) - expect(response).to render_template('merge') - end - end - - context 'when waiting for build' do - let(:merge_request) { create(:merge_request, source_project: project, merge_when_pipeline_succeeds: true, merge_user: user) } - - it 'returns an OK response' do - expect(response).to have_http_status(:ok) - end - - it 'sets status to :merge_when_pipeline_succeeds' do - expect(assigns(:status)).to eq(:merge_when_pipeline_succeeds) - expect(response).to render_template('merge') - end - end - - context 'when MR does not have special state' do - let(:merge_request) { create(:merge_request, source_project: project) } - - it 'returns an OK response' do - expect(response).to have_http_status(:ok) - end - - it 'sets status to success' do - expect(assigns(:status)).to eq(:success) - expect(response).to render_template('merge') - end - end - end - describe 'GET pipeline_status.json' do context 'when head_pipeline exists' do let!(:pipeline) do @@ -1199,7 +1166,10 @@ describe Projects::MergeRequestsController do let(:status) { pipeline.detailed_status(double('user')) } - before { get_pipeline_status } + before do + merge_request.update(head_pipeline: pipeline) + get_pipeline_status + end it 'return a detailed head_pipeline status in json' do expect(response).to have_http_status(:ok) diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index e46ef447df2..e230944d52e 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -227,7 +227,7 @@ describe ProjectsController do get :show, namespace_id: 'foo', id: 'bar' expect(response).to redirect_to(public_project) - expect(controller).to set_flash[:notice].to(/moved/) + expect(controller).to set_flash[:notice].to(project_moved_message(redirect_route, public_project)) end end end @@ -473,7 +473,7 @@ describe ProjectsController do get :refs, namespace_id: 'foo', id: 'bar' expect(response).to redirect_to(refs_namespace_project_path(namespace_id: public_project.namespace, id: public_project)) - expect(controller).to set_flash[:notice].to(/moved/) + expect(controller).to set_flash[:notice].to(project_moved_message(redirect_route, public_project)) end end end @@ -487,4 +487,8 @@ describe ProjectsController do expect(JSON.parse(response.body).keys).to match_array(%w(body references)) end end + + def project_moved_message(redirect_route, project) + "Project '#{redirect_route.path}' was moved to '#{project.full_path}'. Please update any links and bookmarks that may still have the old path." + end end diff --git a/spec/controllers/snippets_controller_spec.rb b/spec/controllers/snippets_controller_spec.rb index 41cd5bdcdd8..930415a4778 100644 --- a/spec/controllers/snippets_controller_spec.rb +++ b/spec/controllers/snippets_controller_spec.rb @@ -3,6 +3,34 @@ require 'spec_helper' describe SnippetsController do let(:user) { create(:user) } + describe 'GET #index' do + let(:user) { create(:user) } + + context 'when username parameter is present' do + it 'renders snippets of a user when username is present' do + get :index, username: user.username + + expect(response).to render_template(:index) + end + end + + context 'when username parameter is not present' do + it 'redirects to explore snippets page when user is not logged in' do + get :index + + expect(response).to redirect_to(explore_snippets_path) + end + + it 'redirects to snippets dashboard page when user is logged in' do + sign_in(user) + + get :index + + expect(response).to redirect_to(dashboard_snippets_path) + end + end + end + describe 'GET #new' do context 'when signed in' do before do @@ -132,7 +160,7 @@ describe SnippetsController do it 'responds with status 404' do get :show, id: 'doesntexist' - expect(response).to have_http_status(404) + expect(response).to redirect_to(new_user_session_path) end end end @@ -478,10 +506,10 @@ describe SnippetsController do end context 'when not signed in' do - it 'responds with status 404' do + it 'redirects to the sign in path' do get :raw, id: 'doesntexist' - expect(response).to have_http_status(404) + expect(response).to redirect_to(new_user_session_path) end end end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 74c5aa44ba9..1d61719f1d0 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -83,7 +83,7 @@ describe UsersController do get :show, username: redirect_route.path expect(response).to redirect_to(user) - expect(controller).to set_flash[:notice].to(/moved/) + expect(controller).to set_flash[:notice].to(user_moved_message(redirect_route, user)) end end @@ -162,7 +162,7 @@ describe UsersController do get :calendar, username: redirect_route.path expect(response).to redirect_to(user_calendar_path(user)) - expect(controller).to set_flash[:notice].to(/moved/) + expect(controller).to set_flash[:notice].to(user_moved_message(redirect_route, user)) end end end @@ -216,7 +216,7 @@ describe UsersController do get :calendar_activities, username: redirect_route.path expect(response).to redirect_to(user_calendar_activities_path(user)) - expect(controller).to set_flash[:notice].to(/moved/) + expect(controller).to set_flash[:notice].to(user_moved_message(redirect_route, user)) end end end @@ -270,7 +270,7 @@ describe UsersController do get :snippets, username: redirect_route.path expect(response).to redirect_to(user_snippets_path(user)) - expect(controller).to set_flash[:notice].to(/moved/) + expect(controller).to set_flash[:notice].to(user_moved_message(redirect_route, user)) end end end @@ -320,4 +320,8 @@ describe UsersController do end end end + + def user_moved_message(redirect_route, user) + "User '#{redirect_route.path}' was moved to '#{user.full_path}'. Please update any links and bookmarks that may still have the old path." + end end diff --git a/spec/factories/ci/variables.rb b/spec/factories/ci/variables.rb index 6653f0bb5c3..c5fba597c1c 100644 --- a/spec/factories/ci/variables.rb +++ b/spec/factories/ci/variables.rb @@ -2,5 +2,7 @@ FactoryGirl.define do factory :ci_variable, class: Ci::Variable do sequence(:key) { |n| "VARIABLE_#{n}" } value 'VARIABLE_VALUE' + + project factory: :empty_project end end diff --git a/spec/factories/services.rb b/spec/factories/services.rb index 62aa71ae8d8..28ddd0da753 100644 --- a/spec/factories/services.rb +++ b/spec/factories/services.rb @@ -22,7 +22,7 @@ FactoryGirl.define do properties({ namespace: 'somepath', api_url: 'https://kubernetes.example.com', - token: 'a' * 40, + token: 'a' * 40 }) end diff --git a/spec/features/admin/admin_uses_repository_checks_spec.rb b/spec/features/admin/admin_uses_repository_checks_spec.rb index 855247de2ea..ab5c42365fe 100644 --- a/spec/features/admin/admin_uses_repository_checks_spec.rb +++ b/spec/features/admin/admin_uses_repository_checks_spec.rb @@ -23,7 +23,7 @@ feature 'Admin uses repository checks', feature: true do project = create(:empty_project) project.update_columns( last_repository_check_failed: true, - last_repository_check_at: Time.now, + last_repository_check_at: Time.now ) visit_admin_project_page(project) diff --git a/spec/features/auto_deploy_spec.rb b/spec/features/auto_deploy_spec.rb index 67b0f006854..6c7423e4922 100644 --- a/spec/features/auto_deploy_spec.rb +++ b/spec/features/auto_deploy_spec.rb @@ -5,14 +5,7 @@ describe 'Auto deploy' do let(:project) { create(:project, :repository) } before do - project.create_kubernetes_service( - active: true, - properties: { - namespace: project.path, - api_url: 'https://kubernetes.example.com', - token: 'a' * 40, - } - ) + create :kubernetes_service, project: project project.team << [user, :master] login_as user end diff --git a/spec/features/boards/issue_ordering_spec.rb b/spec/features/boards/issue_ordering_spec.rb index c50155a6d14..bfa2a72a256 100644 --- a/spec/features/boards/issue_ordering_spec.rb +++ b/spec/features/boards/issue_ordering_spec.rb @@ -38,6 +38,8 @@ describe 'Issue Boards', :feature, :js do it 'moves un-ordered issue to top of list' do drag(from_index: 3, to_index: 0) + wait_for_vue_resource + page.within(first('.board')) do expect(first('.card')).to have_content(issue4.title) end diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index 7c53d2b47d9..1238647d3f3 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -158,13 +158,13 @@ describe 'Issue Boards', feature: true, js: true do end page.within(first('.board')) do - find('.card:nth-child(2)').click + find('.card:nth-child(2)').trigger('click') end page.within('.assignee') do click_link 'Edit' - - expect(page).to have_selector('.is-active') + + expect(find('.dropdown-menu')).to have_selector('.is-active') end end end diff --git a/spec/features/boards/sub_group_project_spec.rb b/spec/features/boards/sub_group_project_spec.rb new file mode 100644 index 00000000000..6cd7fddd288 --- /dev/null +++ b/spec/features/boards/sub_group_project_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +describe 'Sub-group project issue boards', :feature, :js do + include WaitForVueResource + + let(:group) { create(:group) } + let(:nested_group_1) { create(:group, parent: group) } + let(:project) { create(:empty_project, group: nested_group_1) } + let(:board) { create(:board, project: project) } + let(:label) { create(:label, project: project) } + let(:user) { create(:user) } + let!(:list1) { create(:list, board: board, label: label, position: 0) } + let!(:issue) { create(:labeled_issue, project: project, labels: [label]) } + + before do + project.add_master(user) + + login_as(user) + + visit namespace_project_board_path(project.namespace, project, board) + wait_for_vue_resource + end + + it 'creates new label from sidebar' do + find('.card').click + + page.within '.labels' do + click_link 'Edit' + click_link 'Create new label' + end + + page.within '.dropdown-new-label' do + fill_in 'new_label_name', with: 'test label' + first('.suggest-colors-dropdown a').click + + click_button 'Create' + + wait_for_ajax + end + + page.within '.labels' do + expect(page).to have_link 'test label' + end + end +end diff --git a/spec/features/copy_as_gfm_spec.rb b/spec/features/copy_as_gfm_spec.rb index f197fb44608..be615519a09 100644 --- a/spec/features/copy_as_gfm_spec.rb +++ b/spec/features/copy_as_gfm_spec.rb @@ -96,7 +96,7 @@ describe 'Copy as GFM', feature: true, js: true do # issue link "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue)})", # issue link with note anchor - "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123')})", + "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123')})" ) verify( diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb index 7c9d522273b..cbeb73d9cae 100644 --- a/spec/features/cycle_analytics_spec.rb +++ b/spec/features/cycle_analytics_spec.rb @@ -6,14 +6,16 @@ feature 'Cycle Analytics', feature: true, js: true do let(:project) { create(:project, :repository) } let(:issue) { create(:issue, project: project, created_at: 2.days.ago) } let(:milestone) { create(:milestone, project: project) } - let(:mr) { create_merge_request_closing_issue(issue) } + let(:mr) { create_merge_request_closing_issue(issue, commit_message: "References #{issue.to_reference}") } let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha) } context 'as an allowed user' do context 'when project is new' do before do - project.team << [user, :master] + project.add_master(user) + login_as(user) + visit namespace_project_cycle_analytics_path(project.namespace, project) wait_for_ajax end @@ -30,9 +32,10 @@ feature 'Cycle Analytics', feature: true, js: true do context "when there's cycle analytics data" do before do - project.team << [user, :master] - allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue]) + mr.update(head_pipeline: pipeline) + project.add_master(user) + create_cycle deploy_master @@ -85,7 +88,7 @@ feature 'Cycle Analytics', feature: true, js: true do context "as a guest" do before do - project.team << [guest, :guest] + project.add_guest(guest) allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue]) create_cycle diff --git a/spec/features/dashboard/group_spec.rb b/spec/features/dashboard/group_spec.rb index 1d4b86ed4b4..8e20fdec8ad 100644 --- a/spec/features/dashboard/group_spec.rb +++ b/spec/features/dashboard/group_spec.rb @@ -7,7 +7,7 @@ RSpec.describe 'Dashboard Group', feature: true do it 'creates new group', js: true do visit dashboard_groups_path - click_link 'New group' + find('.btn-new').trigger('click') new_path = 'Samurai' new_description = 'Tokugawa Shogunate' diff --git a/spec/features/dashboard/issuables_counter_spec.rb b/spec/features/dashboard/issuables_counter_spec.rb index 6f7bf0eba6e..354267dbee7 100644 --- a/spec/features/dashboard/issuables_counter_spec.rb +++ b/spec/features/dashboard/issuables_counter_spec.rb @@ -19,7 +19,7 @@ describe 'Navigation bar counter', feature: true, caching: true do issue.assignees = [] - user.update_cache_counts + user.invalidate_cache_counts Timecop.travel(3.minutes.from_now) do visit issues_path @@ -35,6 +35,8 @@ describe 'Navigation bar counter', feature: true, caching: true do merge_request.update(assignee: nil) + user.invalidate_cache_counts + Timecop.travel(3.minutes.from_now) do visit merge_requests_path diff --git a/spec/features/dashboard/milestone_filter_spec.rb b/spec/features/dashboard/milestone_filter_spec.rb new file mode 100644 index 00000000000..d60a002a8d7 --- /dev/null +++ b/spec/features/dashboard/milestone_filter_spec.rb @@ -0,0 +1,60 @@ +require 'spec_helper' + +describe 'Dashboard > milestone filter', :feature, :js do + include WaitForAjax + + let(:user) { create(:user) } + let(:project) { create(:project, name: 'test', namespace: user.namespace) } + let(:milestone) { create(:milestone, title: "v1.0", project: project) } + let(:milestone2) { create(:milestone, title: "v2.0", project: project) } + let!(:issue) { create :issue, author: user, project: project, milestone: milestone } + let!(:issue2) { create :issue, author: user, project: project, milestone: milestone2 } + + before do + login_as(user) + visit issues_dashboard_path(author_id: user.id) + end + + context 'default state' do + it 'shows issues with Any Milestone' do + page.all('.issue-info').each do |issue_info| + expect(issue_info.text).to match(/v\d.0/) + end + end + end + + context 'filtering by milestone' do + milestone_select = '.js-milestone-select' + + before do + find(milestone_select).click + wait_for_ajax + + page.within('.dropdown-content') do + click_link 'v1.0' + end + + find(milestone_select).click + wait_for_ajax + end + + it 'shows issues with Milestone v1.0' do + expect(find('.issues-list')).to have_selector('.issue', count: 1) + expect(find('.dropdown-content')).to have_selector('a.is-active', count: 1) + end + + it 'should not change active Milestone unless clicked' do + expect(find('.dropdown-content')).to have_selector('a.is-active', count: 1) + + # open & close dropdown + find('.dropdown-menu-close').click + + expect(find('.milestone-filter')).not_to have_selector('.dropdown.open') + + find(milestone_select).click + + expect(find('.dropdown-content')).to have_selector('a.is-active', count: 1) + expect(find('.dropdown-content a.is-active')).to have_content('v1.0') + end + end +end diff --git a/spec/features/dashboard/shortcuts_spec.rb b/spec/features/dashboard/shortcuts_spec.rb index 4c9adcabe34..349b948eaee 100644 --- a/spec/features/dashboard/shortcuts_spec.rb +++ b/spec/features/dashboard/shortcuts_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature 'Dashboard shortcuts', feature: true, js: true do +feature 'Dashboard shortcuts', :feature, :js do context 'logged in' do before do login_as :user @@ -8,21 +8,21 @@ feature 'Dashboard shortcuts', feature: true, js: true do end scenario 'Navigate to tabs' do - find('body').native.send_keys([:shift, 'P']) - - check_page_title('Projects') - - find('body').native.send_key([:shift, 'I']) + find('body').send_keys([:shift, 'I']) check_page_title('Issues') - find('body').native.send_key([:shift, 'M']) + find('body').send_keys([:shift, 'M']) check_page_title('Merge Requests') - find('body').native.send_keys([:shift, 'T']) + find('body').send_keys([:shift, 'T']) check_page_title('Todos') + + find('body').send_keys([:shift, 'P']) + + check_page_title('Projects') end end @@ -32,17 +32,20 @@ feature 'Dashboard shortcuts', feature: true, js: true do end scenario 'Navigate to tabs' do - find('body').native.send_keys([:shift, 'P']) - - expect(page).to have_content('No projects found') - - find('body').native.send_keys([:shift, 'G']) + find('body').send_keys([:shift, 'G']) + find('.nothing-here-block') expect(page).to have_content('No public groups') - find('body').native.send_keys([:shift, 'S']) + find('body').send_keys([:shift, 'S']) + find('.nothing-here-block') expect(page).to have_selector('.snippets-list-holder') + + find('body').send_keys([:shift, 'P']) + + find('.nothing-here-block') + expect(page).to have_content('No projects found') end end diff --git a/spec/features/dashboard/snippets_spec.rb b/spec/features/dashboard/snippets_spec.rb index 62937688c22..c6ba118220a 100644 --- a/spec/features/dashboard/snippets_spec.rb +++ b/spec/features/dashboard/snippets_spec.rb @@ -12,4 +12,51 @@ describe 'Dashboard snippets', feature: true do it_behaves_like 'paginated snippets' end + + context 'filtering by visibility' do + let(:user) { create(:user) } + let!(:snippets) do + [ + create(:personal_snippet, :public, author: user), + create(:personal_snippet, :internal, author: user), + create(:personal_snippet, :private, author: user), + create(:personal_snippet, :public) + ] + end + + before do + login_as(user) + + visit dashboard_snippets_path + end + + it 'contains all snippets of logged user' do + expect(page).to have_selector('.snippet-row', count: 3) + + expect(page).to have_content(snippets[0].title) + expect(page).to have_content(snippets[1].title) + expect(page).to have_content(snippets[2].title) + end + + it 'contains all private snippets of logged user when clicking on private' do + click_link('Private') + + expect(page).to have_selector('.snippet-row', count: 1) + expect(page).to have_content(snippets[2].title) + end + + it 'contains all internal snippets of logged user when clicking on internal' do + click_link('Internal') + + expect(page).to have_selector('.snippet-row', count: 1) + expect(page).to have_content(snippets[1].title) + end + + it 'contains all public snippets of logged user when clicking on public' do + click_link('Public') + + expect(page).to have_selector('.snippet-row', count: 1) + expect(page).to have_content(snippets[0].title) + end + end end diff --git a/spec/features/groups/members/sorting_spec.rb b/spec/features/groups/members/sorting_spec.rb index 608aedd3471..902d3f789ff 100644 --- a/spec/features/groups/members/sorting_spec.rb +++ b/spec/features/groups/members/sorting_spec.rb @@ -68,7 +68,7 @@ feature 'Groups > Members > Sorting', feature: true do expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, descending') end - scenario 'sorts by recent sign in' do + scenario 'sorts by recent sign in', :redis do visit_members_list(sort: :recent_sign_in) expect(first_member).to include(owner.name) @@ -76,7 +76,7 @@ feature 'Groups > Members > Sorting', feature: true do expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in') end - scenario 'sorts by oldest sign in' do + scenario 'sorts by oldest sign in', :redis do visit_members_list(sort: :oldest_sign_in) expect(first_member).to include(developer.name) diff --git a/spec/features/issuables/issuable_list_spec.rb b/spec/features/issuables/issuable_list_spec.rb index f3ec80bb149..414838fa22e 100644 --- a/spec/features/issuables/issuable_list_spec.rb +++ b/spec/features/issuables/issuable_list_spec.rb @@ -52,6 +52,9 @@ describe 'issuable list', feature: true do create(:issue, project: project, author: user) else create(:merge_request, source_project: project, source_branch: generate(:branch)) + source_branch = FFaker::Name.name + pipeline = create(:ci_empty_pipeline, project: project, ref: source_branch, status: %w(running failed success).sample, sha: 'any') + create(:merge_request, title: FFaker::Lorem.sentence, source_project: project, source_branch: source_branch, head_pipeline: pipeline) end 2.times do diff --git a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb index 58f897cba3e..24e2419b5ce 100644 --- a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb +++ b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb @@ -14,7 +14,7 @@ feature 'Resolving all open discussions in a merge request from an issue', featu end it 'shows a button to resolve all discussions by creating a new issue' do - within('li#resolve-count-app') do + within('#resolve-count-app') do expect(page).to have_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) end end @@ -49,7 +49,7 @@ feature 'Resolving all open discussions in a merge request from an issue', featu end it 'does not show a link to create a new issue' do - expect(page).not_to have_link 'open an issue to resolve them later' + expect(page).not_to have_link 'Create an issue to resolve them later' end end @@ -59,18 +59,18 @@ feature 'Resolving all open discussions in a merge request from an issue', featu end it 'shows a warning that the merge request contains unresolved discussions' do - expect(page).to have_content 'This merge request has unresolved discussions' + expect(page).to have_content 'There are unresolved discussions.' end it 'has a link to resolve all discussions by creating an issue' do page.within '.mr-widget-body' do - expect(page).to have_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) + expect(page).to have_link 'Create an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) end end context 'creating an issue for discussions' do before do - page.click_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) + page.click_link 'Create an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) end it_behaves_like 'creating an issue for a discussion' diff --git a/spec/features/issues/filtered_search/recent_searches_spec.rb b/spec/features/issues/filtered_search/recent_searches_spec.rb index 08fe3b4553b..09f228bcf49 100644 --- a/spec/features/issues/filtered_search/recent_searches_spec.rb +++ b/spec/features/issues/filtered_search/recent_searches_spec.rb @@ -3,17 +3,17 @@ require 'spec_helper' describe 'Recent searches', js: true, feature: true do include FilteredSearchHelpers - let!(:group) { create(:group) } - let!(:project) { create(:project, group: group) } - let!(:user) { create(:user) } + let(:project_1) { create(:empty_project, :public) } + let(:project_2) { create(:empty_project, :public) } + let(:project_1_local_storage_key) { "#{project_1.full_path}-issue-recent-searches" } before do Capybara.ignore_hidden_elements = false - project.add_master(user) - group.add_developer(user) - create(:issue, project: project) - login_as(user) + create(:issue, project: project_1) + create(:issue, project: project_2) + # Visit any fast-loading page so we can clear local storage without a DOM exception + visit '/404' remove_recent_searches end @@ -22,7 +22,7 @@ describe 'Recent searches', js: true, feature: true do end it 'searching adds to recent searches' do - visit namespace_project_issues_path(project.namespace, project) + visit namespace_project_issues_path(project_1.namespace, project_1) input_filtered_search('foo', submit: true) input_filtered_search('bar', submit: true) @@ -35,8 +35,8 @@ describe 'Recent searches', js: true, feature: true do end it 'visiting URL with search params adds to recent searches' do - visit namespace_project_issues_path(project.namespace, project, label_name: 'foo', search: 'bar') - visit namespace_project_issues_path(project.namespace, project, label_name: 'qux', search: 'garply') + visit namespace_project_issues_path(project_1.namespace, project_1, label_name: 'foo', search: 'bar') + visit namespace_project_issues_path(project_1.namespace, project_1, label_name: 'qux', search: 'garply') items = all('.filtered-search-history-dropdown-item', visible: false) @@ -46,9 +46,9 @@ describe 'Recent searches', js: true, feature: true do end it 'saved recent searches are restored last on the list' do - set_recent_searches('["saved1", "saved2"]') + set_recent_searches(project_1_local_storage_key, '["saved1", "saved2"]') - visit namespace_project_issues_path(project.namespace, project, search: 'foo') + visit namespace_project_issues_path(project_1.namespace, project_1, search: 'foo') items = all('.filtered-search-history-dropdown-item', visible: false) @@ -58,9 +58,27 @@ describe 'Recent searches', js: true, feature: true do expect(items[2].text).to eq('saved2') end + it 'searches are scoped to projects' do + visit namespace_project_issues_path(project_1.namespace, project_1) + + input_filtered_search('foo', submit: true) + input_filtered_search('bar', submit: true) + + visit namespace_project_issues_path(project_2.namespace, project_2) + + input_filtered_search('more', submit: true) + input_filtered_search('things', submit: true) + + items = all('.filtered-search-history-dropdown-item', visible: false) + + expect(items.count).to eq(2) + expect(items[0].text).to eq('things') + expect(items[1].text).to eq('more') + end + it 'clicking item fills search input' do - set_recent_searches('["foo", "bar"]') - visit namespace_project_issues_path(project.namespace, project) + set_recent_searches(project_1_local_storage_key, '["foo", "bar"]') + visit namespace_project_issues_path(project_1.namespace, project_1) all('.filtered-search-history-dropdown-item', visible: false)[0].trigger('click') wait_for_filtered_search('foo') @@ -69,8 +87,8 @@ describe 'Recent searches', js: true, feature: true do end it 'clear recent searches button, clears recent searches' do - set_recent_searches('["foo"]') - visit namespace_project_issues_path(project.namespace, project) + set_recent_searches(project_1_local_storage_key, '["foo"]') + visit namespace_project_issues_path(project_1.namespace, project_1) items_before = all('.filtered-search-history-dropdown-item', visible: false) @@ -83,8 +101,8 @@ describe 'Recent searches', js: true, feature: true do end it 'shows flash error when failed to parse saved history' do - set_recent_searches('fail') - visit namespace_project_issues_path(project.namespace, project) + set_recent_searches(project_1_local_storage_key, 'fail') + visit namespace_project_issues_path(project_1.namespace, project_1) expect(find('.flash-alert')).to have_text('An error occured while parsing recent searches') end diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb index 87adce3cddd..095cbb65c16 100644 --- a/spec/features/issues/form_spec.rb +++ b/spec/features/issues/form_spec.rb @@ -1,8 +1,9 @@ require 'rails_helper' -describe 'New/edit issue', feature: true, js: true do +describe 'New/edit issue', :feature, :js do include GitlabRoutingHelper include ActionView::Helpers::JavaScriptHelper + include WaitForAjax let!(:project) { create(:project) } let!(:user) { create(:user)} @@ -26,6 +27,8 @@ describe 'New/edit issue', feature: true, js: true do describe 'multiple assignees' do before do click_button 'Unassigned' + + wait_for_ajax end it 'unselects other assignees when unassigned is selected' do @@ -65,6 +68,9 @@ describe 'New/edit issue', feature: true, js: true do expect(find('a', text: 'Assign to me')).to be_visible click_button 'Unassigned' + + wait_for_ajax + page.within '.dropdown-menu-user' do click_link user2.name end @@ -148,16 +154,15 @@ describe 'New/edit issue', feature: true, js: true do it 'correctly updates the selected user when changing assignee' do click_button 'Unassigned' + + wait_for_ajax + page.within '.dropdown-menu-user' do click_link user.name end expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user.id.to_s) - - click_button user.name - expect(find('.dropdown-menu-user a.is-active').first(:xpath, '..')['data-user-id']).to eq(user.id.to_s) - # check the ::before pseudo element to ensure checkmark icon is present expect(before_for_selector('.dropdown-menu-selectable a.is-active')).not_to eq('') expect(before_for_selector('.dropdown-menu-selectable a:not(.is-active)')).to eq('') @@ -167,9 +172,6 @@ describe 'New/edit issue', feature: true, js: true do end expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user2.id.to_s) - - click_button user2.name - expect(find('.dropdown-menu-user a.is-active').first(:xpath, '..')['data-user-id']).to eq(user2.id.to_s) end end diff --git a/spec/features/issues/notes_on_issues_spec.rb b/spec/features/issues/notes_on_issues_spec.rb new file mode 100644 index 00000000000..a4035324d2b --- /dev/null +++ b/spec/features/issues/notes_on_issues_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +describe 'Create notes on issues', :js, :feature do + let(:user) { create(:user) } + + shared_examples 'notes with reference' do + let(:issue) { create(:issue, project: project) } + let(:note_text) { "Check #{mention.to_reference}" } + + before do + project.team << [user, :developer] + login_as(user) + visit namespace_project_issue_path(project.namespace, project, issue) + + fill_in 'note[note]', with: note_text + click_button 'Comment' + + wait_for_ajax + end + + it 'creates a note with reference and cross references the issue' do + page.within('div#notes li.note div.note-text') do + expect(page).to have_content(note_text) + expect(page.find('a')).to have_content(mention.to_reference) + end + + find('div#notes li.note div.note-text a').click + + page.within('div#notes li.note .system-note-message') do + expect(page).to have_content('mentioned in issue') + expect(page.find('a')).to have_content(issue.to_reference) + end + end + end + + context 'mentioning issue on a private project' do + it_behaves_like 'notes with reference' do + let(:project) { create(:project, :private) } + let(:mention) { create(:issue, project: project) } + end + end + + context 'mentioning issue on an internal project' do + it_behaves_like 'notes with reference' do + let(:project) { create(:project, :internal) } + let(:mention) { create(:issue, project: project) } + end + end + + context 'mentioning issue on a public project' do + it_behaves_like 'notes with reference' do + let(:project) { create(:project, :public) } + let(:mention) { create(:issue, project: project) } + end + end + + context 'mentioning merge request on a private project' do + it_behaves_like 'notes with reference' do + let(:project) { create(:project, :private) } + let(:mention) { create(:merge_request, source_project: project) } + end + end + + context 'mentioning merge request on an internal project' do + it_behaves_like 'notes with reference' do + let(:project) { create(:project, :internal) } + let(:mention) { create(:merge_request, source_project: project) } + end + end + + context 'mentioning merge request on a public project' do + it_behaves_like 'notes with reference' do + let(:project) { create(:project, :public) } + let(:mention) { create(:merge_request, source_project: project) } + end + end +end diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index 5285dda361b..fdd78600a1d 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -573,6 +573,8 @@ describe 'Issues', feature: true do end describe 'new issue' do + let!(:issue) { create(:issue, project: project) } + context 'by unauthenticated user' do before do logout diff --git a/spec/features/login_spec.rb b/spec/features/login_spec.rb index 11d417c253d..c82e8c03343 100644 --- a/spec/features/login_spec.rb +++ b/spec/features/login_spec.rb @@ -41,7 +41,7 @@ feature 'Login', feature: true do expect(page).to have_content('Your account has been blocked.') end - it 'does not update Devise trackable attributes' do + it 'does not update Devise trackable attributes', :redis do user = create(:user, :blocked) expect { login_with(user) }.not_to change { user.reload.sign_in_count } @@ -55,7 +55,7 @@ feature 'Login', feature: true do expect(page).to have_content('Invalid Login or password.') end - it 'does not update Devise trackable attributes' do + it 'does not update Devise trackable attributes', :redis do expect { login_with(User.ghost) }.not_to change { User.ghost.reload.sign_in_count } end end diff --git a/spec/features/merge_requests/assign_issues_spec.rb b/spec/features/merge_requests/assign_issues_spec.rb index ec49003772b..b306e2f5f75 100644 --- a/spec/features/merge_requests/assign_issues_spec.rb +++ b/spec/features/merge_requests/assign_issues_spec.rb @@ -18,7 +18,7 @@ feature 'Merge request issue assignment', js: true, feature: true do end context 'logged in as author' do - scenario 'updates related issues' do + it 'updates related issues' do visit_merge_request click_link "Assign yourself to these issues" diff --git a/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb b/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb index 77b7ba4ac7a..fa306c02a43 100644 --- a/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb +++ b/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb @@ -19,8 +19,8 @@ feature 'Check if mergeable with unresolved discussions', js: true, feature: tru it 'does not allow to merge' do visit_merge_request(merge_request) - expect(page).not_to have_button 'Accept merge request' - expect(page).to have_content('This merge request has unresolved discussions') + expect(page).not_to have_button 'Merge' + expect(page).to have_content('There are unresolved discussions.') end end @@ -32,7 +32,7 @@ feature 'Check if mergeable with unresolved discussions', js: true, feature: tru it 'allows MR to be merged' do visit_merge_request(merge_request) - expect(page).to have_button 'Accept merge request' + expect(page).to have_button 'Merge' end end end @@ -46,7 +46,7 @@ feature 'Check if mergeable with unresolved discussions', js: true, feature: tru it 'does not allow to merge' do visit_merge_request(merge_request) - expect(page).to have_button 'Accept merge request' + expect(page).to have_button 'Merge' end end @@ -58,7 +58,7 @@ feature 'Check if mergeable with unresolved discussions', js: true, feature: tru it 'allows MR to be merged' do visit_merge_request(merge_request) - expect(page).to have_button 'Accept merge request' + expect(page).to have_button 'Merge' end end end diff --git a/spec/features/merge_requests/cherry_pick_spec.rb b/spec/features/merge_requests/cherry_pick_spec.rb index dfe7c910a10..6ba681e36f7 100644 --- a/spec/features/merge_requests/cherry_pick_spec.rb +++ b/spec/features/merge_requests/cherry_pick_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Cherry-pick Merge Requests' do +describe 'Cherry-pick Merge Requests', js: true do let(:user) { create(:user) } let(:group) { create(:group) } let(:project) { create(:project, namespace: group) } diff --git a/spec/features/merge_requests/closes_issues_spec.rb b/spec/features/merge_requests/closes_issues_spec.rb index eafcab6a0d7..ee0880a1e2f 100644 --- a/spec/features/merge_requests/closes_issues_spec.rb +++ b/spec/features/merge_requests/closes_issues_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' -feature 'Merge Request closing issues message', feature: true do +feature 'Merge Request closing issues message', feature: true, js: true do + include WaitForAjax + let(:user) { create(:user) } let(:project) { create(:project, :public) } let(:issue_1) { create(:issue, project: project)} @@ -23,6 +25,7 @@ feature 'Merge Request closing issues message', feature: true do login_as user visit namespace_project_merge_request_path(project.namespace, project, merge_request) + wait_for_ajax end context 'not closing or mentioning any issue' do @@ -35,7 +38,7 @@ feature 'Merge Request closing issues message', feature: true do let(:merge_request_description) { "Description\n\nclosing #{issue_1.to_reference}, #{issue_2.to_reference}" } it 'does not display closing issue message' do - expect(page).to have_content("Accepting this merge request will close issues #{issue_1.to_reference} and #{issue_2.to_reference}") + expect(page).to have_content("Closes issues #{issue_1.to_reference} and #{issue_2.to_reference}") end end @@ -51,7 +54,8 @@ feature 'Merge Request closing issues message', feature: true do let(:merge_request_title) { "closes #{issue_1.to_reference}\n\n refers to #{issue_2.to_reference}" } it 'does not display closing issue message' do - expect(page).to have_content("Accepting this merge request will close issue #{issue_1.to_reference}. Issue #{issue_2.to_reference} is mentioned but will not be closed.") + expect(page).to have_content("Closes issue #{issue_1.to_reference}.") + expect(page).to have_content("Issue #{issue_2.to_reference} is mentioned but will not be closed.") end end @@ -59,7 +63,7 @@ feature 'Merge Request closing issues message', feature: true do let(:merge_request_title) { "closing #{issue_1.to_reference}, #{issue_2.to_reference}" } it 'does not display closing issue message' do - expect(page).to have_content("Accepting this merge request will close issues #{issue_1.to_reference} and #{issue_2.to_reference}") + expect(page).to have_content("Closes issues #{issue_1.to_reference} and #{issue_2.to_reference}") end end @@ -75,7 +79,8 @@ feature 'Merge Request closing issues message', feature: true do let(:merge_request_title) { "closes #{issue_1.to_reference}\n\n refers to #{issue_2.to_reference}" } it 'does not display closing issue message' do - expect(page).to have_content("Accepting this merge request will close issue #{issue_1.to_reference}. Issue #{issue_2.to_reference} is mentioned but will not be closed.") + expect(page).to have_content("Closes issue #{issue_1.to_reference}. Issue #{issue_2.to_reference} is mentioned but will not be closed.") + expect(page).to have_content("Issue #{issue_2.to_reference} is mentioned but will not be closed.") end end end diff --git a/spec/features/merge_requests/conflicts_spec.rb b/spec/features/merge_requests/conflicts_spec.rb index 43977ad2fc5..04b7593ce68 100644 --- a/spec/features/merge_requests/conflicts_spec.rb +++ b/spec/features/merge_requests/conflicts_spec.rb @@ -151,7 +151,7 @@ feature 'Merge request conflict resolution', js: true, feature: true do 'conflict-too-large' => 'when the conflicts contain a large file', 'conflict-binary-file' => 'when the conflicts contain a binary file', 'conflict-missing-side' => 'when the conflicts contain a file edited in one branch and deleted in another', - 'conflict-non-utf8' => 'when the conflicts contain a non-UTF-8 file', + 'conflict-non-utf8' => 'when the conflicts contain a non-UTF-8 file' }.freeze UNRESOLVABLE_CONFLICTS.each do |source_branch, description| diff --git a/spec/features/merge_requests/created_from_fork_spec.rb b/spec/features/merge_requests/created_from_fork_spec.rb index 18833ba7266..bf34c99b92a 100644 --- a/spec/features/merge_requests/created_from_fork_spec.rb +++ b/spec/features/merge_requests/created_from_fork_spec.rb @@ -31,7 +31,7 @@ feature 'Merge request created from fork' do fork_project.destroy! end - scenario 'user can access merge request' do + scenario 'user can access merge request', js: true do visit_merge_request(merge_request) expect(page).to have_content 'Test merge request' diff --git a/spec/features/merge_requests/deleted_source_branch_spec.rb b/spec/features/merge_requests/deleted_source_branch_spec.rb index 648678e2b1a..01e5e4f3a05 100644 --- a/spec/features/merge_requests/deleted_source_branch_spec.rb +++ b/spec/features/merge_requests/deleted_source_branch_spec.rb @@ -20,7 +20,7 @@ describe 'Deleted source branch', feature: true, js: true do it 'shows a message about missing source branch' do expect(page).to have_content( - 'Source branch this-branch-does-not-exist does not exist' + 'Source branch does not exist.' ) end @@ -35,6 +35,6 @@ describe 'Deleted source branch', feature: true, js: true do wait_for_ajax expect(page).to have_selector('.diffs.tab-pane .nothing-here-block') - expect(page).to have_content('Nothing to merge from this-branch-does-not-exist into feature') + expect(page).to have_content('Source branch does not exist.') end end diff --git a/spec/features/merge_requests/diff_notes_resolve_spec.rb b/spec/features/merge_requests/diff_notes_resolve_spec.rb index 0e23c3a8849..4d549f3bdbb 100644 --- a/spec/features/merge_requests/diff_notes_resolve_spec.rb +++ b/spec/features/merge_requests/diff_notes_resolve_spec.rb @@ -275,7 +275,7 @@ feature 'Diff notes resolve', feature: true, js: true do end page.within '.line-resolve-all-container' do - page.find('.discussion-next-btn').click + page.find('.discussion-next-btn').trigger('click') end expect(page.evaluate_script("$('body').scrollTop()")).to be > 0 diff --git a/spec/features/merge_requests/edit_mr_spec.rb b/spec/features/merge_requests/edit_mr_spec.rb index cb3bc392903..ec87a99b3ab 100644 --- a/spec/features/merge_requests/edit_mr_spec.rb +++ b/spec/features/merge_requests/edit_mr_spec.rb @@ -29,18 +29,6 @@ feature 'Edit Merge Request', feature: true do expect(page).to have_content 'Someone edited the merge request the same time you did' end - it 'allows to unselect "Remove source branch"' do - merge_request.update(merge_params: { 'force_remove_source_branch' => '1' }) - expect(merge_request.merge_params['force_remove_source_branch']).to be_truthy - - visit edit_namespace_project_merge_request_path(project.namespace, project, merge_request) - uncheck 'Remove source branch when merge request is accepted' - - click_button 'Save changes' - - expect(page).to have_content 'Remove source branch' - end - it 'should preserve description textarea height', js: true do long_description = %q( Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam ac ornare ligula, ut tempus arcu. Etiam ultricies accumsan dolor vitae faucibus. Donec at elit lacus. Mauris orci ante, aliquam quis lorem eget, convallis faucibus arcu. Aenean at pulvinar lacus. Ut viverra quam massa, molestie ornare tortor dignissim a. Suspendisse tristique pellentesque tellus, id lacinia metus elementum id. Nam tristique, arcu rhoncus faucibus viverra, lacus ipsum sagittis ligula, vitae convallis odio lacus a nibh. Ut tincidunt est purus, ac vestibulum augue maximus in. Suspendisse vel erat et mi ultricies semper. Pellentesque volutpat pellentesque consequat. diff --git a/spec/features/merge_requests/merge_commit_message_toggle_spec.rb b/spec/features/merge_requests/merge_commit_message_toggle_spec.rb index 1bc2a5548dd..221ddb5873c 100644 --- a/spec/features/merge_requests/merge_commit_message_toggle_spec.rb +++ b/spec/features/merge_requests/merge_commit_message_toggle_spec.rb @@ -14,8 +14,6 @@ feature 'Clicking toggle commit message link', feature: true, js: true do ) end let(:textbox) { page.find(:css, '.js-commit-message', visible: false) } - let(:include_link) { page.find(:css, '.js-with-description-link', visible: false) } - let(:do_not_include_link) { page.find(:css, '.js-without-description-link', visible: false) } let(:default_message) do [ "Merge branch 'feature' into 'master'", @@ -40,7 +38,7 @@ feature 'Clicking toggle commit message link', feature: true, js: true do visit namespace_project_merge_request_path(project.namespace, project, merge_request) - expect(textbox).not_to be_visible + expect(page).not_to have_selector('.js-commit-message') click_button "Modify commit message" expect(textbox).to be_visible end @@ -56,19 +54,4 @@ feature 'Clicking toggle commit message link', feature: true, js: true do expect(textbox.value).to eq(default_message) end - - it "toggles link between 'Include description' and 'Don't include description'" do - expect(include_link).to be_visible - expect(do_not_include_link).not_to be_visible - - click_link "Include description in commit message" - - expect(include_link).not_to be_visible - expect(do_not_include_link).to be_visible - - click_link "Don't include description in commit message" - - expect(include_link).to be_visible - expect(do_not_include_link).not_to be_visible - end end diff --git a/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb b/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb index 497240803d4..5820784f8e7 100644 --- a/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb +++ b/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb @@ -4,16 +4,18 @@ feature 'Merge immediately', :feature, :js do let(:user) { create(:user) } let(:project) { create(:project, :public) } - let(:merge_request) do + let!(:merge_request) do create(:merge_request_with_diffs, source_project: project, author: user, - title: 'Bug NS-04') + title: 'Bug NS-04', + head_pipeline: pipeline, + source_branch: pipeline.ref) end let(:pipeline) do create(:ci_pipeline, project: project, - sha: merge_request.diff_head_sha, - ref: merge_request.source_branch) + ref: 'master', + sha: project.repository.commit('master').id) end before { project.team << [user, :master] } @@ -34,7 +36,7 @@ feature 'Merge immediately', :feature, :js do click_link 'Merge immediately' - expect(find('.js-merge-when-pipeline-succeeds-button')).to have_content('Merge in progress') + expect(find('.accept-merge-request.btn-info')).to have_content('Merge in progress') wait_for_ajax end diff --git a/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb b/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb index cd540ca113a..11b6f0c0a64 100644 --- a/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb +++ b/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb @@ -16,7 +16,10 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do ref: merge_request.source_branch) end - before { project.team << [user, :master] } + before do + project.add_master(user) + merge_request.update(head_pipeline_id: pipeline.id) + end context 'when there is active pipeline for merge request' do background do @@ -38,8 +41,8 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do click_button "Merge when pipeline succeeds" expect(page).to have_content "Set by #{user.name} to be merged automatically when the pipeline succeeds." - expect(page).to have_content "The source branch will not be removed." - expect(page).to have_link "Cancel automatic merge" + expect(page).to have_content "The source branch will be removed." + expect(page).to have_selector ".js-cancel-auto-merge" visit_merge_request(merge_request) # Needed to refresh the page expect(page).to have_content /enabled an automatic merge when the pipeline for \h{8} succeeds/i end @@ -93,12 +96,10 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do describe 'enabling Merge when pipeline succeeds via dropdown' do it 'activates the Merge when pipeline succeeds feature' do click_button 'Select merge moment' - within('.js-merge-dropdown') do - click_link 'Merge when pipeline succeeds' - end + click_link 'Merge when pipeline succeeds' expect(page).to have_content "Set by #{user.name} to be merged automatically when the pipeline succeeds." - expect(page).to have_content "The source branch will not be removed." + expect(page).to have_content "The source branch will be removed." expect(page).to have_link "Cancel automatic merge" end end @@ -131,13 +132,6 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do expect(page).to have_content "canceled the automatic merge" end - it "allows the user to remove the source branch" do - expect(page).to have_link "Remove source branch when merged" - - click_link "Remove source branch when merged" - expect(page).to have_content "The source branch will be removed" - end - context 'when pipeline succeeds' do background { build.success } diff --git a/spec/features/merge_requests/mini_pipeline_graph_spec.rb b/spec/features/merge_requests/mini_pipeline_graph_spec.rb index 449a60c1d05..5b2798af32f 100644 --- a/spec/features/merge_requests/mini_pipeline_graph_spec.rb +++ b/spec/features/merge_requests/mini_pipeline_graph_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' feature 'Mini Pipeline Graph', :js, :feature do let(:user) { create(:user) } let(:project) { create(:project, :public) } - let(:merge_request) { create(:merge_request, source_project: project) } + let(:merge_request) { create(:merge_request, source_project: project, head_pipeline: pipeline) } let(:pipeline) { create(:ci_empty_pipeline, project: project, ref: 'master', status: 'running', sha: project.commit.id) } let(:build) { create(:ci_build, pipeline: pipeline, stage: 'test', commands: 'test') } diff --git a/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb b/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb index 4a590e3bf68..cdda0542c51 100644 --- a/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb +++ b/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' -feature 'Only allow merge requests to be merged if the pipeline succeeds', feature: true do +feature 'Only allow merge requests to be merged if the pipeline succeeds', feature: true, js: true do + include WaitForVueResource + let(:merge_request) { create(:merge_request_with_diffs) } let(:project) { merge_request.target_project } @@ -10,15 +12,17 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu project.team << [merge_request.author, :master] end - context 'project does not have CI enabled' do + context 'project does not have CI enabled', js: true do it 'allows MR to be merged' do visit_merge_request(merge_request) - expect(page).to have_button 'Accept merge request' + wait_for_vue_resource + + expect(page).to have_button 'Merge' end end - context 'when project has CI enabled' do + context 'when project has CI enabled', js: true do given!(:pipeline) do create(:ci_empty_pipeline, project: project, @@ -27,6 +31,8 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu status: status) end + before { merge_request.update(head_pipeline: pipeline) } + context 'when merge requests can only be merged if the pipeline succeeds' do before do project.update_attribute(:only_allow_merge_if_pipeline_succeeds, true) @@ -38,6 +44,8 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'does not allow to merge immediately' do visit_merge_request(merge_request) + wait_for_vue_resource + expect(page).to have_button 'Merge when pipeline succeeds' expect(page).not_to have_button 'Select merge moment' end @@ -49,7 +57,9 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'does not allow MR to be merged' do visit_merge_request(merge_request) - expect(page).not_to have_button 'Accept merge request' + wait_for_vue_resource + + expect(page).to have_css('button[disabled="disabled"]', text: 'Merge') expect(page).to have_content('Please retry the job or push a new commit to fix the failure.') end end @@ -60,7 +70,9 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'does not allow MR to be merged' do visit_merge_request(merge_request) - expect(page).not_to have_button 'Accept merge request' + wait_for_vue_resource + + expect(page).not_to have_button 'Merge' expect(page).to have_content('Please retry the job or push a new commit to fix the failure.') end end @@ -71,7 +83,9 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'allows MR to be merged' do visit_merge_request(merge_request) - expect(page).to have_button 'Accept merge request' + wait_for_vue_resource + + expect(page).to have_button 'Merge' end end @@ -81,7 +95,9 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'allows MR to be merged' do visit_merge_request(merge_request) - expect(page).to have_button 'Accept merge request' + wait_for_vue_resource + + expect(page).to have_button 'Merge' end end end @@ -94,9 +110,11 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu context 'when CI is running' do given(:status) { :running } - it 'allows MR to be merged immediately', js: true do + it 'allows MR to be merged immediately' do visit_merge_request(merge_request) + wait_for_vue_resource + expect(page).to have_button 'Merge when pipeline succeeds' click_button 'Select merge moment' @@ -110,7 +128,9 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'allows MR to be merged' do visit_merge_request(merge_request) - expect(page).to have_button 'Accept merge request' + wait_for_vue_resource + + expect(page).to have_button 'Merge' end end @@ -120,7 +140,9 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'allows MR to be merged' do visit_merge_request(merge_request) - expect(page).to have_button 'Accept merge request' + wait_for_vue_resource + + expect(page).to have_button 'Merge' end end end diff --git a/spec/features/merge_requests/target_branch_spec.rb b/spec/features/merge_requests/target_branch_spec.rb index b6134540273..c154cf8ade9 100644 --- a/spec/features/merge_requests/target_branch_spec.rb +++ b/spec/features/merge_requests/target_branch_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Target branch', feature: true do +describe 'Target branch', feature: true, js: true do let(:user) { create(:user) } let(:merge_request) { create(:merge_request) } let(:project) { merge_request.project } @@ -17,11 +17,6 @@ describe 'Target branch', feature: true do project.team << [user, :master] end - it 'shows link to target branch' do - visit path_to_merge_request - expect(page).to have_link('feature', href: namespace_project_commits_path(project.namespace, project, merge_request.target_branch)) - end - context 'when branch was deleted' do before do DeleteBranchService.new(project, user).execute('feature') @@ -30,12 +25,12 @@ describe 'Target branch', feature: true do it 'shows a message about missing target branch' do expect(page).to have_content( - 'Target branch feature does not exist' + 'Target branch does not exist' ) end it 'does not show link to target branch' do - expect(page).not_to have_link('feature') + expect(page).not_to have_selector('.mr-widget-body .js-branch-text a') end end end diff --git a/spec/features/merge_requests/widget_deployments_spec.rb b/spec/features/merge_requests/widget_deployments_spec.rb index 00d191ddf2c..8370499f6ed 100644 --- a/spec/features/merge_requests/widget_deployments_spec.rb +++ b/spec/features/merge_requests/widget_deployments_spec.rb @@ -21,7 +21,7 @@ feature 'Widget Deployments Header', feature: true, js: true do wait_for_ajax expect(page).to have_content("Deployed to #{environment.name}") - expect(find('.ci_widget > span > span')['data-title']).to eq(deployment.created_at.to_time.in_time_zone.to_s(:medium)) + expect(find('.js-deploy-time')['data-title']).to eq(deployment.created_at.to_time.in_time_zone.to_s(:medium)) end context 'with stop action' do @@ -38,11 +38,11 @@ feature 'Widget Deployments Header', feature: true, js: true do end scenario 'does show stop button' do - expect(page).to have_link('Stop environment') + expect(page).to have_button('Stop environment') end scenario 'does start build when stop button clicked' do - click_link('Stop environment') + click_button('Stop environment') expect(page).to have_content('close_app') end @@ -51,7 +51,7 @@ feature 'Widget Deployments Header', feature: true, js: true do given(:role) { :reporter } scenario 'does not show stop button' do - expect(page).not_to have_link('Stop environment') + expect(page).not_to have_button('Stop environment') end end end diff --git a/spec/features/merge_requests/widget_spec.rb b/spec/features/merge_requests/widget_spec.rb index d918181a238..ae799584c0f 100644 --- a/spec/features/merge_requests/widget_spec.rb +++ b/spec/features/merge_requests/widget_spec.rb @@ -30,6 +30,7 @@ describe 'Merge request', :feature, :js do wait_for_ajax expect(page).to have_selector('.accept-merge-request') + expect(find('.accept-merge-request')['disabled']).not_to be(true) end end @@ -51,14 +52,15 @@ describe 'Merge request', :feature, :js do page.within('.mr-widget-heading') do expect(page).to have_content("Deployed to #{environment.name}") - expect(find('.js-environment-link')[:href]).to include(environment.formatted_external_url) + expect(find('.js-deploy-url')[:href]).to include(environment.formatted_external_url) end end it 'shows green accept merge request button' do # Wait for the `ci_status` and `merge_check` requests wait_for_ajax - expect(page).to have_selector('.accept-merge-request.btn-create') + expect(page).to have_selector('.accept-merge-request') + expect(find('.accept-merge-request')['disabled']).not_to be(true) end end @@ -89,6 +91,8 @@ describe 'Merge request', :feature, :js do statuses: [commit_status]) create(:ci_build, :pending, pipeline: pipeline) + merge_request.update(head_pipeline: pipeline) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) end @@ -101,10 +105,15 @@ describe 'Merge request', :feature, :js do context 'when merge request is in the blocked pipeline state' do before do - create(:ci_pipeline, project: project, - sha: merge_request.diff_head_sha, - ref: merge_request.source_branch, - status: :manual) + pipeline = create( + :ci_pipeline, + project: project, + sha: merge_request.diff_head_sha, + ref: merge_request.source_branch, + status: :manual + ) + + merge_request.update(head_pipeline: pipeline) visit namespace_project_merge_request_path(project.namespace, project, @@ -129,13 +138,36 @@ describe 'Merge request', :feature, :js do statuses: [commit_status]) create(:ci_build, :pending, pipeline: pipeline) + merge_request.update(head_pipeline: pipeline) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) end it 'has info button when MWBS button' do # Wait for the `ci_status` and `merge_check` requests wait_for_ajax - expect(page).to have_selector('.merge-when-pipeline-succeeds.btn-info') + expect(page).to have_selector('.accept-merge-request.btn-info') + end + end + + context 'view merge request with MWPS enabled but automatically merge fails' do + before do + merge_request.update( + merge_when_pipeline_succeeds: true, + merge_user: merge_request.author, + merge_error: 'Something went wrong' + ) + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'shows information about the merge error' do + # Wait for the `ci_status` and `merge_check` requests + wait_for_ajax + + page.within('.mr-widget-body') do + expect(page).to have_content('Something went wrong') + end end end @@ -164,11 +196,11 @@ describe 'Merge request', :feature, :js do before do allow_any_instance_of(Repository).to receive(:merge).and_return(false) visit namespace_project_merge_request_path(project.namespace, project, merge_request) - click_button 'Accept merge request' - wait_for_ajax end it 'updates the MR widget' do + click_button 'Merge' + page.within('.mr-widget-body') do expect(page).to have_content('Conflicts detected during merge') end diff --git a/spec/features/profiles/preferences_spec.rb b/spec/features/profiles/preferences_spec.rb index 15c8677fcd3..d368bc4d753 100644 --- a/spec/features/profiles/preferences_spec.rb +++ b/spec/features/profiles/preferences_spec.rb @@ -44,7 +44,7 @@ describe 'Profile > Preferences', feature: true do expect(page.current_path).to eq starred_dashboard_projects_path end - click_link 'Your projects' + find('.shortcuts-activity').trigger('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/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb index 5955623f565..9888624a509 100644 --- a/spec/features/projects/blobs/blob_show_spec.rb +++ b/spec/features/projects/blobs/blob_show_spec.rb @@ -5,13 +5,13 @@ feature 'File blob', :js, feature: true do def visit_blob(path, fragment = nil) visit namespace_project_blob_path(project.namespace, project, File.join('master', path), anchor: fragment) + + wait_for_ajax end context 'Ruby file' do before do visit_blob('files/ruby/popen.rb') - - wait_for_ajax end it 'displays the blob' do @@ -35,8 +35,6 @@ feature 'File blob', :js, feature: true do context 'visiting directly' do before do visit_blob('files/markdown/ruby-style-guide.md') - - wait_for_ajax end it 'displays the blob using the rich viewer' do @@ -104,8 +102,6 @@ feature 'File blob', :js, feature: true do context 'visiting with a line number anchor' do before do visit_blob('files/markdown/ruby-style-guide.md', 'L1') - - wait_for_ajax end it 'displays the blob using the simple viewer' do @@ -148,8 +144,6 @@ feature 'File blob', :js, feature: true do project.update_attribute(:lfs_enabled, true) visit_blob('files/lfs/file.md') - - wait_for_ajax end it 'displays an error' do @@ -198,8 +192,6 @@ feature 'File blob', :js, feature: true do context 'when LFS is disabled on the project' do before do visit_blob('files/lfs/file.md') - - wait_for_ajax end it 'displays the blob' do @@ -235,8 +227,6 @@ feature 'File blob', :js, feature: true do ).execute visit_blob('files/test.pdf') - - wait_for_ajax end it 'displays the blob' do @@ -263,8 +253,6 @@ feature 'File blob', :js, feature: true do project.update_attribute(:lfs_enabled, true) visit_blob('files/lfs/lfs_object.iso') - - wait_for_ajax end it 'displays the blob' do @@ -287,8 +275,6 @@ feature 'File blob', :js, feature: true do context 'when LFS is disabled on the project' do before do visit_blob('files/lfs/lfs_object.iso') - - wait_for_ajax end it 'displays the blob' do @@ -312,8 +298,6 @@ feature 'File blob', :js, feature: true do context 'ZIP file' do before do visit_blob('Gemfile.zip') - - wait_for_ajax end it 'displays the blob' do @@ -348,8 +332,6 @@ feature 'File blob', :js, feature: true do ).execute visit_blob('files/empty.md') - - wait_for_ajax end it 'displays an error' do @@ -369,4 +351,80 @@ feature 'File blob', :js, feature: true do end end end + + context '.gitlab-ci.yml' do + before do + project.add_master(project.creator) + + Files::CreateService.new( + project, + project.creator, + start_branch: 'master', + branch_name: 'master', + commit_message: "Add .gitlab-ci.yml", + file_path: '.gitlab-ci.yml', + file_content: File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) + ).execute + + visit_blob('.gitlab-ci.yml') + end + + it 'displays an auxiliary viewer' do + aggregate_failures do + # shows that configuration is valid + expect(page).to have_content('This GitLab CI configuration is valid.') + + # shows a learn more link + expect(page).to have_link('Learn more') + end + end + end + + context '.gitlab/route-map.yml' do + before do + project.add_master(project.creator) + + Files::CreateService.new( + project, + project.creator, + start_branch: 'master', + branch_name: 'master', + commit_message: "Add .gitlab/route-map.yml", + file_path: '.gitlab/route-map.yml', + file_content: <<-MAP.strip_heredoc + # Team data + - source: 'data/team.yml' + public: 'team/' + MAP + ).execute + + visit_blob('.gitlab/route-map.yml') + end + + it 'displays an auxiliary viewer' do + aggregate_failures do + # shows that map is valid + expect(page).to have_content('This Route Map is valid.') + + # shows a learn more link + expect(page).to have_link('Learn more') + end + end + end + + context 'LICENSE' do + before do + visit_blob('LICENSE') + end + + it 'displays an auxiliary viewer' do + aggregate_failures do + # shows license + expect(page).to have_content('This project is licensed under the MIT License.') + + # shows a learn more link + expect(page).to have_link('Learn more about this license', 'http://choosealicense.com/licenses/mit/') + end + end + end end diff --git a/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb b/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb index cfc782c98ad..c5e0a0f0517 100644 --- a/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb +++ b/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe 'New Branch Ref Dropdown', :js, :feature do let(:user) { create(:user) } let(:project) { create(:project, :public) } - let(:toggle) { find('.create-from .dropdown-toggle') } + let(:toggle) { find('.create-from .dropdown-menu-toggle') } before do project.add_master(user) diff --git a/spec/features/projects/features_visibility_spec.rb b/spec/features/projects/features_visibility_spec.rb index e1781cf320a..4533a6fb144 100644 --- a/spec/features/projects/features_visibility_spec.rb +++ b/spec/features/projects/features_visibility_spec.rb @@ -74,7 +74,7 @@ describe 'Edit Project Settings', feature: true do issues: namespace_project_issues_path(project.namespace, project), wiki: namespace_project_wiki_path(project.namespace, project, :home), snippets: namespace_project_snippets_path(project.namespace, project), - merge_requests: namespace_project_merge_requests_path(project.namespace, project), + merge_requests: namespace_project_merge_requests_path(project.namespace, project) } end diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb index c7a32a65e49..b7ae5f0b925 100644 --- a/spec/features/projects/members/sorting_spec.rb +++ b/spec/features/projects/members/sorting_spec.rb @@ -68,7 +68,7 @@ feature 'Projects > Members > Sorting', feature: true do expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, descending') end - scenario 'sorts by recent sign in' do + scenario 'sorts by recent sign in', :redis do visit_members_list(sort: :recent_sign_in) expect(first_member).to include(master.name) @@ -76,7 +76,7 @@ feature 'Projects > Members > Sorting', feature: true do expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in') end - scenario 'sorts by oldest sign in' do + scenario 'sorts by oldest sign in', :redis do visit_members_list(sort: :oldest_sign_in) expect(first_member).to include(developer.name) diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb index cdac4fe2111..fe9f94db574 100644 --- a/spec/features/projects/pipeline_schedules_spec.rb +++ b/spec/features/projects/pipeline_schedules_spec.rb @@ -6,6 +6,7 @@ feature 'Pipeline Schedules', :feature do let!(:project) { create(:project) } let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project) } + let!(:pipeline) { create(:ci_pipeline, pipeline_schedule: pipeline_schedule) } let(:scope) { nil } let!(:user) { create(:user) } @@ -32,7 +33,7 @@ feature 'Pipeline Schedules', :feature do page.within('.pipeline-schedule-table-row') do expect(page).to have_content('pipeline schedule') expect(page).to have_link('master') - expect(page).to have_content('None') + expect(page).to have_link("##{pipeline.id}") end end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index 8cc96c7b00f..5f82cf2f5e5 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -22,7 +22,7 @@ describe 'Pipelines', :feature, :js do project: project, ref: 'master', status: 'running', - sha: project.commit.id, + sha: project.commit.id ) end diff --git a/spec/features/projects/snippets_spec.rb b/spec/features/projects/snippets_spec.rb index d37e8ed4699..18689c17fe9 100644 --- a/spec/features/projects/snippets_spec.rb +++ b/spec/features/projects/snippets_spec.rb @@ -4,11 +4,27 @@ describe 'Project snippets', feature: true do context 'when the project has snippets' do let(:project) { create(:empty_project, :public) } let!(:snippets) { create_list(:project_snippet, 2, :public, author: project.owner, project: project) } - before do - allow(Snippet).to receive(:default_per_page).and_return(1) - visit namespace_project_snippets_path(project.namespace, project) + let!(:other_snippet) { create(:project_snippet) } + + context 'pagination' do + before do + allow(Snippet).to receive(:default_per_page).and_return(1) + + visit namespace_project_snippets_path(project.namespace, project) + end + + it_behaves_like 'paginated snippets' end - it_behaves_like 'paginated snippets' + context 'list content' do + it 'contains all project snippets' do + visit namespace_project_snippets_path(project.namespace, project) + + expect(page).to have_selector('.snippet-row', count: 2) + + expect(page).to have_content(snippets[0].title) + expect(page).to have_content(snippets[1].title) + end + end end end diff --git a/spec/features/projects/wiki/markdown_preview_spec.rb b/spec/features/projects/wiki/markdown_preview_spec.rb index 43d8b45669e..49d7ef09e64 100644 --- a/spec/features/projects/wiki/markdown_preview_spec.rb +++ b/spec/features/projects/wiki/markdown_preview_spec.rb @@ -17,14 +17,14 @@ feature 'Projects > Wiki > User previews markdown changes', feature: true, js: t login_as(user) visit namespace_project_path(project.namespace, project) - click_link 'Wiki' + find('.shortcuts-wiki').trigger('click') WikiPages::CreateService.new(project, user, title: 'home', content: 'Home page').execute 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 - click_link 'New page' + find('.add-new-wiki').trigger('click') page.within '#modal-new-wiki' do fill_in :new_wiki_path, with: 'a/b/c/d' click_button 'Create page' @@ -73,7 +73,7 @@ feature 'Projects > Wiki > User previews markdown changes', feature: true, js: t fill_in :new_wiki_path, with: 'a-page/b-page/c-page/d-page' click_button 'Create page' end - + page.within '.wiki-form' do fill_in :wiki_content, with: wiki_content click_on "Preview" @@ -91,7 +91,7 @@ feature 'Projects > Wiki > User previews markdown changes', feature: true, js: t context "while editing a wiki page" do def create_wiki_page(path) - click_link 'New page' + find('.add-new-wiki').trigger('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 1ffac8cd542..5c502ce4fb5 100644 --- a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature 'Projects > Wiki > User creates wiki page', feature: true do +feature 'Projects > Wiki > User creates wiki page', js: true, feature: true do let(:user) { create(:user) } background do @@ -8,7 +8,7 @@ feature 'Projects > Wiki > User creates wiki page', feature: true do login_as(user) visit namespace_project_path(project.namespace, project) - click_link 'Wiki' + find('.shortcuts-wiki').trigger('click') end context 'in the user namespace' do diff --git a/spec/features/protected_branches/access_control_ce_spec.rb b/spec/features/protected_branches/access_control_ce_spec.rb index d30e7947106..7fda4ade665 100644 --- a/spec/features/protected_branches/access_control_ce_spec.rb +++ b/spec/features/protected_branches/access_control_ce_spec.rb @@ -31,7 +31,7 @@ RSpec.shared_examples "protected branches > access control > CE" do within(".protected-branches-list") do find(".js-allowed-to-push").click - + within('.js-allowed-to-push-container') do expect(first("li")).to have_content("Roles") click_on access_type_name diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index 498a4a5cba0..2fda7758407 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -20,13 +20,14 @@ describe "Search", feature: true do context 'search filters', js: true do let(:group) { create(:group) } + let!(:group_project) { create(:empty_project, group: group) } before do group.add_owner(user) end it 'shows group name after filtering' do - find('.js-search-group-dropdown').click + find('.js-search-group-dropdown').trigger('click') wait_for_ajax page.within '.search-holder' do @@ -36,9 +37,27 @@ describe "Search", feature: true do expect(find('.js-search-group-dropdown')).to have_content(group.name) end + it 'filters by group projects after filtering by group' do + find('.js-search-group-dropdown').trigger('click') + wait_for_ajax + + page.within '.search-holder' do + click_link group.name + end + + expect(find('.js-search-group-dropdown')).to have_content(group.name) + + page.within('.project-filter') do + find('.js-search-project-dropdown').trigger('click') + wait_for_ajax + + expect(page).to have_link(group_project.name_with_namespace) + end + end + it 'shows project name after filtering' do page.within('.project-filter') do - find('.js-search-project-dropdown').click + find('.js-search-project-dropdown').trigger('click') wait_for_ajax click_link project.name_with_namespace diff --git a/spec/features/snippets/explore_spec.rb b/spec/features/snippets/explore_spec.rb index 10a4597e467..fd097fe2e74 100644 --- a/spec/features/snippets/explore_spec.rb +++ b/spec/features/snippets/explore_spec.rb @@ -1,11 +1,11 @@ require 'rails_helper' feature 'Explore Snippets', feature: true do - scenario 'User should see snippets that are not private' do - public_snippet = create(:personal_snippet, :public) - internal_snippet = create(:personal_snippet, :internal) - private_snippet = create(:personal_snippet, :private) + let!(:public_snippet) { create(:personal_snippet, :public) } + let!(:internal_snippet) { create(:personal_snippet, :internal) } + let!(:private_snippet) { create(:personal_snippet, :private) } + scenario 'User should see snippets that are not private' do login_as create(:user) visit explore_snippets_path @@ -13,4 +13,21 @@ feature 'Explore Snippets', feature: true do expect(page).to have_content(internal_snippet.title) expect(page).not_to have_content(private_snippet.title) end + + scenario 'External user should see only public snippets' do + login_as create(:user, :external) + visit explore_snippets_path + + expect(page).to have_content(public_snippet.title) + expect(page).not_to have_content(internal_snippet.title) + expect(page).not_to have_content(private_snippet.title) + end + + scenario 'Not authenticated user should see only public snippets' do + visit explore_snippets_path + + expect(page).to have_content(public_snippet.title) + expect(page).not_to have_content(internal_snippet.title) + expect(page).not_to have_content(private_snippet.title) + end end diff --git a/spec/features/snippets/internal_snippet_spec.rb b/spec/features/snippets/internal_snippet_spec.rb new file mode 100644 index 00000000000..93382f4c359 --- /dev/null +++ b/spec/features/snippets/internal_snippet_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +feature 'Internal Snippets', feature: true, js: true do + let(:internal_snippet) { create(:personal_snippet, :internal) } + + describe 'normal user' do + before do + login_as :user + end + + scenario 'sees internal snippets' do + visit snippet_path(internal_snippet) + + expect(page).to have_content(internal_snippet.content) + end + + scenario 'sees raw internal snippets' do + visit raw_snippet_path(internal_snippet) + + expect(page).to have_content(internal_snippet.content) + end + end +end diff --git a/spec/features/snippets/notes_on_personal_snippets_spec.rb b/spec/features/snippets/notes_on_personal_snippets_spec.rb index 957baac02eb..698eb46573f 100644 --- a/spec/features/snippets/notes_on_personal_snippets_spec.rb +++ b/spec/features/snippets/notes_on_personal_snippets_spec.rb @@ -78,9 +78,11 @@ describe 'Comments on personal snippets', :js, feature: true do end page.within("#notes-list li#note_#{snippet_notes[0].id}") do + edited_text = find('.edited-text') + expect(page).to have_css('.note_edited_ago') expect(page).to have_content('new content') - expect(find('.note_edited_ago').text).to match(/less than a minute ago/) + expect(edited_text).to have_selector('.note_edited_ago') end end end diff --git a/spec/features/tags/master_creates_tag_spec.rb b/spec/features/tags/master_creates_tag_spec.rb index ca25c696f75..af25eebed13 100644 --- a/spec/features/tags/master_creates_tag_spec.rb +++ b/spec/features/tags/master_creates_tag_spec.rb @@ -51,10 +51,24 @@ feature 'Master creates tag', feature: true do end end + scenario 'opens dropdown for ref', js: true do + click_link 'New tag' + ref_row = find('.form-group:nth-of-type(2) .col-sm-10') + page.within ref_row do + ref_input = find('[name="ref"]', visible: false) + expect(ref_input.value).to eq 'master' + expect(find('.dropdown-toggle-text')).to have_content 'master' + + find('.js-branch-select').trigger('click') + + expect(find('.dropdown-menu')).to have_content 'empty-branch' + end + end + def create_tag_in_form(tag:, ref:, message: nil, desc: nil) click_link 'New tag' fill_in 'tag_name', with: tag - fill_in 'ref', with: ref + find('#ref', visible: false).set(ref) fill_in 'message', with: message unless message.nil? fill_in 'release_description', with: desc unless desc.nil? click_button 'Create tag' diff --git a/spec/features/todos/todos_spec.rb b/spec/features/todos/todos_spec.rb index be5b3af417f..55b3e3d9424 100644 --- a/spec/features/todos/todos_spec.rb +++ b/spec/features/todos/todos_spec.rb @@ -251,7 +251,7 @@ describe 'Dashboard Todos', feature: true do describe 'mark all as done', js: true do before do visit dashboard_todos_path - click_link 'Mark all as done' + find('.js-todos-mark-all').trigger('click') end it 'shows "All done" message!' do @@ -308,9 +308,9 @@ describe 'Dashboard Todos', feature: true do end def mark_all_and_undo - click_link 'Mark all as done' + find('.js-todos-mark-all').trigger('click') wait_for_ajax - click_link 'Undo mark all as done' + find('.js-todos-undo-all').trigger('click') wait_for_ajax end end diff --git a/spec/features/user_callout_spec.rb b/spec/features/user_callout_spec.rb index 848af5e3a4d..b84f834ff1e 100644 --- a/spec/features/user_callout_spec.rb +++ b/spec/features/user_callout_spec.rb @@ -20,7 +20,7 @@ describe 'User Callouts', js: true do visit dashboard_projects_path within('.user-callout') do - find('.close').click + find('.close').trigger('click') end visit dashboard_projects_path diff --git a/spec/features/users/snippets_spec.rb b/spec/features/users/snippets_spec.rb index 1546a06b80c..4efbd672322 100644 --- a/spec/features/users/snippets_spec.rb +++ b/spec/features/users/snippets_spec.rb @@ -3,14 +3,46 @@ require 'spec_helper' describe 'Snippets tab on a user profile', feature: true, js: true do context 'when the user has snippets' do let(:user) { create(:user) } - let!(:snippets) { create_list(:snippet, 2, :public, author: user) } - before do - allow(Snippet).to receive(:default_per_page).and_return(1) - visit user_path(user) - page.within('.user-profile-nav') { click_link 'Snippets' } - wait_for_ajax + + context 'pagination' do + let!(:snippets) { create_list(:snippet, 2, :public, author: user) } + + before do + allow(Snippet).to receive(:default_per_page).and_return(1) + visit user_path(user) + page.within('.user-profile-nav') { click_link 'Snippets' } + wait_for_ajax + end + + it_behaves_like 'paginated snippets', remote: true end - it_behaves_like 'paginated snippets', remote: true + context 'list content' do + let!(:public_snippet) { create(:snippet, :public, author: user) } + let!(:internal_snippet) { create(:snippet, :internal, author: user) } + let!(:private_snippet) { create(:snippet, :private, author: user) } + let!(:other_snippet) { create(:snippet, :public) } + + it 'contains only internal and public snippets of a user when a user is logged in' do + login_as(:user) + visit user_path(user) + page.within('.user-profile-nav') { click_link 'Snippets' } + wait_for_ajax + + expect(page).to have_selector('.snippet-row', count: 2) + + expect(page).to have_content(public_snippet.title) + expect(page).to have_content(internal_snippet.title) + end + + it 'contains only public snippets of a user when a user is not logged in' do + visit user_path(user) + page.within('.user-profile-nav') { click_link 'Snippets' } + wait_for_ajax + + expect(page).to have_selector('.snippet-row', count: 1) + expect(page).to have_content(public_snippet.title) + end + end end end diff --git a/spec/finders/groups_finder_spec.rb b/spec/finders/groups_finder_spec.rb index d5d111e8d15..5b3591550c1 100644 --- a/spec/finders/groups_finder_spec.rb +++ b/spec/finders/groups_finder_spec.rb @@ -3,29 +3,64 @@ require 'spec_helper' describe GroupsFinder do describe '#execute' do let(:user) { create(:user) } - let!(:private_group) { create(:group, :private) } - let!(:internal_group) { create(:group, :internal) } - let!(:public_group) { create(:group, :public) } - let(:finder) { described_class.new } - describe 'execute' do - describe 'without a user' do - subject { finder.execute } + context 'root level groups' do + let!(:private_group) { create(:group, :private) } + let!(:internal_group) { create(:group, :internal) } + let!(:public_group) { create(:group, :public) } + + context 'without a user' do + subject { described_class.new.execute } it { is_expected.to eq([public_group]) } end - describe 'with a user' do - subject { finder.execute(user) } + context 'with a user' do + subject { described_class.new(user).execute } context 'normal user' do - it { is_expected.to eq([public_group, internal_group]) } + it { is_expected.to contain_exactly(public_group, internal_group) } end context 'external user' do let(:user) { create(:user, external: true) } - it { is_expected.to eq([public_group]) } + it { is_expected.to contain_exactly(public_group) } + end + + context 'user is member of the private group' do + before do + private_group.add_guest(user) + end + + it { is_expected.to contain_exactly(public_group, internal_group, private_group) } + end + end + end + + context 'subgroups' do + let!(:parent_group) { create(:group, :public) } + let!(:public_subgroup) { create(:group, :public, parent: parent_group) } + let!(:internal_subgroup) { create(:group, :internal, parent: parent_group) } + let!(:private_subgroup) { create(:group, :private, parent: parent_group) } + + context 'without a user' do + it 'only returns public subgroups' do + expect(described_class.new(nil, parent: parent_group).execute).to contain_exactly(public_subgroup) + end + end + + context 'with a user' do + it 'returns public and internal subgroups' do + expect(described_class.new(user, parent: parent_group).execute).to contain_exactly(public_subgroup, internal_subgroup) + end + + context 'being member' do + it 'returns public subgroups, internal subgroups, and private subgroups user is member of' do + private_subgroup.add_guest(user) + + expect(described_class.new(user, parent: parent_group).execute).to contain_exactly(public_subgroup, internal_subgroup, private_subgroup) + end end end end diff --git a/spec/finders/snippets_finder_spec.rb b/spec/finders/snippets_finder_spec.rb index cb6c80d1bd0..35f1683eef9 100644 --- a/spec/finders/snippets_finder_spec.rb +++ b/spec/finders/snippets_finder_spec.rb @@ -8,79 +8,145 @@ describe SnippetsFinder do let(:project1) { create(:empty_project, :public, group: group) } let(:project2) { create(:empty_project, :private, group: group) } - context ':all filter' do + context 'all snippets visible to a user' do let!(:snippet1) { create(:personal_snippet, :private) } let!(:snippet2) { create(:personal_snippet, :internal) } let!(:snippet3) { create(:personal_snippet, :public) } + let!(:project_snippet1) { create(:project_snippet, :private) } + let!(:project_snippet2) { create(:project_snippet, :internal) } + let!(:project_snippet3) { create(:project_snippet, :public) } it "returns all private and internal snippets" do - snippets = described_class.new.execute(user, filter: :all) - expect(snippets).to include(snippet2, snippet3) - expect(snippets).not_to include(snippet1) + snippets = described_class.new(user, scope: :all).execute + expect(snippets).to include(snippet2, snippet3, project_snippet2, project_snippet3) + expect(snippets).not_to include(snippet1, project_snippet1) end it "returns all public snippets" do - snippets = described_class.new.execute(nil, filter: :all) - expect(snippets).to include(snippet3) - expect(snippets).not_to include(snippet1, snippet2) + snippets = described_class.new(nil, scope: :all).execute + expect(snippets).to include(snippet3, project_snippet3) + expect(snippets).not_to include(snippet1, snippet2, project_snippet1, project_snippet2) + end + + it "returns all public and internal snippets for normal user" do + snippets = described_class.new(user).execute + + expect(snippets).to include(snippet2, snippet3, project_snippet2, project_snippet3) + expect(snippets).not_to include(snippet1, project_snippet1) + end + + it "returns all public snippets for non authorized user" do + snippets = described_class.new(nil).execute + + expect(snippets).to include(snippet3, project_snippet3) + expect(snippets).not_to include(snippet1, snippet2, project_snippet1, project_snippet2) + end + + it "returns all public and authored snippets for external user" do + external_user = create(:user, :external) + authored_snippet = create(:personal_snippet, :internal, author: external_user) + + snippets = described_class.new(external_user).execute + + expect(snippets).to include(snippet3, project_snippet3, authored_snippet) + expect(snippets).not_to include(snippet1, snippet2, project_snippet1, project_snippet2) end end - context ':public filter' do + context 'filter by visibility' do let!(:snippet1) { create(:personal_snippet, :private) } let!(:snippet2) { create(:personal_snippet, :internal) } let!(:snippet3) { create(:personal_snippet, :public) } - it "returns public public snippets" do - snippets = described_class.new.execute(nil, filter: :public) + it "returns public snippets when visibility is PUBLIC" do + snippets = described_class.new(nil, visibility: Snippet::PUBLIC).execute expect(snippets).to include(snippet3) expect(snippets).not_to include(snippet1, snippet2) end end - context ':by_user filter' do + context 'filter by scope' do + let!(:snippet1) { create(:personal_snippet, :private, author: user) } + let!(:snippet2) { create(:personal_snippet, :internal, author: user) } + let!(:snippet3) { create(:personal_snippet, :public, author: user) } + + it "returns all snippets for 'all' scope" do + snippets = described_class.new(user, scope: :all).execute + + expect(snippets).to include(snippet1, snippet2, snippet3) + end + + it "returns all snippets for 'are_private' scope" do + snippets = described_class.new(user, scope: :are_private).execute + + expect(snippets).to include(snippet1) + expect(snippets).not_to include(snippet2, snippet3) + end + + it "returns all snippets for 'are_interna;' scope" do + snippets = described_class.new(user, scope: :are_internal).execute + + expect(snippets).to include(snippet2) + expect(snippets).not_to include(snippet1, snippet3) + end + + it "returns all snippets for 'are_private' scope" do + snippets = described_class.new(user, scope: :are_public).execute + + expect(snippets).to include(snippet3) + expect(snippets).not_to include(snippet1, snippet2) + end + end + + context 'filter by author' do let!(:snippet1) { create(:personal_snippet, :private, author: user) } let!(:snippet2) { create(:personal_snippet, :internal, author: user) } let!(:snippet3) { create(:personal_snippet, :public, author: user) } it "returns all public and internal snippets" do - snippets = described_class.new.execute(user1, filter: :by_user, user: user) + snippets = described_class.new(user1, author: user).execute + expect(snippets).to include(snippet2, snippet3) expect(snippets).not_to include(snippet1) end it "returns internal snippets" do - snippets = described_class.new.execute(user, filter: :by_user, user: user, scope: "are_internal") + snippets = described_class.new(user, author: user, visibility: Snippet::INTERNAL).execute + expect(snippets).to include(snippet2) expect(snippets).not_to include(snippet1, snippet3) end it "returns private snippets" do - snippets = described_class.new.execute(user, filter: :by_user, user: user, scope: "are_private") + snippets = described_class.new(user, author: user, visibility: Snippet::PRIVATE).execute + expect(snippets).to include(snippet1) expect(snippets).not_to include(snippet2, snippet3) end it "returns public snippets" do - snippets = described_class.new.execute(user, filter: :by_user, user: user, scope: "are_public") + snippets = described_class.new(user, author: user, visibility: Snippet::PUBLIC).execute + expect(snippets).to include(snippet3) expect(snippets).not_to include(snippet1, snippet2) end it "returns all snippets" do - snippets = described_class.new.execute(user, filter: :by_user, user: user) + snippets = described_class.new(user, author: user).execute + expect(snippets).to include(snippet1, snippet2, snippet3) end it "returns only public snippets if unauthenticated user" do - snippets = described_class.new.execute(nil, filter: :by_user, user: user) + snippets = described_class.new(nil, author: user).execute + expect(snippets).to include(snippet3) expect(snippets).not_to include(snippet2, snippet1) end end - context 'by_project filter' do + context 'filter by project' do before do @snippet1 = create(:project_snippet, :private, project: project1) @snippet2 = create(:project_snippet, :internal, project: project1) @@ -88,43 +154,52 @@ describe SnippetsFinder do end it "returns public snippets for unauthorized user" do - snippets = described_class.new.execute(nil, filter: :by_project, project: project1) + snippets = described_class.new(nil, project: project1).execute + expect(snippets).to include(@snippet3) expect(snippets).not_to include(@snippet1, @snippet2) end it "returns public and internal snippets for non project members" do - snippets = described_class.new.execute(user, filter: :by_project, project: project1) + snippets = described_class.new(user, project: project1).execute + expect(snippets).to include(@snippet2, @snippet3) expect(snippets).not_to include(@snippet1) end it "returns public snippets for non project members" do - snippets = described_class.new.execute(user, filter: :by_project, project: project1, scope: "are_public") + snippets = described_class.new(user, project: project1, visibility: Snippet::PUBLIC).execute + expect(snippets).to include(@snippet3) expect(snippets).not_to include(@snippet1, @snippet2) end it "returns internal snippets for non project members" do - snippets = described_class.new.execute(user, filter: :by_project, project: project1, scope: "are_internal") + snippets = described_class.new(user, project: project1, visibility: Snippet::INTERNAL).execute + expect(snippets).to include(@snippet2) expect(snippets).not_to include(@snippet1, @snippet3) end it "does not return private snippets for non project members" do - snippets = described_class.new.execute(user, filter: :by_project, project: project1, scope: "are_private") + snippets = described_class.new(user, project: project1, visibility: Snippet::PRIVATE).execute + expect(snippets).not_to include(@snippet1, @snippet2, @snippet3) end it "returns all snippets for project members" do project1.team << [user, :developer] - snippets = described_class.new.execute(user, filter: :by_project, project: project1) + + snippets = described_class.new(user, project: project1).execute + expect(snippets).to include(@snippet1, @snippet2, @snippet3) end it "returns private snippets for project members" do project1.team << [user, :developer] - snippets = described_class.new.execute(user, filter: :by_project, project: project1, scope: "are_private") + + snippets = described_class.new(user, project: project1, visibility: Snippet::PRIVATE).execute + expect(snippets).to include(@snippet1) end end diff --git a/spec/finders/users_finder_spec.rb b/spec/finders/users_finder_spec.rb new file mode 100644 index 00000000000..780b309b45e --- /dev/null +++ b/spec/finders/users_finder_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +describe UsersFinder do + describe '#execute' do + let!(:user1) { create(:user, username: 'johndoe') } + let!(:user2) { create(:user, :blocked, username: 'notsorandom') } + let!(:external_user) { create(:user, :external) } + let!(:omniauth_user) { create(:omniauth_user, provider: 'twitter', extern_uid: '123456') } + + context 'with a normal user' do + let(:user) { create(:user) } + + it 'returns all users' do + users = described_class.new(user).execute + + expect(users).to contain_exactly(user, user1, user2, omniauth_user) + end + + it 'filters by username' do + users = described_class.new(user, username: 'johndoe').execute + + expect(users).to contain_exactly(user1) + end + + it 'filters by search' do + users = described_class.new(user, search: 'orando').execute + + expect(users).to contain_exactly(user2) + end + + it 'filters by blocked users' do + users = described_class.new(user, blocked: true).execute + + expect(users).to contain_exactly(user2) + end + + it 'filters by active users' do + users = described_class.new(user, active: true).execute + + expect(users).to contain_exactly(user, user1, omniauth_user) + end + + it 'returns no external users' do + users = described_class.new(user, external: true).execute + + expect(users).to contain_exactly(user, user1, user2, omniauth_user) + end + end + + context 'with an admin user' do + let(:admin) { create(:admin) } + + it 'filters by external users' do + users = described_class.new(admin, external: true).execute + + expect(users).to contain_exactly(external_user) + end + + it 'returns all users' do + users = described_class.new(admin).execute + + expect(users).to contain_exactly(admin, user1, user2, external_user, omniauth_user) + end + end + end +end diff --git a/spec/fixtures/api/schemas/entities/merge_request.json b/spec/fixtures/api/schemas/entities/merge_request.json new file mode 100644 index 00000000000..0a7e0e2d5f2 --- /dev/null +++ b/spec/fixtures/api/schemas/entities/merge_request.json @@ -0,0 +1,98 @@ +{ + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "assignee_id": { "type": ["integer", "null"] }, + "author_id": { "type": "integer" }, + "description": { "type": ["string", "null"] }, + "lock_version": { "type": ["string", "null"] }, + "milestone_id": { "type": ["string", "null"] }, + "position": { "type": "integer" }, + "state": { "type": "string" }, + "title": { "type": "string" }, + "updated_by_id": { "type": ["string", "null"] }, + "created_at": { "type": "string" }, + "updated_at": { "type": "string" }, + "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"] }, + "in_progress_merge_commit_sha": { "type": ["string", "null"] }, + "locked_at": { "type": ["string", "null"] }, + "merge_error": { "type": ["string", "null"] }, + "merge_commit_sha": { "type": ["string", "null"] }, + "merge_params": { "type": ["object", "null"] }, + "merge_status": { "type": "string" }, + "merge_user_id": { "type": ["integer", "null"] }, + "merge_when_pipeline_succeeds": { "type": "boolean" }, + "source_branch": { "type": "string" }, + "source_project_id": { "type": "integer" }, + "target_branch": { "type": "string" }, + "target_project_id": { "type": "integer" }, + "merge_event": { "type": ["object", "null"] }, + "closed_event": { "type": ["object", "null"] }, + "author": { "type": ["object", "null"] }, + "merge_user": { "type": ["object", "null"] }, + "diff_head_sha": { "type": ["string", "null"] }, + "diff_head_commit_short_id": { "type": ["string", "null"] }, + "merge_commit_message": { "type": ["string", "null"] }, + "pipeline": { "type": ["object", "null"] }, + "work_in_progress": { "type": "boolean" }, + "source_branch_exists": { "type": "boolean" }, + "mergeable_discussions_state": { "type": "boolean" }, + "conflicts_can_be_resolved_in_ui": { "type": "boolean" }, + "branch_missing": { "type": "boolean" }, + "has_conflicts": { "type": "boolean" }, + "can_be_merged": { "type": "boolean" }, + "project_archived": { "type": "boolean" }, + "only_allow_merge_if_pipeline_succeeds": { "type": "boolean" }, + "has_ci": { "type": "boolean" }, + "ci_status": { "type": ["string", "null"] }, + "issues_links": { + "type": "object", + "required": ["closing", "mentioned_but_not_closing", "assign_to_closing"], + "properties" : { + "closing": { "type": "string" }, + "mentioned_but_not_closing": { "type": "string" }, + "assign_to_closing": { "type": ["string", "null"] } + }, + "additionalProperties": false + }, + "source_branch_with_namespace_link": { "type": "string" }, + "current_user": { + "type": "object", + "required": [ + "can_remove_source_branch", + "can_revert_on_current_merge_request", + "can_cherry_pick_on_current_merge_request" + ], + "properties": { + "can_remove_source_branch": { "type": "boolean" }, + "can_revert_on_current_merge_request": { "type": ["boolean", "null"] }, + "can_cherry_pick_on_current_merge_request": { "type": ["boolean", "null"] } + }, + "additionalProperties": false + }, + "target_branch_commits_path": { "type": "string" }, + "source_branch_path": { "type": "string" }, + "conflict_resolution_path": { "type": ["string", "null"] }, + "cancel_merge_when_pipeline_succeeds_path": { "type": "string" }, + "create_issue_to_resolve_discussions_path": { "type": "string" }, + "merge_path": { "type": "string" }, + "cherry_pick_in_fork_path": { "type": ["string", "null"] }, + "revert_in_fork_path": { "type": ["string", "null"] }, + "email_patches_path": { "type": "string" }, + "plain_diff_path": { "type": "string" }, + "status_path": { "type": "string" }, + "merge_check_path": { "type": "string" }, + "ci_environments_status_path": { "type": "string" }, + "merge_commit_message_with_description": { "type": "string" }, + "diverged_commits_count": { "type": "integer" }, + "commit_change_content_path": { "type": "string" }, + "remove_wip_path": { "type": "string" }, + "commits_count": { "type": "integer" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/entities/merge_request_basic.json b/spec/fixtures/api/schemas/entities/merge_request_basic.json new file mode 100644 index 00000000000..ea6364b878c --- /dev/null +++ b/spec/fixtures/api/schemas/entities/merge_request_basic.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties" : { + "state": { "type": "string" }, + "merge_status": { "type": "string" }, + "source_branch_exists": { "type": "boolean" }, + "time_estimate": { "type": "integer" }, + "total_time_spent": { "type": "integer" }, + "human_time_estimate": { "type": ["string", "null"] }, + "human_total_time_spent": { "type": ["string", "null"] }, + "merge_error": { "type": ["string", "null"] } + }, + "additionalProperties": false +} diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 01bdf01ad22..785fb724132 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe ApplicationHelper do include UploadHelpers + let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" } + describe 'current_controller?' do it 'returns true when controller matches argument' do stub_controller_name('foo') @@ -56,8 +58,14 @@ describe ApplicationHelper do describe 'project_icon' do it 'returns an url for the avatar' do project = create(:empty_project, avatar: File.open(uploaded_image_temp_path)) + avatar_url = "/uploads/project/avatar/#{project.id}/banana_sample.gif" + + expect(helper.project_icon(project.full_path).to_s). + to eq "<img src=\"#{avatar_url}\" alt=\"Banana sample\" />" + + allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host) + avatar_url = "#{gitlab_host}/uploads/project/avatar/#{project.id}/banana_sample.gif" - avatar_url = "http://#{Gitlab.config.gitlab.host}/uploads/project/avatar/#{project.id}/banana_sample.gif" expect(helper.project_icon(project.full_path).to_s). to eq "<img src=\"#{avatar_url}\" alt=\"Banana sample\" />" end @@ -67,9 +75,8 @@ describe ApplicationHelper do allow_any_instance_of(Project).to receive(:avatar_in_git).and_return(true) - avatar_url = "http://#{Gitlab.config.gitlab.host}#{namespace_project_avatar_path(project.namespace, project)}" - expect(helper.project_icon(project.full_path).to_s).to match( - image_tag(avatar_url)) + avatar_url = "#{gitlab_host}#{namespace_project_avatar_path(project.namespace, project)}" + expect(helper.project_icon(project.full_path).to_s).to match(image_tag(avatar_url)) end end @@ -77,8 +84,14 @@ describe ApplicationHelper do it 'returns an url for the avatar' do user = create(:user, avatar: File.open(uploaded_image_temp_path)) - expect(helper.avatar_icon(user.email).to_s). - to match("/uploads/user/avatar/#{user.id}/banana_sample.gif") + avatar_url = "/uploads/user/avatar/#{user.id}/banana_sample.gif" + + expect(helper.avatar_icon(user.email).to_s).to match(avatar_url) + + allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host) + avatar_url = "#{gitlab_host}/uploads/user/avatar/#{user.id}/banana_sample.gif" + + expect(helper.avatar_icon(user.email).to_s).to match(avatar_url) end it 'returns an url for the avatar with relative url' do diff --git a/spec/helpers/avatars_helper_spec.rb b/spec/helpers/avatars_helper_spec.rb index 581726c1d0e..6157abfe339 100644 --- a/spec/helpers/avatars_helper_spec.rb +++ b/spec/helpers/avatars_helper_spec.rb @@ -15,7 +15,7 @@ describe AvatarsHelper do end it "contains the user's avatar image" do - is_expected.to include(CGI.escapeHTML(user.avatar_url(16))) + is_expected.to include(CGI.escapeHTML(user.avatar_url(size: 16))) end end end diff --git a/spec/helpers/diff_helper_spec.rb b/spec/helpers/diff_helper_spec.rb index eae097126ce..dd6566d25bb 100644 --- a/spec/helpers/diff_helper_spec.rb +++ b/spec/helpers/diff_helper_spec.rb @@ -122,9 +122,9 @@ describe DiffHelper do it "returns strings with marked inline diffs" do marked_old_line, marked_new_line = mark_inline_diffs(old_line, new_line) - expect(marked_old_line).to eq("abc <span class='idiff left right deletion'>'def'</span>") + expect(marked_old_line).to eq(%q{abc <span class="idiff left right deletion">'def'</span>}) expect(marked_old_line).to be_html_safe - expect(marked_new_line).to eq("abc <span class='idiff left right addition'>"def"</span>") + expect(marked_new_line).to eq(%q{abc <span class="idiff left right addition">"def"</span>}) expect(marked_new_line).to be_html_safe end end diff --git a/spec/helpers/merge_requests_helper_spec.rb b/spec/helpers/merge_requests_helper_spec.rb index 10681af5f7e..f2c9d927388 100644 --- a/spec/helpers/merge_requests_helper_spec.rb +++ b/spec/helpers/merge_requests_helper_spec.rb @@ -21,55 +21,6 @@ describe MergeRequestsHelper do end end - describe '#issues_sentence' do - let(:project) { create :project } - - subject { issues_sentence(issues) } - let(:issues) do - [build(:issue, iid: 2, project: project), - build(:issue, iid: 3, project: project), - build(:issue, iid: 1, project: project)] - end - - it do - @project = project - - is_expected.to eq('#1, #2, and #3') - end - - context 'for JIRA issues' do - let(:project) { create(:empty_project) } - let(:issues) do - [ - ExternalIssue.new('JIRA-456', project), - ExternalIssue.new('FOOBAR-7890', project), - ExternalIssue.new('JIRA-123', project) - ] - end - - it do - @project = project - is_expected.to eq('FOOBAR-7890, JIRA-123, and JIRA-456') - end - end - - context 'for issues from multiple namespaces' do - let(:project) { create(:project) } - let(:other_project) { create(:project) } - let(:issues) do - [build(:issue, iid: 2, project: project), - build(:issue, iid: 3, project: other_project), - build(:issue, iid: 1, project: project)] - end - - it do - @project = project - - is_expected.to eq("#1, #2, and #{other_project.namespace.path}/#{other_project.path}#3") - end - end - end - describe '#format_mr_branch_names' do describe 'within the same project' do let(:merge_request) { create(:merge_request) } @@ -89,147 +40,4 @@ describe MergeRequestsHelper do it { is_expected.to eq([source_title, target_title]) } end end - - describe '#mr_widget_refresh_url' do - let(:guest) { create(:user) } - let(:project) { create(:project, :public) } - let(:project_fork) { Projects::ForkService.new(project, guest).execute } - let(:merge_request) { create(:merge_request, source_project: project_fork, target_project: project) } - - it 'returns correct url for MR' do - expected_url = "#{project.path_with_namespace}/merge_requests/#{merge_request.iid}/merge_widget_refresh" - - expect(mr_widget_refresh_url(merge_request)).to end_with(expected_url) - end - - it 'returns empty string for nil' do - expect(mr_widget_refresh_url(nil)).to eq('') - end - end - - describe '#mr_closes_issues' do - let(:user_1) { create(:user) } - let(:user_2) { create(:user) } - - let(:project_1) { create(:project, :private, creator: user_1, namespace: user_1.namespace) } - let(:project_2) { create(:project, :private, creator: user_2, namespace: user_2.namespace) } - - let(:issue_1) { create(:issue, project: project_1) } - let(:issue_2) { create(:issue, project: project_2) } - - let(:merge_request) { create(:merge_request, source_project: project_1, target_project: project_1,) } - - let(:merge_request) do - create(:merge_request, - source_project: project_1, target_project: project_1, - description: "Fixes #{issue_1.to_reference} Fixes #{issue_2.to_reference(project_1)}") - end - - before do - project_1.team << [user_2, :developer] - project_2.team << [user_2, :developer] - allow(merge_request.project).to receive(:default_branch).and_return(merge_request.target_branch) - @merge_request = merge_request - end - - context 'user without access to another private project' do - let(:current_user) { user_1 } - - it 'cannot see that project\'s issue that will be closed on acceptance' do - expect(mr_closes_issues).to contain_exactly(issue_1) - end - end - - context 'user with access to another private project' do - let(:current_user) { user_2 } - - it 'can see that project\'s issue that will be closed on acceptance' do - expect(mr_closes_issues).to contain_exactly(issue_1, issue_2) - end - end - end - - describe '#target_projects' do - let(:project) { create(:empty_project) } - let(:fork_project) { create(:empty_project, forked_from_project: project) } - - context 'when target project has enabled merge requests' do - it 'returns the forked_from project' do - expect(target_projects(fork_project)).to contain_exactly(project, fork_project) - end - end - - context 'when target project has disabled merge requests' do - it 'returns the forked project' do - project.project_feature.update(merge_requests_access_level: 0) - - expect(target_projects(fork_project)).to contain_exactly(fork_project) - end - end - end - - describe '#new_mr_path_from_push_event' do - subject(:url_params) { URI.decode_www_form(new_mr_path_from_push_event(event)).to_h } - let(:user) { create(:user) } - let(:project) { create(:empty_project, creator: user) } - let(:fork_project) { create(:project, forked_from_project: project, creator: user) } - let(:event) do - push_data = Gitlab::DataBuilder::Push.build_sample(fork_project, user) - create(:event, :pushed, project: fork_project, target: fork_project, author: user, data: push_data) - end - - context 'when target project has enabled merge requests' do - it 'returns link to create merge request on source project' do - expect(url_params['merge_request[target_project_id]'].to_i).to eq(project.id) - end - end - - context 'when target project has disabled merge requests' do - it 'returns link to create merge request on forked project' do - project.project_feature.update(merge_requests_access_level: 0) - - expect(url_params['merge_request[target_project_id]'].to_i).to eq(fork_project.id) - end - end - end - - describe '#mr_issues_mentioned_but_not_closing' do - let(:user_1) { create(:user) } - let(:user_2) { create(:user) } - - let(:project_1) { create(:project, :private, creator: user_1, namespace: user_1.namespace) } - let(:project_2) { create(:project, :private, creator: user_2, namespace: user_2.namespace) } - - let(:issue_1) { create(:issue, project: project_1) } - let(:issue_2) { create(:issue, project: project_2) } - - let(:merge_request) do - create(:merge_request, - source_project: project_1, target_project: project_1, - description: "#{issue_1.to_reference} #{issue_2.to_reference(project_1)}") - end - - before do - project_1.team << [user_2, :developer] - project_2.team << [user_2, :developer] - allow(merge_request.project).to receive(:default_branch).and_return(merge_request.target_branch) - @merge_request = merge_request - end - - context 'user without access to another private project' do - let(:current_user) { user_1 } - - it 'cannot see that project\'s issue that will be closed on acceptance' do - expect(mr_issues_mentioned_but_not_closing).to contain_exactly(issue_1) - end - end - - context 'user with access to another private project' do - let(:current_user) { user_2 } - - it 'can see that project\'s issue that will be closed on acceptance' do - expect(mr_issues_mentioned_but_not_closing).to contain_exactly(issue_1, issue_2) - end - end - end end diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index be97973c693..54c5ba57bdf 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -66,8 +66,8 @@ describe ProjectsHelper do describe "#project_list_cache_key", redis: true do let(:project) { create(:project) } - it "includes the namespace" do - expect(helper.project_list_cache_key(project)).to include(project.namespace.cache_key) + it "includes the route" do + expect(helper.project_list_cache_key(project)).to include(project.route.cache_key) end it "includes the project" do diff --git a/spec/helpers/submodule_helper_spec.rb b/spec/helpers/submodule_helper_spec.rb index 345bc33a67b..18935be95c9 100644 --- a/spec/helpers/submodule_helper_spec.rb +++ b/spec/helpers/submodule_helper_spec.rb @@ -81,6 +81,19 @@ describe SubmoduleHelper do end end + context 'in-repository submodule' do + let(:group) { create(:group, name: "Master Project", path: "master-project") } + let(:project) { create(:empty_project, group: group) } + before do + self.instance_variable_set(:@project, project) + end + + it 'in-repository' do + stub_url('./') + expect(submodule_links(submodule_item)).to eq(["/master-project/#{project.path}", "/master-project/#{project.path}/tree/hash"]) + end + end + context 'submodule on gitlab.com' do it 'detects ssh' do stub_url('git@gitlab.com:gitlab-org/gitlab-ce.git') @@ -109,6 +122,18 @@ describe SubmoduleHelper do end context 'submodule on unsupported' do + it 'sanitizes unsupported protocols' do + stub_url('javascript:alert("XSS");') + + expect(helper.submodule_links(submodule_item)).to eq([nil, nil]) + end + + it 'sanitizes unsupported protocols disguised as a repository URL' do + stub_url('javascript:alert("XSS");foo/bar.git') + + expect(helper.submodule_links(submodule_item)).to eq([nil, nil]) + end + it 'returns original' do stub_url('http://mygitserver.com/gitlab-org/gitlab-ce') expect(submodule_links(submodule_item)).to eq([repo.submodule_url_for, nil]) diff --git a/spec/javascripts/behaviors/bind_in_out_spec.js b/spec/javascripts/behaviors/bind_in_out_spec.js index dd9ab33289f..5ff66167718 100644 --- a/spec/javascripts/behaviors/bind_in_out_spec.js +++ b/spec/javascripts/behaviors/bind_in_out_spec.js @@ -2,7 +2,7 @@ import BindInOut from '~/behaviors/bind_in_out'; import ClassSpecHelper from '../helpers/class_spec_helper'; describe('BindInOut', function () { - describe('.constructor', function () { + describe('constructor', function () { beforeEach(function () { this.in = {}; this.out = {}; @@ -53,7 +53,7 @@ describe('BindInOut', function () { }); }); - describe('.addEvents', function () { + describe('addEvents', function () { beforeEach(function () { this.in = jasmine.createSpyObj('in', ['addEventListener']); @@ -79,7 +79,7 @@ describe('BindInOut', function () { }); }); - describe('.updateOut', function () { + describe('updateOut', function () { beforeEach(function () { this.in = { value: 'the-value' }; this.out = { textContent: 'not-the-value' }; @@ -98,7 +98,7 @@ describe('BindInOut', function () { }); }); - describe('.removeEvents', function () { + describe('removeEvents', function () { beforeEach(function () { this.in = jasmine.createSpyObj('in', ['removeEventListener']); this.updateOut = () => {}; @@ -122,7 +122,7 @@ describe('BindInOut', function () { }); }); - describe('.initAll', function () { + describe('initAll', function () { beforeEach(function () { this.ins = [0, 1, 2]; this.instances = []; @@ -153,7 +153,7 @@ describe('BindInOut', function () { }); }); - describe('.init', function () { + describe('init', function () { beforeEach(function () { spyOn(BindInOut.prototype, 'addEvents').and.callFake(function () { return this; }); spyOn(BindInOut.prototype, 'updateOut').and.callFake(function () { return this; }); diff --git a/spec/javascripts/blob/balsamiq/balsamiq_viewer_integration_spec.js b/spec/javascripts/blob/balsamiq/balsamiq_viewer_integration_spec.js new file mode 100644 index 00000000000..acd0aaf2a86 --- /dev/null +++ b/spec/javascripts/blob/balsamiq/balsamiq_viewer_integration_spec.js @@ -0,0 +1,51 @@ +/* eslint-disable import/no-unresolved */ + +import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer'; +import bmprPath from '../../fixtures/blob/balsamiq/test.bmpr'; + +describe('Balsamiq integration spec', () => { + let container; + let endpoint; + let balsamiqViewer; + + preloadFixtures('static/balsamiq_viewer.html.raw'); + + beforeEach(() => { + loadFixtures('static/balsamiq_viewer.html.raw'); + + container = document.getElementById('js-balsamiq-viewer'); + balsamiqViewer = new BalsamiqViewer(container); + }); + + describe('successful response', () => { + beforeEach((done) => { + endpoint = bmprPath; + + balsamiqViewer.loadFile(endpoint).then(done).catch(done.fail); + }); + + it('does not show loading icon', () => { + expect(document.querySelector('.loading')).toBeNull(); + }); + + it('renders the balsamiq previews', () => { + expect(document.querySelectorAll('.previews .preview').length).not.toEqual(0); + }); + }); + + describe('error getting file', () => { + beforeEach((done) => { + endpoint = 'invalid/path/to/file.bmpr'; + + balsamiqViewer.loadFile(endpoint).then(done.fail, null).catch(done); + }); + + it('does not show loading icon', () => { + expect(document.querySelector('.loading')).toBeNull(); + }); + + it('does not render the balsamiq previews', () => { + expect(document.querySelectorAll('.previews .preview').length).toEqual(0); + }); + }); +}); diff --git a/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js index 85816ee1f11..aa87956109f 100644 --- a/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js +++ b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js @@ -4,17 +4,11 @@ import ClassSpecHelper from '../../helpers/class_spec_helper'; describe('BalsamiqViewer', () => { let balsamiqViewer; - let endpoint; let viewer; describe('class constructor', () => { beforeEach(() => { - endpoint = 'endpoint'; - viewer = { - dataset: { - endpoint, - }, - }; + viewer = {}; balsamiqViewer = new BalsamiqViewer(viewer); }); @@ -22,25 +16,25 @@ describe('BalsamiqViewer', () => { it('should set .viewer', () => { expect(balsamiqViewer.viewer).toBe(viewer); }); + }); + + describe('fileLoaded', () => { - it('should set .endpoint', () => { - expect(balsamiqViewer.endpoint).toBe(endpoint); - }); }); describe('loadFile', () => { let xhr; + let loadFile; + const endpoint = 'endpoint'; beforeEach(() => { - endpoint = 'endpoint'; xhr = jasmine.createSpyObj('xhr', ['open', 'send']); balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderFile']); - balsamiqViewer.endpoint = endpoint; spyOn(window, 'XMLHttpRequest').and.returnValue(xhr); - BalsamiqViewer.prototype.loadFile.call(balsamiqViewer); + loadFile = BalsamiqViewer.prototype.loadFile.call(balsamiqViewer, endpoint); }); it('should call .open', () => { @@ -54,6 +48,10 @@ describe('BalsamiqViewer', () => { it('should call .send', () => { expect(xhr.send).toHaveBeenCalled(); }); + + it('should return a promise', () => { + expect(loadFile).toEqual(jasmine.any(Promise)); + }); }); describe('renderFile', () => { @@ -325,18 +323,4 @@ describe('BalsamiqViewer', () => { expect(parseTitle).toBe('name'); }); }); - - describe('onError', () => { - beforeEach(() => { - spyOn(window, 'Flash'); - - BalsamiqViewer.onError(); - }); - - ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'onError'); - - it('should instantiate Flash', () => { - expect(window.Flash).toHaveBeenCalledWith('Balsamiq file could not be loaded.'); - }); - }); }); diff --git a/spec/javascripts/blob/create_branch_dropdown_spec.js b/spec/javascripts/blob/create_branch_dropdown_spec.js index c1179e572ae..9f0d373cb81 100644 --- a/spec/javascripts/blob/create_branch_dropdown_spec.js +++ b/spec/javascripts/blob/create_branch_dropdown_spec.js @@ -1,5 +1,4 @@ require('~/gl_dropdown'); -require('~/lib/utils/type_utility'); require('~/blob/create_branch_dropdown'); require('~/blob/target_branch_dropdown'); diff --git a/spec/javascripts/blob/target_branch_dropdown_spec.js b/spec/javascripts/blob/target_branch_dropdown_spec.js index 4fb79663c51..76ed3dc1a2d 100644 --- a/spec/javascripts/blob/target_branch_dropdown_spec.js +++ b/spec/javascripts/blob/target_branch_dropdown_spec.js @@ -1,5 +1,4 @@ require('~/gl_dropdown'); -require('~/lib/utils/type_utility'); require('~/blob/create_branch_dropdown'); require('~/blob/target_branch_dropdown'); @@ -63,7 +62,7 @@ describe('TargetBranchDropdown', () => { expect('change.branch').toHaveBeenTriggeredOn(dropdown.$dropdown); }); - describe('#dropdownData', () => { + describe('dropdownData', () => { it('cache the refs', () => { const refs = dropdown.cachedRefs; dropdown.cachedRefs = null; @@ -88,7 +87,7 @@ describe('TargetBranchDropdown', () => { }); }); - describe('#setNewBranch', () => { + describe('setNewBranch', () => { it('adds the new branch and select it', () => { const branchName = 'new_branch'; diff --git a/spec/javascripts/commit/pipelines/mock_data.js b/spec/javascripts/commit/pipelines/mock_data.js deleted file mode 100644 index 82b00b4c1ec..00000000000 --- a/spec/javascripts/commit/pipelines/mock_data.js +++ /dev/null @@ -1,89 +0,0 @@ -export default { - id: 73, - user: { - name: 'Administrator', - username: 'root', - id: 1, - state: 'active', - avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - web_url: 'http://localhost:3000/root', - }, - path: '/root/review-app/pipelines/73', - details: { - status: { - icon: 'icon_status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - has_details: true, - details_path: '/root/review-app/pipelines/73', - }, - duration: null, - finished_at: '2017-01-25T00:00:17.130Z', - stages: [{ - name: 'build', - title: 'build: failed', - status: { - icon: 'icon_status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - has_details: true, - details_path: '/root/review-app/pipelines/73#build', - }, - path: '/root/review-app/pipelines/73#build', - dropdown_path: '/root/review-app/pipelines/73/stage.json?stage=build', - }], - artifacts: [], - manual_actions: [ - { - name: 'stop_review', - path: '/root/review-app/builds/1463/play', - }, - { - name: 'name', - path: '/root/review-app/builds/1490/play', - }, - ], - }, - flags: { - latest: true, - triggered: false, - stuck: false, - yaml_errors: false, - retryable: true, - cancelable: false, - }, - ref: - { - name: 'master', - path: '/root/review-app/tree/master', - tag: false, - branch: true, - }, - commit: { - id: 'fbd79f04fa98717641deaaeb092a4d417237c2e4', - short_id: 'fbd79f04', - title: 'Update .gitlab-ci.yml', - author_name: 'Administrator', - author_email: 'admin@example.com', - created_at: '2017-01-16T12:13:57.000-05:00', - committer_name: 'Administrator', - committer_email: 'admin@example.com', - message: 'Update .gitlab-ci.yml', - author: { - name: 'Administrator', - username: 'root', - id: 1, - state: 'active', - avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - web_url: 'http://localhost:3000/root', - }, - author_gravatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - commit_url: 'http://localhost:3000/root/review-app/commit/fbd79f04fa98717641deaaeb092a4d417237c2e4', - commit_path: '/root/review-app/commit/fbd79f04fa98717641deaaeb092a4d417237c2e4', - }, - retry_path: '/root/review-app/pipelines/73/retry', - created_at: '2017-01-16T17:13:59.800Z', - updated_at: '2017-01-25T00:00:17.132Z', -}; diff --git a/spec/javascripts/commit/pipelines/pipelines_spec.js b/spec/javascripts/commit/pipelines/pipelines_spec.js index ad31448f81c..398c593eec2 100644 --- a/spec/javascripts/commit/pipelines/pipelines_spec.js +++ b/spec/javascripts/commit/pipelines/pipelines_spec.js @@ -1,12 +1,17 @@ import Vue from 'vue'; import PipelinesTable from '~/commit/pipelines/pipelines_table'; -import pipeline from './mock_data'; describe('Pipelines table in Commits and Merge requests', () => { + const jsonFixtureName = 'pipelines/pipelines.json'; + let pipeline; + preloadFixtures('static/pipelines_table.html.raw'); + preloadFixtures(jsonFixtureName); beforeEach(() => { loadFixtures('static/pipelines_table.html.raw'); + const pipelines = getJSONFixture(jsonFixtureName).pipelines; + pipeline = pipelines.find(p => p.id === 1); }); describe('successful request', () => { diff --git a/spec/javascripts/droplab/drop_down_spec.js b/spec/javascripts/droplab/drop_down_spec.js index e7786e8cc2c..2bbcebeeac0 100644 --- a/spec/javascripts/droplab/drop_down_spec.js +++ b/spec/javascripts/droplab/drop_down_spec.js @@ -1,5 +1,3 @@ -/* eslint-disable */ - import DropDown from '~/droplab/drop_down'; import utils from '~/droplab/utils'; import { SELECTED_CLASS, IGNORE_CLASS } from '~/droplab/constants'; @@ -17,7 +15,7 @@ describe('DropDown', function () { it('sets the .hidden property to true', function () { expect(this.dropdown.hidden).toBe(true); - }) + }); it('sets the .list property', function () { expect(this.dropdown.list).toBe(this.list); @@ -152,7 +150,7 @@ describe('DropDown', function () { it('should call addSelectedClass', function () { expect(this.dropdown.addSelectedClass).toHaveBeenCalledWith(this.closestElement); - }) + }); it('should call .preventDefault', function () { expect(this.event.preventDefault).toHaveBeenCalled(); @@ -293,36 +291,6 @@ describe('DropDown', function () { }); }); - describe('toggle', function () { - beforeEach(function () { - this.dropdown = { hidden: true, show: () => {}, hide: () => {} }; - - spyOn(this.dropdown, 'show'); - spyOn(this.dropdown, 'hide'); - - DropDown.prototype.toggle.call(this.dropdown); - }); - - it('should call .show if hidden is true', function () { - expect(this.dropdown.show).toHaveBeenCalled(); - }); - - describe('if hidden is false', function () { - beforeEach(function () { - this.dropdown = { hidden: false, show: () => {}, hide: () => {} }; - - spyOn(this.dropdown, 'show'); - spyOn(this.dropdown, 'hide'); - - DropDown.prototype.toggle.call(this.dropdown); - }); - - it('should call .show if hidden is true', function () { - expect(this.dropdown.hide).toHaveBeenCalled(); - }); - }); - }); - describe('setData', function () { beforeEach(function () { this.dropdown = { render: () => {} }; @@ -399,7 +367,7 @@ describe('DropDown', function () { expect(this.data.map).toHaveBeenCalledWith(jasmine.any(Function)); }); - it('should call .renderChildren for each data item', function() { + it('should call .renderChildren for each data item', function () { expect(this.dropdown.renderChildren.calls.count()).toBe(this.data.length); }); @@ -407,7 +375,7 @@ describe('DropDown', function () { expect(this.renderableList.innerHTML).toBe('01'); }); - describe('if no data argument is passed' , function () { + describe('if no data argument is passed', function () { beforeEach(function () { this.data.map.calls.reset(); this.dropdown.renderChildren.calls.reset(); @@ -446,14 +414,14 @@ describe('DropDown', function () { describe('renderChildren', function () { beforeEach(function () { this.templateString = 'templateString'; - this.dropdown = { setImagesSrc: () => {}, templateString: this.templateString }; + this.dropdown = { templateString: this.templateString }; this.data = { droplab_hidden: true }; this.html = 'html'; this.template = { firstChild: { outerHTML: 'outerHTML', style: {} } }; spyOn(utils, 'template').and.returnValue(this.html); spyOn(document, 'createElement').and.returnValue(this.template); - spyOn(this.dropdown, 'setImagesSrc'); + spyOn(DropDown, 'setImagesSrc'); this.renderChildren = DropDown.prototype.renderChildren.call(this.dropdown, this.data); }); @@ -471,7 +439,7 @@ describe('DropDown', function () { }); it('should call .setImagesSrc with the template', function () { - expect(this.dropdown.setImagesSrc).toHaveBeenCalledWith(this.template); + expect(DropDown.setImagesSrc).toHaveBeenCalledWith(this.template); }); it('should set the template display to none', function () { @@ -496,12 +464,11 @@ describe('DropDown', function () { describe('setImagesSrc', function () { beforeEach(function () { - this.dropdown = {}; this.template = { querySelectorAll: () => {} }; spyOn(this.template, 'querySelectorAll').and.returnValue([]); - DropDown.prototype.setImagesSrc.call(this.dropdown, this.template); + DropDown.setImagesSrc(this.template); }); it('should call .querySelectorAll', function () { @@ -562,7 +529,7 @@ describe('DropDown', function () { describe('toggle', function () { beforeEach(function () { - this.hidden = true + this.hidden = true; this.dropdown = { hidden: this.hidden, show: () => {}, hide: () => {} }; spyOn(this.dropdown, 'show'); @@ -577,7 +544,7 @@ describe('DropDown', function () { describe('if .hidden is false', function () { beforeEach(function () { - this.hidden = false + this.hidden = false; this.dropdown = { hidden: this.hidden, show: () => {}, hide: () => {} }; spyOn(this.dropdown, 'show'); diff --git a/spec/javascripts/droplab/hook_spec.js b/spec/javascripts/droplab/hook_spec.js index 8ebdcdd1404..75bf5f3d611 100644 --- a/spec/javascripts/droplab/hook_spec.js +++ b/spec/javascripts/droplab/hook_spec.js @@ -1,5 +1,3 @@ -/* eslint-disable */ - import Hook from '~/droplab/hook'; import * as dropdownSrc from '~/droplab/drop_down'; @@ -73,10 +71,4 @@ describe('Hook', function () { }); }); }); - - describe('addEvents', function () { - it('should exist', function () { - expect(Hook.prototype.hasOwnProperty('addEvents')).toBe(true); - }); - }); }); diff --git a/spec/javascripts/fixtures/balsamiq.rb b/spec/javascripts/fixtures/balsamiq.rb new file mode 100644 index 00000000000..b5372821bf5 --- /dev/null +++ b/spec/javascripts/fixtures/balsamiq.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe 'Balsamiq file', '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project, namespace: namespace, path: 'balsamiq-project') } + + before(:all) do + clean_frontend_fixtures('blob/balsamiq/') + end + + it 'blob/balsamiq/test.bmpr' do |example| + blob = project.repository.blob_at('b89b56d79', 'files/images/balsamiq.bmpr') + + store_frontend_fixture(blob.data.force_encoding('utf-8'), example.description) + end +end diff --git a/spec/javascripts/fixtures/balsamiq_viewer.html.haml b/spec/javascripts/fixtures/balsamiq_viewer.html.haml new file mode 100644 index 00000000000..18166ba4901 --- /dev/null +++ b/spec/javascripts/fixtures/balsamiq_viewer.html.haml @@ -0,0 +1 @@ +.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: '/test' } } diff --git a/spec/javascripts/fixtures/merge_requests.rb b/spec/javascripts/fixtures/merge_requests.rb index 47d904b865b..a746a776548 100644 --- a/spec/javascripts/fixtures/merge_requests.rb +++ b/spec/javascripts/fixtures/merge_requests.rb @@ -16,6 +16,16 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont sha: merge_request.diff_head_sha ) end + let(:path) { "files/ruby/popen.rb" } + let(:position) do + Gitlab::Diff::Position.new( + old_path: path, + new_path: path, + old_line: nil, + new_line: 14, + diff_refs: merge_request.diff_refs + ) + end render_views @@ -39,6 +49,12 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont render_merge_request(example.description, merged_merge_request) end + it 'merge_requests/diff_comment.html.raw' do |example| + create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request) + create(:note_on_merge_request, author: admin, project: project, noteable: merge_request) + render_merge_request(example.description, merge_request) + end + private def render_merge_request(fixture_file_name, merge_request) diff --git a/spec/javascripts/fixtures/pipelines.rb b/spec/javascripts/fixtures/pipelines.rb new file mode 100644 index 00000000000..daafbac86db --- /dev/null +++ b/spec/javascripts/fixtures/pipelines.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe Projects::PipelinesController, '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project, :repository, namespace: namespace, path: 'pipelines-project') } + let(:commit) { create(:commit, project: project) } + let(:commit_without_author) { RepoHelpers.another_sample_commit } + let!(:user) { create(:user, email: commit.author_email) } + let!(:pipeline) { create(:ci_pipeline, project: project, sha: commit.id, user: user) } + let!(:pipeline_without_author) { create(:ci_pipeline, project: project, sha: commit_without_author.id) } + let!(:pipeline_without_commit) { create(:ci_pipeline, project: project, sha: '0000') } + + render_views + + before(:all) do + clean_frontend_fixtures('pipelines/') + end + + before(:each) do + sign_in(admin) + end + + it 'pipelines/pipelines.json' do |example| + get :index, + namespace_id: namespace, + project_id: project, + format: :json + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end +end diff --git a/spec/javascripts/gl_dropdown_spec.js b/spec/javascripts/gl_dropdown_spec.js index c207fb00a47..8f90ed69e64 100644 --- a/spec/javascripts/gl_dropdown_spec.js +++ b/spec/javascripts/gl_dropdown_spec.js @@ -2,7 +2,6 @@ require('~/gl_dropdown'); require('~/lib/utils/common_utils'); -require('~/lib/utils/type_utility'); require('~/lib/utils/url_utility'); (() => { @@ -44,21 +43,18 @@ require('~/lib/utils/url_utility'); preloadFixtures('static/gl_dropdown.html.raw'); loadJSONFixtures('projects.json'); - function initDropDown(hasRemote, isFilterable) { - this.dropdownButtonElement = $('#js-project-dropdown', this.dropdownContainerElement).glDropdown({ + function initDropDown(hasRemote, isFilterable, extraOpts = {}) { + const options = Object.assign({ selectable: true, filterable: isFilterable, data: hasRemote ? remoteMock.bind({}, this.projectsData) : this.projectsData, search: { fields: ['name'] }, - text: (project) => { - (project.name_with_namespace || project.name); - }, - id: (project) => { - project.id; - } - }); + text: project => (project.name_with_namespace || project.name), + id: project => project.id, + }, extraOpts); + this.dropdownButtonElement = $('#js-project-dropdown', this.dropdownContainerElement).glDropdown(options); } beforeEach(() => { @@ -80,6 +76,37 @@ require('~/lib/utils/url_utility'); expect(this.dropdownContainerElement).toHaveClass('open'); }); + it('escapes HTML as text', () => { + this.projectsData[0].name_with_namespace = '<script>alert("testing");</script>'; + + initDropDown.call(this, false); + + this.dropdownButtonElement.click(); + + expect( + $('.dropdown-content li:first-child').text(), + ).toBe('<script>alert("testing");</script>'); + }); + + it('should output HTML when highlighting', () => { + this.projectsData[0].name_with_namespace = 'testing'; + $('.dropdown-input .dropdown-input-field').val('test'); + + initDropDown.call(this, false, true, { + highlight: true, + }); + + this.dropdownButtonElement.click(); + + expect( + $('.dropdown-content li:first-child').text(), + ).toBe('testing'); + + expect( + $('.dropdown-content li:first-child a').html(), + ).toBe('<b>t</b><b>e</b><b>s</b><b>t</b>ing'); + }); + describe('that is open', () => { beforeEach(() => { initDropDown.call(this, false, false); diff --git a/spec/javascripts/gl_form_spec.js b/spec/javascripts/gl_form_spec.js index 71d6e2a7e22..5ed18d0a76b 100644 --- a/spec/javascripts/gl_form_spec.js +++ b/spec/javascripts/gl_form_spec.js @@ -32,7 +32,7 @@ describe('GLForm', () => { }); }); - describe('.setupAutosize', () => { + describe('setupAutosize', () => { beforeEach((done) => { this.glForm.setupAutosize(); setTimeout(() => { @@ -59,7 +59,7 @@ describe('GLForm', () => { }); }); - describe('.setHeightData', () => { + describe('setHeightData', () => { beforeEach(() => { spyOn($.prototype, 'data'); spyOn($.prototype, 'outerHeight').and.returnValue(200); @@ -75,7 +75,7 @@ describe('GLForm', () => { }); }); - describe('.destroyAutosize', () => { + describe('destroyAutosize', () => { describe('when called', () => { beforeEach(() => { spyOn($.prototype, 'data'); diff --git a/spec/javascripts/helpers/class_spec_helper_spec.js b/spec/javascripts/helpers/class_spec_helper_spec.js index 0a61e561640..a1cfc8ba820 100644 --- a/spec/javascripts/helpers/class_spec_helper_spec.js +++ b/spec/javascripts/helpers/class_spec_helper_spec.js @@ -3,7 +3,7 @@ require('./class_spec_helper'); describe('ClassSpecHelper', () => { - describe('.itShouldBeAStaticMethod', function () { + describe('itShouldBeAStaticMethod', function () { beforeEach(() => { class TestClass { instanceMethod() { this.prop = 'val'; } diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js new file mode 100644 index 00000000000..ee456869c53 --- /dev/null +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -0,0 +1,60 @@ +import Vue from 'vue'; +import '~/render_math'; +import '~/render_gfm'; +import issuableApp from '~/issue_show/components/app.vue'; +import issueShowData from '../mock_data'; + +const issueShowInterceptor = data => (request, next) => { + next(request.respondWith(JSON.stringify(data), { + status: 200, + headers: { + 'POLL-INTERVAL': 1, + }, + })); +}; + +describe('Issuable output', () => { + document.body.innerHTML = '<span id="task_status"></span>'; + + let vm; + + beforeEach(() => { + const IssuableDescriptionComponent = Vue.extend(issuableApp); + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest)); + + vm = new IssuableDescriptionComponent({ + propsData: { + canUpdate: true, + endpoint: '/gitlab-org/gitlab-shell/issues/9/realtime_changes', + issuableRef: '#1', + initialTitle: '', + initialDescriptionHtml: '', + initialDescriptionText: '', + }, + }).$mount(); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, issueShowInterceptor); + }); + + it('should render a title/description and update title/description on update', (done) => { + setTimeout(() => { + expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); + expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>'); + expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>this is a description!</p>'); + expect(vm.$el.querySelector('.js-task-list-field').value).toContain('this is a description'); + + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest)); + + setTimeout(() => { + expect(document.querySelector('title').innerText).toContain('2 (#1)'); + expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>'); + expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>'); + expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/issue_show/components/description_spec.js b/spec/javascripts/issue_show/components/description_spec.js new file mode 100644 index 00000000000..408349cc42d --- /dev/null +++ b/spec/javascripts/issue_show/components/description_spec.js @@ -0,0 +1,99 @@ +import Vue from 'vue'; +import descriptionComponent from '~/issue_show/components/description.vue'; + +describe('Description component', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(descriptionComponent); + + if (!document.querySelector('.issuable-meta')) { + const metaData = document.createElement('div'); + metaData.classList.add('issuable-meta'); + metaData.innerHTML = '<span id="task_status"></span><span id="task_status_short"></span>'; + + document.body.appendChild(metaData); + } + + vm = new Component({ + propsData: { + canUpdate: true, + descriptionHtml: 'test', + descriptionText: 'test', + updatedAt: new Date().toString(), + taskStatus: '', + }, + }).$mount(); + }); + + it('animates description changes', (done) => { + vm.descriptionHtml = 'changed'; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.wiki').classList.contains('issue-realtime-pre-pulse'), + ).toBeTruthy(); + + setTimeout(() => { + expect( + vm.$el.querySelector('.wiki').classList.contains('issue-realtime-trigger-pulse'), + ).toBeTruthy(); + + done(); + }); + }); + }); + + it('re-inits the TaskList when description changed', (done) => { + spyOn(gl, 'TaskList'); + vm.descriptionHtml = 'changed'; + + setTimeout(() => { + expect( + gl.TaskList, + ).toHaveBeenCalled(); + + done(); + }); + }); + + it('does not re-init the TaskList when canUpdate is false', (done) => { + spyOn(gl, 'TaskList'); + vm.canUpdate = false; + vm.descriptionHtml = 'changed'; + + setTimeout(() => { + expect( + gl.TaskList, + ).not.toHaveBeenCalled(); + + done(); + }); + }); + + describe('taskStatus', () => { + it('adds full taskStatus', (done) => { + vm.taskStatus = '1 of 1'; + + setTimeout(() => { + expect( + document.querySelector('.issuable-meta #task_status').textContent.trim(), + ).toBe('1 of 1'); + + done(); + }); + }); + + it('adds short taskStatus', (done) => { + vm.taskStatus = '1 of 1'; + + setTimeout(() => { + expect( + document.querySelector('.issuable-meta #task_status_short').textContent.trim(), + ).toBe('1/1 task'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/issue_show/components/title_spec.js b/spec/javascripts/issue_show/components/title_spec.js new file mode 100644 index 00000000000..2f953e7e92e --- /dev/null +++ b/spec/javascripts/issue_show/components/title_spec.js @@ -0,0 +1,67 @@ +import Vue from 'vue'; +import titleComponent from '~/issue_show/components/title.vue'; + +describe('Title component', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(titleComponent); + vm = new Component({ + propsData: { + issuableRef: '#1', + titleHtml: 'Testing <img />', + titleText: 'Testing', + }, + }).$mount(); + }); + + it('renders title HTML', () => { + expect( + vm.$el.innerHTML.trim(), + ).toBe('Testing <img>'); + }); + + it('updates page title when changing titleHtml', (done) => { + spyOn(vm, 'setPageTitle'); + vm.titleHtml = 'test'; + + Vue.nextTick(() => { + expect( + vm.setPageTitle, + ).toHaveBeenCalled(); + + done(); + }); + }); + + it('animates title changes', (done) => { + vm.titleHtml = 'test'; + + Vue.nextTick(() => { + expect( + vm.$el.classList.contains('issue-realtime-pre-pulse'), + ).toBeTruthy(); + + setTimeout(() => { + expect( + vm.$el.classList.contains('issue-realtime-trigger-pulse'), + ).toBeTruthy(); + + done(); + }); + }); + }); + + it('updates page title after changing title', (done) => { + vm.titleHtml = 'changed'; + vm.titleText = 'changed'; + + Vue.nextTick(() => { + expect( + document.querySelector('title').textContent.trim(), + ).toContain('changed'); + + done(); + }); + }); +}); diff --git a/spec/javascripts/issue_show/issue_title_description_spec.js b/spec/javascripts/issue_show/issue_title_description_spec.js deleted file mode 100644 index 1ec4fe58b08..00000000000 --- a/spec/javascripts/issue_show/issue_title_description_spec.js +++ /dev/null @@ -1,60 +0,0 @@ -import Vue from 'vue'; -import $ from 'jquery'; -import '~/render_math'; -import '~/render_gfm'; -import issueTitleDescription from '~/issue_show/issue_title_description.vue'; -import issueShowData from './mock_data'; - -window.$ = $; - -const issueShowInterceptor = data => (request, next) => { - next(request.respondWith(JSON.stringify(data), { - status: 200, - headers: { - 'POLL-INTERVAL': 1, - }, - })); -}; - -describe('Issue Title', () => { - document.body.innerHTML = '<span id="task_status"></span>'; - - let IssueTitleDescriptionComponent; - - beforeEach(() => { - IssueTitleDescriptionComponent = Vue.extend(issueTitleDescription); - }); - - afterEach(() => { - Vue.http.interceptors = _.without(Vue.http.interceptors, issueShowInterceptor); - }); - - it('should render a title/description and update title/description on update', (done) => { - Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest)); - - const issueShowComponent = new IssueTitleDescriptionComponent({ - propsData: { - canUpdateIssue: '.css-stuff', - endpoint: '/gitlab-org/gitlab-shell/issues/9/rendered_title', - }, - }).$mount(); - - setTimeout(() => { - expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); - expect(issueShowComponent.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>'); - expect(issueShowComponent.$el.querySelector('.wiki').innerHTML).toContain('<p>this is a description!</p>'); - expect(issueShowComponent.$el.querySelector('.js-task-list-field').innerText).toContain('this is a description'); - - Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest)); - - setTimeout(() => { - expect(document.querySelector('title').innerText).toContain('2 (#1)'); - expect(issueShowComponent.$el.querySelector('.title').innerHTML).toContain('<p>2</p>'); - expect(issueShowComponent.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>'); - expect(issueShowComponent.$el.querySelector('.js-task-list-field').innerText).toContain('42'); - - done(); - }); - }); - }); -}); diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js index ad5a7b63470..6683d581bc5 100644 --- a/spec/javascripts/issue_show/mock_data.js +++ b/spec/javascripts/issue_show/mock_data.js @@ -4,23 +4,23 @@ export default { title_text: 'this is a title', description: '<p>this is a description!</p>', description_text: 'this is a description', - issue_number: 1, task_status: '2 of 4 completed', + updated_at: new Date().toString(), }, secondRequest: { title: '<p>2</p>', title_text: '2', description: '<p>42</p>', description_text: '42', - issue_number: 1, task_status: '0 of 0 completed', + updated_at: new Date().toString(), }, issueSpecRequest: { title: '<p>this is a title</p>', title_text: 'this is a title', description: '<li class="task-list-item enabled"><input type="checkbox" class="task-list-item-checkbox">Task List Item</li>', description_text: '- [ ] Task List Item', - issue_number: 1, task_status: '0 of 1 completed', + updated_at: new Date().toString(), }, }; diff --git a/spec/javascripts/labels_issue_sidebar_spec.js b/spec/javascripts/labels_issue_sidebar_spec.js index 37e038c16da..53aba191b19 100644 --- a/spec/javascripts/labels_issue_sidebar_spec.js +++ b/spec/javascripts/labels_issue_sidebar_spec.js @@ -2,7 +2,6 @@ /* global IssuableContext */ /* global LabelsSelect */ -require('~/lib/utils/type_utility'); require('~/gl_dropdown'); require('select2'); require('vendor/jquery.nicescroll'); diff --git a/spec/javascripts/lib/utils/ajax_cache_spec.js b/spec/javascripts/lib/utils/ajax_cache_spec.js index 7b466a11b92..e1747a82329 100644 --- a/spec/javascripts/lib/utils/ajax_cache_spec.js +++ b/spec/javascripts/lib/utils/ajax_cache_spec.js @@ -5,19 +5,13 @@ describe('AjaxCache', () => { const dummyResponse = { important: 'dummy data', }; - let ajaxSpy = (url) => { - expect(url).toBe(dummyEndpoint); - const deferred = $.Deferred(); - deferred.resolve(dummyResponse); - return deferred.promise(); - }; beforeEach(() => { AjaxCache.internalStorage = { }; - spyOn(jQuery, 'ajax').and.callFake(url => ajaxSpy(url)); + AjaxCache.pendingRequests = { }; }); - describe('#get', () => { + describe('get', () => { it('returns undefined if cache is empty', () => { const data = AjaxCache.get(dummyEndpoint); @@ -41,7 +35,7 @@ describe('AjaxCache', () => { }); }); - describe('#hasData', () => { + describe('hasData', () => { it('returns false if cache is empty', () => { expect(AjaxCache.hasData(dummyEndpoint)).toBe(false); }); @@ -59,9 +53,9 @@ describe('AjaxCache', () => { }); }); - describe('#purge', () => { + describe('remove', () => { it('does nothing if cache is empty', () => { - AjaxCache.purge(dummyEndpoint); + AjaxCache.remove(dummyEndpoint); expect(AjaxCache.internalStorage).toEqual({ }); }); @@ -69,7 +63,7 @@ describe('AjaxCache', () => { it('does nothing if cache contains no matching data', () => { AjaxCache.internalStorage['not matching'] = dummyResponse; - AjaxCache.purge(dummyEndpoint); + AjaxCache.remove(dummyEndpoint); expect(AjaxCache.internalStorage['not matching']).toBe(dummyResponse); }); @@ -77,14 +71,27 @@ describe('AjaxCache', () => { it('removes matching data', () => { AjaxCache.internalStorage[dummyEndpoint] = dummyResponse; - AjaxCache.purge(dummyEndpoint); + AjaxCache.remove(dummyEndpoint); expect(AjaxCache.internalStorage).toEqual({ }); }); }); - describe('#retrieve', () => { + describe('retrieve', () => { + let ajaxSpy; + + beforeEach(() => { + spyOn(jQuery, 'ajax').and.callFake(url => ajaxSpy(url)); + }); + it('stores and returns data from Ajax call if cache is empty', (done) => { + ajaxSpy = (url) => { + expect(url).toBe(dummyEndpoint); + const deferred = $.Deferred(); + deferred.resolve(dummyResponse); + return deferred.promise(); + }; + AjaxCache.retrieve(dummyEndpoint) .then((data) => { expect(data).toBe(dummyResponse); @@ -94,6 +101,28 @@ describe('AjaxCache', () => { .catch(fail); }); + it('makes no Ajax call if request is pending', () => { + const responseDeferred = $.Deferred(); + + ajaxSpy = (url) => { + expect(url).toBe(dummyEndpoint); + // neither reject nor resolve to keep request pending + return responseDeferred.promise(); + }; + + const unexpectedResponse = data => fail(`Did not expect response: ${data}`); + + AjaxCache.retrieve(dummyEndpoint) + .then(unexpectedResponse) + .catch(fail); + + AjaxCache.retrieve(dummyEndpoint) + .then(unexpectedResponse) + .catch(fail); + + expect($.ajax.calls.count()).toBe(1); + }); + it('returns undefined if Ajax call fails and cache is empty', (done) => { const dummyStatusText = 'exploded'; const dummyErrorMessage = 'server exploded'; diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js index 5eb147ed888..42a9067ade5 100644 --- a/spec/javascripts/lib/utils/common_utils_spec.js +++ b/spec/javascripts/lib/utils/common_utils_spec.js @@ -41,6 +41,16 @@ require('~/lib/utils/common_utils'); const paramsArray = gl.utils.getUrlParamsArray(); expect(paramsArray[0][0] !== '?').toBe(true); }); + + it('should decode params', () => { + history.pushState('', '', '?label_name%5B%5D=test'); + + expect( + gl.utils.getUrlParamsArray()[0], + ).toBe('label_name[]=test'); + + history.pushState('', '', '?'); + }); }); describe('gl.utils.handleLocationHash', () => { diff --git a/spec/javascripts/lib/utils/poll_spec.js b/spec/javascripts/lib/utils/poll_spec.js index 918b6d32c43..22f30191ab9 100644 --- a/spec/javascripts/lib/utils/poll_spec.js +++ b/spec/javascripts/lib/utils/poll_spec.js @@ -1,9 +1,5 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; import Poll from '~/lib/utils/poll'; -Vue.use(VueResource); - const waitForAllCallsToFinish = (service, waitForCount, successCallback) => { const timer = () => { setTimeout(() => { @@ -12,45 +8,33 @@ const waitForAllCallsToFinish = (service, waitForCount, successCallback) => { } else { timer(); } - }, 5); + }, 0); }; timer(); }; -class ServiceMock { - constructor(endpoint) { - this.service = Vue.resource(endpoint); - } +function mockServiceCall(service, response, shouldFail = false) { + const action = shouldFail ? Promise.reject : Promise.resolve; + const responseObject = response; + + if (!responseObject.headers) responseObject.headers = {}; - fetch() { - return this.service.get(); - } + service.fetch.and.callFake(action.bind(Promise, responseObject)); } describe('Poll', () => { - let callbacks; - let service; + const service = jasmine.createSpyObj('service', ['fetch']); + const callbacks = jasmine.createSpyObj('callbacks', ['success', 'error']); - beforeEach(() => { - callbacks = { - success: () => {}, - error: () => {}, - }; - - service = new ServiceMock('endpoint'); - - spyOn(callbacks, 'success'); - spyOn(callbacks, 'error'); - spyOn(service, 'fetch').and.callThrough(); + afterEach(() => { + callbacks.success.calls.reset(); + callbacks.error.calls.reset(); + service.fetch.calls.reset(); }); it('calls the success callback when no header for interval is provided', (done) => { - const successInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify([]), { status: 200 })); - }; - - Vue.http.interceptors.push(successInterceptor); + mockServiceCall(service, { status: 200 }); new Poll({ resource: service, @@ -63,18 +47,12 @@ describe('Poll', () => { expect(callbacks.success).toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, successInterceptor); - done(); - }, 0); + }); }); it('calls the error callback whe the http request returns an error', (done) => { - const errorInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify([]), { status: 500 })); - }; - - Vue.http.interceptors.push(errorInterceptor); + mockServiceCall(service, { status: 500 }, true); new Poll({ resource: service, @@ -86,42 +64,29 @@ describe('Poll', () => { waitForAllCallsToFinish(service, 1, () => { expect(callbacks.success).not.toHaveBeenCalled(); expect(callbacks.error).toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, errorInterceptor); done(); }); }); it('should call the success callback when the interval header is -1', (done) => { - const intervalInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify([]), { status: 200, headers: { 'poll-interval': -1 } })); - }; - - Vue.http.interceptors.push(intervalInterceptor); + mockServiceCall(service, { status: 200, headers: { 'poll-interval': -1 } }); new Poll({ resource: service, method: 'fetch', successCallback: callbacks.success, errorCallback: callbacks.error, - }).makeRequest(); - - setTimeout(() => { + }).makeRequest().then(() => { expect(callbacks.success).toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, intervalInterceptor); - done(); - }, 0); + }).catch(done.fail); }); it('starts polling when http status is 200 and interval header is provided', (done) => { - const pollInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify([]), { status: 200, headers: { 'poll-interval': 2 } })); - }; - - Vue.http.interceptors.push(pollInterceptor); + mockServiceCall(service, { status: 200, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ resource: service, @@ -141,19 +106,13 @@ describe('Poll', () => { expect(callbacks.success).toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, pollInterceptor); - done(); }); }); describe('stop', () => { it('stops polling when method is called', (done) => { - const pollInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify([]), { status: 200, headers: { 'poll-interval': 2 } })); - }; - - Vue.http.interceptors.push(pollInterceptor); + mockServiceCall(service, { status: 200, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ resource: service, @@ -174,8 +133,6 @@ describe('Poll', () => { expect(service.fetch).toHaveBeenCalledWith({ page: 1 }); expect(Polling.stop).toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, pollInterceptor); - done(); }); }); @@ -183,11 +140,7 @@ describe('Poll', () => { describe('restart', () => { it('should restart polling when its called', (done) => { - const pollInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify([]), { status: 200, headers: { 'poll-interval': 2 } })); - }; - - Vue.http.interceptors.push(pollInterceptor); + mockServiceCall(service, { status: 200, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ resource: service, @@ -215,8 +168,6 @@ describe('Poll', () => { expect(Polling.stop).toHaveBeenCalled(); expect(Polling.restart).toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, pollInterceptor); - done(); }); }); diff --git a/spec/javascripts/line_highlighter_spec.js b/spec/javascripts/line_highlighter_spec.js index a1fd2d38968..b1b08989028 100644 --- a/spec/javascripts/line_highlighter_spec.js +++ b/spec/javascripts/line_highlighter_spec.js @@ -58,7 +58,7 @@ require('~/line_highlighter'); return expect(func).not.toThrow(); }); }); - describe('#clickHandler', function() { + describe('clickHandler', function() { it('handles clicking on a child icon element', function() { var spy; spy = spyOn(this["class"], 'setHash').and.callThrough(); @@ -176,7 +176,7 @@ require('~/line_highlighter'); }); }); }); - describe('#hashToRange', function() { + describe('hashToRange', function() { beforeEach(function() { return this.subject = this["class"].hashToRange; }); @@ -190,7 +190,7 @@ require('~/line_highlighter'); return expect(this.subject('#foo')).toEqual([null, null]); }); }); - describe('#highlightLine', function() { + describe('highlightLine', function() { beforeEach(function() { return this.subject = this["class"].highlightLine; }); @@ -203,7 +203,7 @@ require('~/line_highlighter'); return expect($('#LC13')).toHaveClass(this.css); }); }); - return describe('#setHash', function() { + return describe('setHash', function() { beforeEach(function() { return this.subject = this["class"].setHash; }); diff --git a/spec/javascripts/merge_request_notes_spec.js b/spec/javascripts/merge_request_notes_spec.js new file mode 100644 index 00000000000..e54acfa8e44 --- /dev/null +++ b/spec/javascripts/merge_request_notes_spec.js @@ -0,0 +1,61 @@ +/* global Notes */ + +import 'vendor/autosize'; +import '~/gl_form'; +import '~/lib/utils/text_utility'; +import '~/render_gfm'; +import '~/render_math'; +import '~/notes'; + +describe('Merge request notes', () => { + window.gon = window.gon || {}; + window.gl = window.gl || {}; + gl.utils = gl.utils || {}; + + const fixture = 'merge_requests/diff_comment.html.raw'; + preloadFixtures(fixture); + + beforeEach(() => { + loadFixtures(fixture); + gl.utils.disableButtonIfEmptyField = _.noop; + window.project_uploads_path = 'http://test.host/uploads'; + $('body').data('page', 'projects:merge_requests:show'); + window.gon.current_user_id = $('.note:last').data('author-id'); + + return new Notes('', []); + }); + + describe('up arrow', () => { + it('edits last comment when triggered in main form', () => { + const upArrowEvent = $.Event('keydown'); + upArrowEvent.which = 38; + + spyOnEvent('.note:last .js-note-edit', 'click'); + + $('.js-note-text').trigger(upArrowEvent); + + expect('click').toHaveBeenTriggeredOn('.note:last .js-note-edit'); + }); + + it('edits last comment in discussion when triggered in discussion form', (done) => { + const upArrowEvent = $.Event('keydown'); + upArrowEvent.which = 38; + + spyOnEvent('.note-discussion .js-note-edit', 'click'); + + $('.js-discussion-reply-button').click(); + + setTimeout(() => { + expect( + $('.note-discussion .js-note-text'), + ).toExist(); + + $('.note-discussion .js-note-text').trigger(upArrowEvent); + + expect('click').toHaveBeenTriggeredOn('.note-discussion .js-note-edit'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/merge_request_tabs_spec.js b/spec/javascripts/merge_request_tabs_spec.js index e437333d522..254a41db160 100644 --- a/spec/javascripts/merge_request_tabs_spec.js +++ b/spec/javascripts/merge_request_tabs_spec.js @@ -47,7 +47,7 @@ require('vendor/jquery.scrollTo'); this.class.destroyPipelinesView(); }); - describe('#activateTab', function () { + describe('activateTab', function () { beforeEach(function () { spyOn($, 'ajax').and.callFake(function () {}); loadFixtures('merge_requests/merge_request_with_task_list.html.raw'); @@ -71,7 +71,7 @@ require('vendor/jquery.scrollTo'); }); }); - describe('#opensInNewTab', function () { + describe('opensInNewTab', function () { var tabUrl; var windowTarget = '_blank'; @@ -152,7 +152,7 @@ require('vendor/jquery.scrollTo'); }); }); - describe('#setCurrentAction', function () { + describe('setCurrentAction', function () { beforeEach(function () { spyOn($, 'ajax').and.callFake(function () {}); this.subject = this.class.setCurrentAction; @@ -221,7 +221,7 @@ require('vendor/jquery.scrollTo'); }); }); - describe('#tabShown', () => { + describe('tabShown', () => { beforeEach(function () { spyOn($, 'ajax').and.callFake(function (options) { options.success({ html: '' }); @@ -281,7 +281,7 @@ require('vendor/jquery.scrollTo'); }); }); - describe('#loadDiff', function () { + describe('loadDiff', function () { it('requires an absolute pathname', function () { spyOn($, 'ajax').and.callFake(function (options) { expect(options.url).toEqual('/foo/bar/merge_requests/1/diffs.json'); diff --git a/spec/javascripts/merge_request_widget_spec.js b/spec/javascripts/merge_request_widget_spec.js deleted file mode 100644 index 88dae8c3e06..00000000000 --- a/spec/javascripts/merge_request_widget_spec.js +++ /dev/null @@ -1,199 +0,0 @@ -/* eslint-disable space-before-function-paren, quotes, comma-dangle, dot-notation, quote-props, no-var, max-len */ - -require('~/merge_request_widget'); -require('~/smart_interval'); -require('~/lib/utils/datetime_utility'); - -(function() { - describe('MergeRequestWidget', function() { - beforeEach(function() { - window.notifyPermissions = function() {}; - window.notify = function() {}; - this.opts = { - ci_status_url: "http://sampledomain.local/ci/getstatus", - ci_environments_status_url: "http://sampledomain.local/ci/getenvironmentsstatus", - ci_status: "", - ci_message: { - normal: "Build {{status}} for \"{{title}}\"", - preparing: "{{status}} build for \"{{title}}\"" - }, - ci_title: { - preparing: "{{status}} build", - normal: "Build {{status}}" - }, - gitlab_icon: "gitlab_logo.png", - ci_pipeline: 80, - ci_sha: "12a34bc5", - builds_path: "http://sampledomain.local/sampleBuildsPath", - commits_path: "http://sampledomain.local/commits", - pipeline_path: "http://sampledomain.local/pipelines" - }; - this["class"] = new window.gl.MergeRequestWidget(this.opts); - }); - - describe('getCIEnvironmentsStatus', function() { - beforeEach(function() { - this.ciEnvironmentsStatusData = [{ - created_at: '2016-09-12T13:38:30.636Z', - environment_id: 1, - environment_name: 'env1', - external_url: 'https://test-url.com', - external_url_formatted: 'test-url.com' - }]; - - spyOn(jQuery, 'getJSON').and.callFake(function(req, cb) { - cb(this.ciEnvironmentsStatusData); - }.bind(this)); - }); - - it('should call renderEnvironments when the environments property is set', function() { - const spy = spyOn(this.class, 'renderEnvironments').and.stub(); - this.class.getCIEnvironmentsStatus(); - expect(spy).toHaveBeenCalledWith(this.ciEnvironmentsStatusData); - }); - - it('should not call renderEnvironments when the environments property is not set', function() { - this.ciEnvironmentsStatusData = null; - const spy = spyOn(this.class, 'renderEnvironments').and.stub(); - this.class.getCIEnvironmentsStatus(); - expect(spy).not.toHaveBeenCalled(); - }); - }); - - describe('renderEnvironments', function() { - describe('should render correct timeago', function() { - beforeEach(function() { - this.environments = [{ - id: 'test-environment-id', - url: 'testurl', - deployed_at: new Date().toISOString(), - deployed_at_formatted: true - }]; - }); - - function getTimeagoText(template) { - var el = document.createElement('html'); - el.innerHTML = template; - return el.querySelector('.js-environment-timeago').innerText.trim(); - } - - it('should render less than a minute ago text', function() { - spyOn(this.class.$widgetBody, 'before').and.callFake(function(template) { - expect(getTimeagoText(template)).toBe('less than a minute ago.'); - }); - - this.class.renderEnvironments(this.environments); - }); - - it('should render about an hour ago text', function() { - var oneHourAgo = new Date(); - oneHourAgo.setHours(oneHourAgo.getHours() - 1); - - this.environments[0].deployed_at = oneHourAgo.toISOString(); - spyOn(this.class.$widgetBody, 'before').and.callFake(function(template) { - expect(getTimeagoText(template)).toBe('about an hour ago.'); - }); - - this.class.renderEnvironments(this.environments); - }); - - it('should render about 2 hours ago text', function() { - var twoHoursAgo = new Date(); - twoHoursAgo.setHours(twoHoursAgo.getHours() - 2); - - this.environments[0].deployed_at = twoHoursAgo.toISOString(); - spyOn(this.class.$widgetBody, 'before').and.callFake(function(template) { - expect(getTimeagoText(template)).toBe('about 2 hours ago.'); - }); - - this.class.renderEnvironments(this.environments); - }); - }); - }); - - describe('mergeInProgress', function() { - it('should display error with h4 tag', function() { - spyOn(this.class.$widgetBody, 'html').and.callFake(function(html) { - expect(html).toBe('<h4>Sorry, something went wrong.</h4>'); - }); - spyOn($, 'ajax').and.callFake(function(e) { - e.success({ merge_error: 'Sorry, something went wrong.' }); - }); - this.class.mergeInProgress(null); - }); - }); - - describe('getCIStatus', function() { - beforeEach(function() { - this.ciStatusData = { - "title": "Sample MR title", - "pipeline": 80, - "sha": "12a34bc5", - "status": "success", - "coverage": 98 - }; - - spyOn(jQuery, 'getJSON').and.callFake((function(_this) { - return function(req, cb) { - return cb(_this.ciStatusData); - }; - })(this)); - }); - it('should call showCIStatus even if a notification should not be displayed', function() { - var spy; - spy = spyOn(this["class"], 'showCIStatus').and.stub(); - spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {}); - this["class"].getCIStatus(false); - return expect(spy).toHaveBeenCalledWith(this.ciStatusData.status); - }); - it('should call showCIStatus when a notification should be displayed', function() { - var spy; - spy = spyOn(this["class"], 'showCIStatus').and.stub(); - spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {}); - this["class"].getCIStatus(true); - return expect(spy).toHaveBeenCalledWith(this.ciStatusData.status); - }); - it('should call showCICoverage when the coverage rate is set', function() { - var spy; - spy = spyOn(this["class"], 'showCICoverage').and.stub(); - spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {}); - this["class"].getCIStatus(false); - return expect(spy).toHaveBeenCalledWith(this.ciStatusData.coverage); - }); - it('should not call showCICoverage when the coverage rate is not set', function() { - var spy; - this.ciStatusData.coverage = null; - spy = spyOn(this["class"], 'showCICoverage').and.stub(); - spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {}); - this["class"].getCIStatus(false); - return expect(spy).not.toHaveBeenCalled(); - }); - it('should not display a notification on the first check after the widget has been created', function() { - var spy; - spy = spyOn(window, 'notify'); - spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {}); - this["class"] = new window.gl.MergeRequestWidget(this.opts); - this["class"].getCIStatus(true); - return expect(spy).not.toHaveBeenCalled(); - }); - it('should update the pipeline URL when the pipeline changes', function() { - var spy; - spy = spyOn(this["class"], 'updatePipelineUrls').and.stub(); - spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {}); - this["class"].getCIStatus(false); - this.ciStatusData.pipeline += 1; - this["class"].getCIStatus(false); - return expect(spy).toHaveBeenCalled(); - }); - it('should update the commit URL when the sha changes', function() { - var spy; - spy = spyOn(this["class"], 'updateCommitUrls').and.stub(); - spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {}); - this["class"].getCIStatus(false); - this.ciStatusData.sha = "9b50b99a"; - this["class"].getCIStatus(false); - return expect(spy).toHaveBeenCalled(); - }); - }); - }); -}).call(window); diff --git a/spec/javascripts/merged_buttons_spec.js b/spec/javascripts/merged_buttons_spec.js deleted file mode 100644 index b5c5e60dd97..00000000000 --- a/spec/javascripts/merged_buttons_spec.js +++ /dev/null @@ -1,44 +0,0 @@ -/* global MergedButtons */ - -import '~/merged_buttons'; - -describe('MergedButtons', () => { - const fixturesPath = 'merge_requests/merged_merge_request.html.raw'; - preloadFixtures(fixturesPath); - - beforeEach(() => { - loadFixtures(fixturesPath); - this.mergedButtons = new MergedButtons(); - this.$removeBranchWidget = $('.remove_source_branch_widget:not(.failed)'); - this.$removeBranchProgress = $('.remove_source_branch_in_progress'); - this.$removeBranchFailed = $('.remove_source_branch_widget.failed'); - this.$removeBranchButton = $('.remove_source_branch'); - }); - - describe('removeSourceBranch', () => { - it('shows loader', () => { - $('.remove_source_branch').trigger('click'); - expect(this.$removeBranchProgress).toBeVisible(); - expect(this.$removeBranchWidget).not.toBeVisible(); - }); - }); - - describe('removeBranchSuccess', () => { - it('refreshes page when branch removed', () => { - spyOn(gl.utils, 'refreshCurrentPage').and.stub(); - const response = { status: 200 }; - this.$removeBranchButton.trigger('ajax:success', response, 'xhr'); - expect(gl.utils.refreshCurrentPage).toHaveBeenCalled(); - }); - }); - - describe('removeBranchError', () => { - it('shows error message', () => { - const response = { status: 500 }; - this.$removeBranchButton.trigger('ajax:error', response, 'xhr'); - expect(this.$removeBranchFailed).toBeVisible(); - expect(this.$removeBranchProgress).not.toBeVisible(); - expect(this.$removeBranchWidget).not.toBeVisible(); - }); - }); -}); diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index cfd599f793e..87745ea9817 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -14,6 +14,7 @@ import '~/notes'; gl.utils = gl.utils || {}; describe('Notes', function() { + const FLASH_TYPE_ALERT = 'alert'; var commentsTemplate = 'issues/issue_with_comment.html.raw'; preloadFixtures(commentsTemplate); @@ -376,13 +377,20 @@ import '~/notes'; this.notes = new Notes('', []); }); - it('should return true when comment has slash commands', () => { - const sampleComment = '/wip /milestone %1.0 /merge /unassign Merging this'; + it('should return true when comment begins with a slash command', () => { + const sampleComment = '/wip\n/milestone %1.0\n/merge\n/unassign Merging this'; const hasSlashCommands = this.notes.hasSlashCommands(sampleComment); expect(hasSlashCommands).toBeTruthy(); }); + it('should return false when comment does NOT begin with a slash command', () => { + const sampleComment = 'Hey, /unassign Merging this'; + const hasSlashCommands = this.notes.hasSlashCommands(sampleComment); + + expect(hasSlashCommands).toBeFalsy(); + }); + it('should return false when comment does NOT have any slash commands', () => { const sampleComment = 'Looking good, Awesome!'; const hasSlashCommands = this.notes.hasSlashCommands(sampleComment); @@ -392,14 +400,28 @@ import '~/notes'; }); describe('stripSlashCommands', () => { - const REGEX_SLASH_COMMANDS = /\/\w+/g; + it('should strip slash commands from the comment which begins with a slash command', () => { + this.notes = new Notes(); + const sampleComment = '/wip\n/milestone %1.0\n/merge\n/unassign Merging this'; + const stripedComment = this.notes.stripSlashCommands(sampleComment); + + expect(stripedComment).toBe(''); + }); + + it('should strip slash commands from the comment but leaves plain comment if it is present', () => { + this.notes = new Notes(); + const sampleComment = '/wip\n/milestone %1.0\n/merge\n/unassign\nMerging this'; + const stripedComment = this.notes.stripSlashCommands(sampleComment); + + expect(stripedComment).toBe('Merging this'); + }); - it('should strip slash commands from the comment', () => { + it('should NOT strip string that has slashes within', () => { this.notes = new Notes(); - const sampleComment = '/wip /milestone %1.0 /merge /unassign Merging this'; + const sampleComment = 'http://127.0.0.1:3000/root/gitlab-shell/issues/1'; const stripedComment = this.notes.stripSlashCommands(sampleComment); - expect(REGEX_SLASH_COMMANDS.test(stripedComment)).toBeFalsy(); + expect(stripedComment).toBe(sampleComment); }); }); @@ -411,6 +433,22 @@ import '~/notes'; beforeEach(() => { this.notes = new Notes('', []); + spyOn(_, 'escape').and.callFake((comment) => { + const escapedString = comment.replace(/["&'<>]/g, (a) => { + const escapedToken = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '`': '`' + }[a]; + + return escapedToken; + }); + + return escapedString; + }); }); it('should return constructed placeholder element for regular note based on form contents', () => { @@ -431,7 +469,21 @@ import '~/notes'; expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeFalsy(); expect($tempNoteHeader.find('.hidden-xs').text().trim()).toEqual(currentUserFullname); expect($tempNoteHeader.find('.note-headline-light').text().trim()).toEqual(`@${currentUsername}`); - expect($tempNote.find('.note-body .note-text').text().trim()).toEqual(sampleComment); + expect($tempNote.find('.note-body .note-text p').text().trim()).toEqual(sampleComment); + }); + + it('should escape HTML characters from note based on form contents', () => { + const commentWithHtml = '<script>alert("Boom!");</script>'; + const $tempNote = this.notes.createPlaceholderNote({ + formContent: commentWithHtml, + uniqueId, + isDiscussionNote: false, + currentUsername, + currentUserFullname + }); + + expect(_.escape).toHaveBeenCalledWith(commentWithHtml); + expect($tempNote.find('.note-body .note-text p').html()).toEqual('<script>alert("Boom!");</script>'); }); it('should return constructed placeholder element for discussion note based on form contents', () => { @@ -447,5 +499,33 @@ import '~/notes'; expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeTruthy(); }); }); + + describe('appendFlash', () => { + beforeEach(() => { + this.notes = new Notes(); + }); + + it('shows a flash message', () => { + this.notes.addFlash('Error message', FLASH_TYPE_ALERT, this.notes.parentTimeline); + + expect(document.querySelectorAll('.flash-alert').length).toBe(1); + }); + }); + + describe('clearFlash', () => { + beforeEach(() => { + $(document).off('ajax:success'); + this.notes = new Notes(); + }); + + it('removes all the associated flash messages', () => { + this.notes.addFlash('Error message 1', FLASH_TYPE_ALERT, this.notes.parentTimeline); + this.notes.addFlash('Error message 2', FLASH_TYPE_ALERT, this.notes.parentTimeline); + + this.notes.clearFlash(); + + expect(document.querySelectorAll('.flash-alert').length).toBe(0); + }); + }); }); }).call(window); diff --git a/spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js b/spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js index 1d05f37cb36..6120d224ac0 100644 --- a/spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js +++ b/spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js @@ -4,8 +4,15 @@ import PipelineSchedulesCallout from '~/pipeline_schedules/components/pipeline_s const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout); const cookieKey = 'pipeline_schedules_callout_dismissed'; +const docsUrl = 'help/ci/scheduled_pipelines'; describe('Pipeline Schedule Callout', () => { + beforeEach(() => { + setFixtures(` + <div id='pipeline-schedules-callout' data-docs-url=${docsUrl}></div> + `); + }); + describe('independent of cookies', () => { beforeEach(() => { this.calloutComponent = new PipelineSchedulesCalloutComponent().$mount(); @@ -18,6 +25,10 @@ describe('Pipeline Schedule Callout', () => { it('correctly sets illustrationSvg', () => { expect(this.calloutComponent.illustrationSvg).toContain('<svg'); }); + + it('correctly sets docsUrl', () => { + expect(this.calloutComponent.docsUrl).toContain(docsUrl); + }); }); describe(`when ${cookieKey} cookie is set`, () => { @@ -61,6 +72,10 @@ describe('Pipeline Schedule Callout', () => { expect(this.calloutComponent.$el.outerHTML).toContain('runs pipelines in the future'); }); + it('renders the documentation url', () => { + expect(this.calloutComponent.$el.outerHTML).toContain(docsUrl); + }); + it('updates calloutDismissed when close button is clicked', (done) => { this.calloutComponent.$el.querySelector('#dismiss-callout-btn').click(); diff --git a/spec/javascripts/pipelines/graph/graph_component_spec.js b/spec/javascripts/pipelines/graph/graph_component_spec.js index a756617e65e..6bd0eb86263 100644 --- a/spec/javascripts/pipelines/graph/graph_component_spec.js +++ b/spec/javascripts/pipelines/graph/graph_component_spec.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import graphComponent from '~/pipelines/components/graph/graph_component.vue'; +import graphJSON from './mock_data'; describe('graph component', () => { preloadFixtures('static/graph.html.raw'); @@ -20,41 +21,7 @@ describe('graph component', () => { describe('with a successfull response', () => { const interceptor = (request, next) => { - next(request.respondWith(JSON.stringify({ - details: { - stages: [{ - name: 'test', - title: 'test: passed', - status: { - icon: 'icon_status_success', - text: 'passed', - label: 'passed', - details_path: '/root/ci-mock/pipelines/123#test', - }, - path: '/root/ci-mock/pipelines/123#test', - groups: [{ - name: 'test', - size: 1, - jobs: [{ - id: 4153, - name: 'test', - status: { - icon: 'icon_status_success', - text: 'passed', - label: 'passed', - details_path: '/root/ci-mock/builds/4153', - action: { - icon: 'icon_action_retry', - title: 'Retry', - path: '/root/ci-mock/builds/4153/retry', - method: 'post', - }, - }, - }], - }], - }], - }, - }), { + next(request.respondWith(JSON.stringify(graphJSON), { status: 200, })); }; @@ -73,6 +40,18 @@ describe('graph component', () => { setTimeout(() => { expect(component.$el.classList.contains('js-pipeline-graph')).toEqual(true); + expect( + component.$el.querySelector('.stage-column:first-child').classList.contains('no-margin'), + ).toEqual(true); + + expect( + component.$el.querySelector('.stage-column:nth-child(2)').classList.contains('left-margin'), + ).toEqual(true); + + expect( + component.$el.querySelector('.stage-column:nth-child(2) .build:nth-child(1)').classList.contains('left-connector'), + ).toEqual(true); + expect(component.$el.querySelector('loading-icon')).toBe(null); expect(component.$el.querySelector('.stage-column-list')).toBeDefined(); diff --git a/spec/javascripts/pipelines/graph/mock_data.js b/spec/javascripts/pipelines/graph/mock_data.js new file mode 100644 index 00000000000..56c522b7f77 --- /dev/null +++ b/spec/javascripts/pipelines/graph/mock_data.js @@ -0,0 +1,232 @@ +/* eslint-disable quote-props, quotes, comma-dangle */ +export default { + "id": 123, + "user": { + "name": "Root", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": null, + "web_url": "http://localhost:3000/root" + }, + "active": false, + "coverage": null, + "path": "/root/ci-mock/pipelines/123", + "details": { + "status": { + "icon": "icon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/ci-mock/pipelines/123", + "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico" + }, + "duration": 9, + "finished_at": "2017-04-19T14:30:27.542Z", + "stages": [{ + "name": "test", + "title": "test: passed", + "groups": [{ + "name": "test", + "size": 1, + "status": { + "icon": "icon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/ci-mock/builds/4153", + "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", + "action": { + "icon": "icon_action_retry", + "title": "Retry", + "path": "/root/ci-mock/builds/4153/retry", + "method": "post" + } + }, + "jobs": [{ + "id": 4153, + "name": "test", + "build_path": "/root/ci-mock/builds/4153", + "retry_path": "/root/ci-mock/builds/4153/retry", + "playable": false, + "created_at": "2017-04-13T09:25:18.959Z", + "updated_at": "2017-04-13T09:25:23.118Z", + "status": { + "icon": "icon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/ci-mock/builds/4153", + "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", + "action": { + "icon": "icon_action_retry", + "title": "Retry", + "path": "/root/ci-mock/builds/4153/retry", + "method": "post" + } + } + }] + }], + "status": { + "icon": "icon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/ci-mock/pipelines/123#test", + "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico" + }, + "path": "/root/ci-mock/pipelines/123#test", + "dropdown_path": "/root/ci-mock/pipelines/123/stage.json?stage=test" + }, { + "name": "deploy", + "title": "deploy: passed", + "groups": [{ + "name": "deploy to production", + "size": 1, + "status": { + "icon": "icon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/ci-mock/builds/4166", + "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", + "action": { + "icon": "icon_action_retry", + "title": "Retry", + "path": "/root/ci-mock/builds/4166/retry", + "method": "post" + } + }, + "jobs": [{ + "id": 4166, + "name": "deploy to production", + "build_path": "/root/ci-mock/builds/4166", + "retry_path": "/root/ci-mock/builds/4166/retry", + "playable": false, + "created_at": "2017-04-19T14:29:46.463Z", + "updated_at": "2017-04-19T14:30:27.498Z", + "status": { + "icon": "icon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/ci-mock/builds/4166", + "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", + "action": { + "icon": "icon_action_retry", + "title": "Retry", + "path": "/root/ci-mock/builds/4166/retry", + "method": "post" + } + } + }] + }, { + "name": "deploy to staging", + "size": 1, + "status": { + "icon": "icon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/ci-mock/builds/4159", + "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", + "action": { + "icon": "icon_action_retry", + "title": "Retry", + "path": "/root/ci-mock/builds/4159/retry", + "method": "post" + } + }, + "jobs": [{ + "id": 4159, + "name": "deploy to staging", + "build_path": "/root/ci-mock/builds/4159", + "retry_path": "/root/ci-mock/builds/4159/retry", + "playable": false, + "created_at": "2017-04-18T16:32:08.420Z", + "updated_at": "2017-04-18T16:32:12.631Z", + "status": { + "icon": "icon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/ci-mock/builds/4159", + "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", + "action": { + "icon": "icon_action_retry", + "title": "Retry", + "path": "/root/ci-mock/builds/4159/retry", + "method": "post" + } + } + }] + }], + "status": { + "icon": "icon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/ci-mock/pipelines/123#deploy", + "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico" + }, + "path": "/root/ci-mock/pipelines/123#deploy", + "dropdown_path": "/root/ci-mock/pipelines/123/stage.json?stage=deploy" + }], + "artifacts": [], + "manual_actions": [{ + "name": "deploy to production", + "path": "/root/ci-mock/builds/4166/play", + "playable": false + }] + }, + "flags": { + "latest": true, + "triggered": false, + "stuck": false, + "yaml_errors": false, + "retryable": false, + "cancelable": false + }, + "ref": { + "name": "master", + "path": "/root/ci-mock/tree/master", + "tag": false, + "branch": true + }, + "commit": { + "id": "798e5f902592192afaba73f4668ae30e56eae492", + "short_id": "798e5f90", + "title": "Merge branch 'new-branch' into 'master'\r", + "created_at": "2017-04-13T10:25:17.000+01:00", + "parent_ids": ["54d483b1ed156fbbf618886ddf7ab023e24f8738", "c8e2d38a6c538822e81c57022a6e3a0cfedebbcc"], + "message": "Merge branch 'new-branch' into 'master'\r\n\r\nAdd new file\r\n\r\nSee merge request !1", + "author_name": "Root", + "author_email": "admin@example.com", + "authored_date": "2017-04-13T10:25:17.000+01:00", + "committer_name": "Root", + "committer_email": "admin@example.com", + "committed_date": "2017-04-13T10:25:17.000+01:00", + "author": { + "name": "Root", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": null, + "web_url": "http://localhost:3000/root" + }, + "author_gravatar_url": null, + "commit_url": "http://localhost:3000/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492", + "commit_path": "/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492" + }, + "created_at": "2017-04-13T09:25:18.881Z", + "updated_at": "2017-04-19T14:30:27.561Z" +}; diff --git a/spec/javascripts/pipelines/mock_data.js b/spec/javascripts/pipelines/mock_data.js deleted file mode 100644 index 2365a662b9f..00000000000 --- a/spec/javascripts/pipelines/mock_data.js +++ /dev/null @@ -1,107 +0,0 @@ -export default { - pipelines: [{ - id: 115, - user: { - name: 'Root', - username: 'root', - id: 1, - state: 'active', - avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', - web_url: 'http://localhost:3000/root', - }, - path: '/root/review-app/pipelines/115', - details: { - status: { - icon: 'icon_status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - has_details: true, - details_path: '/root/review-app/pipelines/115', - }, - duration: null, - finished_at: '2017-03-17T19:00:15.996Z', - stages: [{ - name: 'build', - title: 'build: failed', - status: { - icon: 'icon_status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - has_details: true, - details_path: '/root/review-app/pipelines/115#build', - }, - path: '/root/review-app/pipelines/115#build', - dropdown_path: '/root/review-app/pipelines/115/stage.json?stage=build', - }, - { - name: 'review', - title: 'review: skipped', - status: { - icon: 'icon_status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - has_details: true, - details_path: '/root/review-app/pipelines/115#review', - }, - path: '/root/review-app/pipelines/115#review', - dropdown_path: '/root/review-app/pipelines/115/stage.json?stage=review', - }], - artifacts: [], - manual_actions: [{ - name: 'stop_review', - path: '/root/review-app/builds/3766/play', - }], - }, - flags: { - latest: true, - triggered: false, - stuck: false, - yaml_errors: false, - retryable: true, - cancelable: false, - }, - ref: { - name: 'thisisabranch', - path: '/root/review-app/tree/thisisabranch', - tag: false, - branch: true, - }, - commit: { - id: '9e87f87625b26c42c59a2ee0398f81d20cdfe600', - short_id: '9e87f876', - title: 'Update README.md', - created_at: '2017-03-15T22:58:28.000+00:00', - parent_ids: ['3744f9226e699faec2662a8b267e5d3fd0bfff0e'], - message: 'Update README.md', - author_name: 'Root', - author_email: 'admin@example.com', - authored_date: '2017-03-15T22:58:28.000+00:00', - committer_name: 'Root', - committer_email: 'admin@example.com', - committed_date: '2017-03-15T22:58:28.000+00:00', - author: { - name: 'Root', - username: 'root', - id: 1, - state: 'active', - avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', - web_url: 'http://localhost:3000/root', - }, - author_gravatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', - commit_url: 'http://localhost:3000/root/review-app/commit/9e87f87625b26c42c59a2ee0398f81d20cdfe600', - commit_path: '/root/review-app/commit/9e87f87625b26c42c59a2ee0398f81d20cdfe600', - }, - retry_path: '/root/review-app/pipelines/115/retry', - created_at: '2017-03-15T22:58:33.436Z', - updated_at: '2017-03-17T19:00:15.997Z', - }], - count: { - all: 52, - running: 0, - pending: 0, - finished: 52, - }, -}; diff --git a/spec/javascripts/pipelines/pipelines_spec.js b/spec/javascripts/pipelines/pipelines_spec.js index e9c05f74ce6..3a56156358b 100644 --- a/spec/javascripts/pipelines/pipelines_spec.js +++ b/spec/javascripts/pipelines/pipelines_spec.js @@ -1,15 +1,20 @@ import Vue from 'vue'; import pipelinesComp from '~/pipelines/pipelines'; import Store from '~/pipelines/stores/pipelines_store'; -import pipelinesData from './mock_data'; describe('Pipelines', () => { + const jsonFixtureName = 'pipelines/pipelines.json'; + preloadFixtures('static/pipelines.html.raw'); + preloadFixtures(jsonFixtureName); let PipelinesComponent; + let pipeline; beforeEach(() => { loadFixtures('static/pipelines.html.raw'); + const pipelines = getJSONFixture(jsonFixtureName).pipelines; + pipeline = pipelines.find(p => p.id === 1); PipelinesComponent = Vue.extend(pipelinesComp); }); @@ -17,7 +22,7 @@ describe('Pipelines', () => { describe('successfull request', () => { describe('with pipelines', () => { const pipelinesInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify(pipelinesData), { + next(request.respondWith(JSON.stringify(pipeline), { status: 200, })); }; diff --git a/spec/javascripts/project_title_spec.js b/spec/javascripts/project_title_spec.js index 3a1d4e2440f..5c51e855401 100644 --- a/spec/javascripts/project_title_spec.js +++ b/spec/javascripts/project_title_spec.js @@ -2,7 +2,6 @@ /* global Project */ require('select2/select2.js'); -require('~/lib/utils/type_utility'); require('~/gl_dropdown'); require('~/api'); require('~/project_select'); diff --git a/spec/javascripts/search_autocomplete_spec.js b/spec/javascripts/search_autocomplete_spec.js index aaf058bd755..fa52a8a0dd2 100644 --- a/spec/javascripts/search_autocomplete_spec.js +++ b/spec/javascripts/search_autocomplete_spec.js @@ -3,7 +3,6 @@ require('~/gl_dropdown'); require('~/search_autocomplete'); require('~/lib/utils/common_utils'); -require('~/lib/utils/type_utility'); require('vendor/fuzzaldrin-plus'); (function() { diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js index 9e19dabd0e3..757b8d595a4 100644 --- a/spec/javascripts/shortcuts_issuable_spec.js +++ b/spec/javascripts/shortcuts_issuable_spec.js @@ -13,7 +13,7 @@ require('~/shortcuts_issuable'); document.querySelector('.js-new-note-form').classList.add('js-main-target-form'); this.shortcut = new ShortcutsIssuable(); }); - describe('#replyWithSelectedText', function() { + describe('replyWithSelectedText', function() { var stubSelection; // Stub window.gl.utils.getSelectedFragment to return a node with the provided HTML. stubSelection = function(html) { diff --git a/spec/javascripts/sidebar/sidebar_assignees_spec.js b/spec/javascripts/sidebar/sidebar_assignees_spec.js index e0df0a3228f..865951b2ad7 100644 --- a/spec/javascripts/sidebar/sidebar_assignees_spec.js +++ b/spec/javascripts/sidebar/sidebar_assignees_spec.js @@ -24,6 +24,7 @@ describe('sidebar assignees', () => { SidebarService.singleton = null; SidebarStore.singleton = null; SidebarMediator.singleton = null; + Vue.http.interceptors = _.without(Vue.http.interceptors, Mock.sidebarMockInterceptor); }); it('calls the mediator when saves the assignees', () => { diff --git a/spec/javascripts/sidebar/sidebar_bundle_spec.js b/spec/javascripts/sidebar/sidebar_bundle_spec.js deleted file mode 100644 index 7760b34e071..00000000000 --- a/spec/javascripts/sidebar/sidebar_bundle_spec.js +++ /dev/null @@ -1,42 +0,0 @@ -import Vue from 'vue'; -import SidebarBundleDomContentLoaded from '~/sidebar/sidebar_bundle'; -import SidebarTimeTracking from '~/sidebar/components/time_tracking/sidebar_time_tracking'; -import SidebarMediator from '~/sidebar/sidebar_mediator'; -import SidebarService from '~/sidebar/services/sidebar_service'; -import SidebarStore from '~/sidebar/stores/sidebar_store'; -import Mock from './mock_data'; - -describe('sidebar bundle', () => { - gl.sidebarOptions = Mock.mediator; - - beforeEach(() => { - spyOn(SidebarTimeTracking.methods, 'listenForSlashCommands').and.callFake(() => { }); - preloadFixtures('issues/open-issue.html.raw'); - Vue.http.interceptors.push(Mock.sidebarMockInterceptor); - loadFixtures('issues/open-issue.html.raw'); - spyOn(Vue.prototype, '$mount'); - SidebarBundleDomContentLoaded(); - this.mediator = new SidebarMediator(); - }); - - afterEach(() => { - SidebarService.singleton = null; - SidebarStore.singleton = null; - SidebarMediator.singleton = null; - }); - - it('the mediator should be already defined with some data', () => { - SidebarBundleDomContentLoaded(); - - expect(this.mediator.store).toBeDefined(); - expect(this.mediator.service).toBeDefined(); - expect(this.mediator.store.currentUser).toEqual(Mock.mediator.currentUser); - expect(this.mediator.store.rootPath).toEqual(Mock.mediator.rootPath); - expect(this.mediator.store.endPoint).toEqual(Mock.mediator.endPoint); - expect(this.mediator.store.editable).toEqual(Mock.mediator.editable); - }); - - it('the sidebar time tracking and assignees components to have been mounted', () => { - expect(Vue.prototype.$mount).toHaveBeenCalledTimes(2); - }); -}); diff --git a/spec/javascripts/sidebar/sidebar_mediator_spec.js b/spec/javascripts/sidebar/sidebar_mediator_spec.js index 2b00fa17334..e246f41ee82 100644 --- a/spec/javascripts/sidebar/sidebar_mediator_spec.js +++ b/spec/javascripts/sidebar/sidebar_mediator_spec.js @@ -14,6 +14,7 @@ describe('Sidebar mediator', () => { SidebarService.singleton = null; SidebarStore.singleton = null; SidebarMediator.singleton = null; + Vue.http.interceptors = _.without(Vue.http.interceptors, Mock.sidebarMockInterceptor); }); it('assigns yourself ', () => { diff --git a/spec/javascripts/sidebar/sidebar_service_spec.js b/spec/javascripts/sidebar/sidebar_service_spec.js index d41162096a6..91a4dd669a7 100644 --- a/spec/javascripts/sidebar/sidebar_service_spec.js +++ b/spec/javascripts/sidebar/sidebar_service_spec.js @@ -10,6 +10,7 @@ describe('Sidebar service', () => { afterEach(() => { SidebarService.singleton = null; + Vue.http.interceptors = _.without(Vue.http.interceptors, Mock.sidebarMockInterceptor); }); it('gets the data', (done) => { diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js index 07dc51a7815..0464b5d2329 100644 --- a/spec/javascripts/test_bundle.js +++ b/spec/javascripts/test_bundle.js @@ -1,8 +1,8 @@ // enable test fixtures require('jasmine-jquery'); -jasmine.getFixtures().fixturesPath = 'base/spec/javascripts/fixtures'; -jasmine.getJSONFixtures().fixturesPath = 'base/spec/javascripts/fixtures'; +jasmine.getFixtures().fixturesPath = '/base/spec/javascripts/fixtures'; +jasmine.getJSONFixtures().fixturesPath = '/base/spec/javascripts/fixtures'; // include common libraries require('~/commons/index.js'); @@ -55,7 +55,6 @@ if (process.env.BABEL_ENV === 'coverage') { './merge_conflicts/merge_conflicts_bundle.js', './merge_conflicts/components/inline_conflict_lines.js', './merge_conflicts/components/parallel_conflict_lines.js', - './merge_request_widget/ci_bundle.js', './monitoring/monitoring_bundle.js', './network/network_bundle.js', './network/branch_graph.js', diff --git a/spec/javascripts/u2f/mock_u2f_device.js b/spec/javascripts/u2f/mock_u2f_device.js index 6677fe9c1ee..4eb8ad3d9e4 100644 --- a/spec/javascripts/u2f/mock_u2f_device.js +++ b/spec/javascripts/u2f/mock_u2f_device.js @@ -1,12 +1,10 @@ /* eslint-disable space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-expressions, no-return-assign, no-param-reassign, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.MockU2FDevice = (function() { function MockU2FDevice() { - this.respondToAuthenticateRequest = bind(this.respondToAuthenticateRequest, this); - this.respondToRegisterRequest = bind(this.respondToRegisterRequest, this); + this.respondToAuthenticateRequest = this.respondToAuthenticateRequest.bind(this); + this.respondToRegisterRequest = this.respondToRegisterRequest.bind(this); window.u2f || (window.u2f = {}); window.u2f.register = (function(_this) { return function(appId, registerRequests, signRequests, callback) { diff --git a/spec/javascripts/version_check_image_spec.js b/spec/javascripts/version_check_image_spec.js index 464c1fce210..83ffeca253a 100644 --- a/spec/javascripts/version_check_image_spec.js +++ b/spec/javascripts/version_check_image_spec.js @@ -3,7 +3,7 @@ const VersionCheckImage = require('~/version_check_image'); require('jquery'); describe('VersionCheckImage', function () { - describe('.bindErrorEvent', function () { + describe('bindErrorEvent', function () { ClassSpecHelper.itShouldBeAStaticMethod(VersionCheckImage, 'bindErrorEvent'); beforeEach(function () { diff --git a/spec/javascripts/visibility_select_spec.js b/spec/javascripts/visibility_select_spec.js index 9727c03c91e..b26ed41f27a 100644 --- a/spec/javascripts/visibility_select_spec.js +++ b/spec/javascripts/visibility_select_spec.js @@ -22,7 +22,7 @@ require('~/visibility_select'); spyOn(Element.prototype, 'querySelector').and.callFake(selector => mockElements[selector]); }); - describe('#constructor', function () { + describe('constructor', function () { beforeEach(function () { this.visibilitySelect = new VisibilitySelect(mockElements.container); }); @@ -48,7 +48,7 @@ require('~/visibility_select'); }); }); - describe('#init', function () { + describe('init', function () { describe('if there is a select', function () { beforeEach(function () { this.visibilitySelect = new VisibilitySelect(mockElements.container); @@ -85,7 +85,7 @@ require('~/visibility_select'); }); }); - describe('#updateHelpText', function () { + describe('updateHelpText', function () { beforeEach(function () { this.visibilitySelect = new VisibilitySelect(mockElements.container); this.visibilitySelect.init(); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js new file mode 100644 index 00000000000..a750bc78f36 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import authorComponent from '~/vue_merge_request_widget/components/mr_widget_author'; + +const author = { + webUrl: 'http://foo.bar', + avatarUrl: 'http://gravatar.com/foo', + name: 'fatihacet', +}; +const createComponent = () => { + const Component = Vue.extend(authorComponent); + + return new Component({ + el: document.createElement('div'), + propsData: { author }, + }); +}; + +describe('MRWidgetAuthor', () => { + describe('props', () => { + it('should have props', () => { + const authorProp = authorComponent.props.author; + + expect(authorProp).toBeDefined(); + expect(authorProp.type instanceof Object).toBeTruthy(); + expect(authorProp.required).toBeTruthy(); + }); + }); + + describe('template', () => { + it('should have correct elements', () => { + const el = createComponent().$el; + + expect(el.tagName).toEqual('A'); + expect(el.getAttribute('href')).toEqual(author.webUrl); + expect(el.querySelector('img').getAttribute('src')).toEqual(author.avatarUrl); + expect(el.querySelector('.author').innerText.trim()).toEqual(author.name); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js new file mode 100644 index 00000000000..515ddcbb875 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js @@ -0,0 +1,61 @@ +import Vue from 'vue'; +import authorTimeComponent from '~/vue_merge_request_widget/components/mr_widget_author_time'; + +const props = { + actionText: 'Merged by', + author: { + webUrl: 'http://foo.bar', + avatarUrl: 'http://gravatar.com/foo', + name: 'fatihacet', + }, + dateTitle: '2017-03-23T23:02:00.807Z', + dateReadable: '12 hours ago', +}; +const createComponent = () => { + const Component = Vue.extend(authorTimeComponent); + + return new Component({ + el: document.createElement('div'), + propsData: props, + }); +}; + +describe('MRWidgetAuthorTime', () => { + describe('props', () => { + it('should have props', () => { + const { actionText, author, dateTitle, dateReadable } = authorTimeComponent.props; + const ActionTextClass = actionText.type; + const DateTitleClass = dateTitle.type; + const DateReadableClass = dateReadable.type; + + expect(new ActionTextClass() instanceof String).toBeTruthy(); + expect(actionText.required).toBeTruthy(); + + expect(author.type instanceof Object).toBeTruthy(); + expect(author.required).toBeTruthy(); + + expect(new DateTitleClass() instanceof String).toBeTruthy(); + expect(dateTitle.required).toBeTruthy(); + + expect(new DateReadableClass() instanceof String).toBeTruthy(); + expect(dateReadable.required).toBeTruthy(); + }); + }); + + describe('components', () => { + it('should have components', () => { + expect(authorTimeComponent.components['mr-widget-author']).toBeDefined(); + }); + }); + + describe('template', () => { + it('should have correct elements', () => { + const el = createComponent().$el; + + expect(el.tagName).toEqual('H4'); + expect(el.querySelector('a').getAttribute('href')).toEqual(props.author.webUrl); + expect(el.querySelector('time').innerText).toContain(props.dateReadable); + expect(el.querySelector('time').getAttribute('title')).toEqual(props.dateTitle); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js new file mode 100644 index 00000000000..d4b200875df --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js @@ -0,0 +1,188 @@ +import Vue from 'vue'; +import deploymentComponent from '~/vue_merge_request_widget/components/mr_widget_deployment'; +import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; +import { statusIconEntityMap } from '~/vue_shared/ci_status_icons'; + +const deploymentMockData = [ + { + id: 15, + name: 'review/diplo', + url: '/root/acets-review-apps/environments/15', + stop_url: '/root/acets-review-apps/environments/15/stop', + metrics_url: '/root/acets-review-apps/environments/15/deployments/1/metrics', + external_url: 'http://diplo.', + external_url_formatted: 'diplo.', + deployed_at: '2017-03-22T22:44:42.258Z', + deployed_at_formatted: 'Mar 22, 2017 10:44pm', + }, +]; +const createComponent = () => { + const Component = Vue.extend(deploymentComponent); + const mr = { + deployments: deploymentMockData, + }; + const service = {}; + + return new Component({ + el: document.createElement('div'), + propsData: { mr, service }, + }); +}; + +describe('MRWidgetDeployment', () => { + describe('props', () => { + it('should have props', () => { + const { mr, service } = deploymentComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + + expect(service.type instanceof Object).toBeTruthy(); + expect(service.required).toBeTruthy(); + }); + }); + + describe('computed', () => { + describe('svg', () => { + it('should have the proper SVG icon', () => { + const vm = createComponent(deploymentMockData); + expect(vm.svg).toEqual(statusIconEntityMap.icon_status_success); + }); + }); + }); + + describe('methods', () => { + let vm = createComponent(); + const deployment = deploymentMockData[0]; + + describe('formatDate', () => { + it('should work', () => { + const readable = gl.utils.getTimeago().format(deployment.deployed_at); + expect(vm.formatDate(deployment.deployed_at)).toEqual(readable); + }); + }); + + describe('hasExternalUrls', () => { + it('should return true', () => { + expect(vm.hasExternalUrls(deployment)).toBeTruthy(); + }); + + it('should return false when there is not enough information', () => { + expect(vm.hasExternalUrls()).toBeFalsy(); + expect(vm.hasExternalUrls({ external_url: 'Diplo' })).toBeFalsy(); + expect(vm.hasExternalUrls({ external_url_formatted: 'Diplo' })).toBeFalsy(); + }); + }); + + describe('hasDeploymentTime', () => { + it('should return true', () => { + expect(vm.hasDeploymentTime(deployment)).toBeTruthy(); + }); + + it('should return false when there is not enough information', () => { + expect(vm.hasDeploymentTime()).toBeFalsy(); + expect(vm.hasDeploymentTime({ deployed_at: 'Diplo' })).toBeFalsy(); + expect(vm.hasDeploymentTime({ deployed_at_formatted: 'Diplo' })).toBeFalsy(); + }); + }); + + describe('hasDeploymentMeta', () => { + it('should return true', () => { + expect(vm.hasDeploymentMeta(deployment)).toBeTruthy(); + }); + + it('should return false when there is not enough information', () => { + expect(vm.hasDeploymentMeta()).toBeFalsy(); + expect(vm.hasDeploymentMeta({ url: 'Diplo' })).toBeFalsy(); + expect(vm.hasDeploymentMeta({ name: 'Diplo' })).toBeFalsy(); + }); + }); + + describe('stopEnvironment', () => { + const url = '/foo/bar'; + const returnPromise = () => new Promise((resolve) => { + resolve({ + json() { + return { + redirect_url: url, + }; + }, + }); + }); + const mockStopEnvironment = () => { + vm.stopEnvironment(deploymentMockData); + return vm; + }; + + it('should show a confirm dialog and call service.stopEnvironment when confirmed', (done) => { + spyOn(window, 'confirm').and.returnValue(true); + spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(true)); + spyOn(gl.utils, 'visitUrl').and.returnValue(true); + vm = mockStopEnvironment(); + + expect(window.confirm).toHaveBeenCalled(); + expect(MRWidgetService.stopEnvironment).toHaveBeenCalledWith(deploymentMockData.stop_url); + setTimeout(() => { + expect(gl.utils.visitUrl).toHaveBeenCalledWith(url); + done(); + }, 333); + }); + + it('should show a confirm dialog but should not work if the dialog is rejected', () => { + spyOn(window, 'confirm').and.returnValue(false); + spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(false)); + vm = mockStopEnvironment(); + + expect(window.confirm).toHaveBeenCalled(); + expect(MRWidgetService.stopEnvironment).not.toHaveBeenCalled(); + }); + }); + }); + + describe('template', () => { + let vm; + let el; + const [deployment] = deploymentMockData; + + beforeEach(() => { + vm = createComponent(deploymentMockData); + el = vm.$el; + }); + + it('should render template elements correctly', () => { + expect(el.classList.contains('mr-widget-heading')).toBeTruthy(); + expect(el.querySelector('.js-icon-link')).toBeDefined(); + expect(el.querySelector('.js-deploy-meta').getAttribute('href')).toEqual(deployment.url); + expect(el.querySelector('.js-deploy-meta').innerText).toContain(deployment.name); + expect(el.querySelector('.js-deploy-url').getAttribute('href')).toEqual(deployment.external_url); + expect(el.querySelector('.js-deploy-url').innerText).toContain(deployment.external_url_formatted); + expect(el.querySelector('.js-deploy-time').innerText).toContain(vm.formatDate(deployment.deployed_at)); + expect(el.querySelector('.js-mr-memory-usage')).toBeDefined(); + expect(el.querySelector('button')).toBeDefined(); + }); + + it('should list multiple deployments', (done) => { + vm.mr.deployments.push(deployment); + vm.mr.deployments.push(deployment); + + Vue.nextTick(() => { + expect(el.querySelectorAll('.ci-widget').length).toEqual(3); + expect(el.querySelectorAll('.js-mr-memory-usage').length).toEqual(3); + done(); + }); + }); + + it('should not have some elements when there is not enough data', (done) => { + vm.mr.deployments = [{}]; + + Vue.nextTick(() => { + expect(el.querySelectorAll('.js-deploy-meta').length).toEqual(0); + expect(el.querySelectorAll('.js-deploy-url').length).toEqual(0); + expect(el.querySelectorAll('.js-deploy-time').length).toEqual(0); + expect(el.querySelectorAll('.js-mr-memory-usage').length).toEqual(0); + expect(el.querySelectorAll('.button').length).toEqual(0); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js new file mode 100644 index 00000000000..7f3eea7d2e5 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js @@ -0,0 +1,102 @@ +import Vue from 'vue'; +import headerComponent from '~/vue_merge_request_widget/components/mr_widget_header'; + +const createComponent = (mr) => { + const Component = Vue.extend(headerComponent); + return new Component({ + el: document.createElement('div'), + propsData: { mr }, + }); +}; + +describe('MRWidgetHeader', () => { + describe('props', () => { + it('should have props', () => { + const { mr } = headerComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + }); + }); + + describe('computed', () => { + let vm; + beforeEach(() => { + vm = createComponent({ + divergedCommitsCount: 12, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '/foo/bar/mr-widget-refactor', + targetBranch: 'master', + }); + }); + + it('shouldShowCommitsBehindText', () => { + expect(vm.shouldShowCommitsBehindText).toBeTruthy(); + + vm.mr.divergedCommitsCount = 0; + expect(vm.shouldShowCommitsBehindText).toBeFalsy(); + }); + + it('commitsText', () => { + expect(vm.commitsText).toEqual('commits'); + + vm.mr.divergedCommitsCount = 1; + expect(vm.commitsText).toEqual('commit'); + }); + }); + + describe('template', () => { + let vm; + let el; + const sourceBranchPath = '/foo/bar/mr-widget-refactor'; + const mr = { + divergedCommitsCount: 12, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: `<a href="${sourceBranchPath}">mr-widget-refactor</a>`, + targetBranchPath: 'foo/bar/commits-path', + targetBranch: 'master', + isOpen: true, + emailPatchesPath: '/mr/email-patches', + plainDiffPath: '/mr/plainDiffPath', + }; + + beforeEach(() => { + vm = createComponent(mr); + el = vm.$el; + }); + + it('should render template elements correctly', () => { + expect(el.classList.contains('mr-source-target')).toBeTruthy(); + const sourceBranchLink = el.querySelectorAll('.label-branch')[0]; + const targetBranchLink = el.querySelectorAll('.label-branch')[1]; + + expect(sourceBranchLink.textContent).toContain(mr.sourceBranch); + expect(targetBranchLink.textContent).toContain(mr.targetBranch); + expect(sourceBranchLink.querySelector('a').getAttribute('href')).toEqual(sourceBranchPath); + expect(targetBranchLink.querySelector('a').getAttribute('href')).toEqual(mr.targetBranchPath); + expect(el.querySelector('.diverged-commits-count').textContent).toContain('12 commits behind'); + + expect(el.textContent).toContain('Check out branch'); + expect(el.querySelectorAll('.dropdown li a')[0].getAttribute('href')).toEqual(mr.emailPatchesPath); + expect(el.querySelectorAll('.dropdown li a')[1].getAttribute('href')).toEqual(mr.plainDiffPath); + }); + + it('should not have right action links if the MR state is not open', (done) => { + vm.mr.isOpen = false; + Vue.nextTick(() => { + expect(el.textContent).not.toContain('Check out branch'); + expect(el.querySelectorAll('.dropdown li a').length).toEqual(0); + done(); + }); + }); + + it('should not render diverged commits count if the MR has no diverged commits', (done) => { + vm.mr.divergedCommitsCount = null; + Vue.nextTick(() => { + expect(el.textContent).not.toContain('commits behind'); + expect(el.querySelectorAll('.diverged-commits-count').length).toEqual(0); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js new file mode 100644 index 00000000000..da9dff18ada --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js @@ -0,0 +1,184 @@ +import Vue from 'vue'; +import memoryUsageComponent from '~/vue_merge_request_widget/components/mr_widget_memory_usage'; +import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; + +const url = '/root/acets-review-apps/environments/15/deployments/1/metrics'; + +const metricsMockData = { + success: true, + metrics: { + memory_values: [ + { + metric: {}, + values: [ + [1493716685, '4.30859375'], + ], + }, + ], + }, + last_update: '2017-05-02T12:34:49.628Z', + deployment_time: 1493718485, +}; + +const createComponent = () => { + const Component = Vue.extend(memoryUsageComponent); + + return new Component({ + el: document.createElement('div'), + propsData: { + metricsUrl: url, + memoryMetrics: [], + deploymentTime: 0, + hasMetrics: false, + loadFailed: false, + loadingMetrics: true, + backOffRequestCounter: 0, + }, + }); +}; + +const messages = { + loadingMetrics: 'Loading deployment statistics.', + hasMetrics: 'Deployment memory usage:', + loadFailed: 'Failed to load deployment statistics.', + metricsUnavailable: 'Deployment statistics are not available currently.', +}; + +describe('MemoryUsage', () => { + let vm; + let el; + + beforeEach(() => { + vm = createComponent(); + el = vm.$el; + }); + + describe('props', () => { + it('should have props with defaults', () => { + const { metricsUrl } = memoryUsageComponent.props; + const MetricsUrlTypeClass = metricsUrl.type; + + Vue.nextTick(() => { + expect(new MetricsUrlTypeClass() instanceof String).toBeTruthy(); + expect(metricsUrl.required).toBeTruthy(); + }); + }); + }); + + describe('data', () => { + it('should have default data', () => { + const data = memoryUsageComponent.data(); + + expect(Array.isArray(data.memoryMetrics)).toBeTruthy(); + expect(data.memoryMetrics.length).toBe(0); + + expect(typeof data.deploymentTime).toBe('number'); + expect(data.deploymentTime).toBe(0); + + expect(typeof data.hasMetrics).toBe('boolean'); + expect(data.hasMetrics).toBeFalsy(); + + expect(typeof data.loadFailed).toBe('boolean'); + expect(data.loadFailed).toBeFalsy(); + + expect(typeof data.loadingMetrics).toBe('boolean'); + expect(data.loadingMetrics).toBeTruthy(); + + expect(typeof data.backOffRequestCounter).toBe('number'); + expect(data.backOffRequestCounter).toBe(0); + }); + }); + + describe('methods', () => { + const { metrics, deployment_time } = metricsMockData; + + describe('computeGraphData', () => { + it('should populate sparkline graph', () => { + vm.computeGraphData(metrics, deployment_time); + const { hasMetrics, memoryMetrics, deploymentTime } = vm; + + expect(hasMetrics).toBeTruthy(); + expect(memoryMetrics.length > 0).toBeTruthy(); + expect(deploymentTime).toEqual(deployment_time); + }); + }); + + describe('loadMetrics', () => { + const returnServicePromise = () => new Promise((resolve) => { + resolve({ + json() { + return metricsMockData; + }, + }); + }); + + it('should load metrics data using MRWidgetService', (done) => { + spyOn(MRWidgetService, 'fetchMetrics').and.returnValue(returnServicePromise(true)); + spyOn(vm, 'computeGraphData'); + + vm.loadMetrics(); + setTimeout(() => { + expect(MRWidgetService.fetchMetrics).toHaveBeenCalledWith(url); + expect(vm.computeGraphData).toHaveBeenCalledWith(metrics, deployment_time); + done(); + }, 333); + }); + }); + }); + + describe('template', () => { + it('should render template elements correctly', () => { + expect(el.classList.contains('mr-memory-usage')).toBeTruthy(); + expect(el.querySelector('.js-usage-info')).toBeDefined(); + }); + + it('should show loading metrics message while metrics are being loaded', (done) => { + vm.loadingMetrics = true; + vm.hasMetrics = false; + vm.loadFailed = false; + + Vue.nextTick(() => { + expect(el.querySelector('.js-usage-info.usage-info-loading')).toBeDefined(); + expect(el.querySelector('.js-usage-info .usage-info-load-spinner')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadingMetrics); + done(); + }); + }); + + it('should show deployment memory usage when metrics are loaded', (done) => { + vm.loadingMetrics = false; + vm.hasMetrics = true; + vm.loadFailed = false; + + Vue.nextTick(() => { + expect(el.querySelector('.memory-graph-container')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.hasMetrics); + done(); + }); + }); + + it('should show failure message when metrics loading failed', (done) => { + vm.loadingMetrics = false; + vm.hasMetrics = false; + vm.loadFailed = true; + + Vue.nextTick(() => { + expect(el.querySelector('.js-usage-info.usage-info-failed')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadFailed); + done(); + }); + }); + + it('should show metrics unavailable message when metrics loading failed', (done) => { + vm.loadingMetrics = false; + vm.hasMetrics = false; + vm.loadFailed = false; + + Vue.nextTick(() => { + expect(el.querySelector('.js-usage-info.usage-info-unavailable')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.metricsUnavailable); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js new file mode 100644 index 00000000000..4da4fc82c26 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js @@ -0,0 +1,51 @@ +import Vue from 'vue'; +import mergeHelpComponent from '~/vue_merge_request_widget/components/mr_widget_merge_help'; + +const props = { + missingBranch: 'this-is-not-the-branch-you-are-looking-for', +}; +const text = `If the ${props.missingBranch} branch exists in your local repository`; + +const createComponent = () => { + const Component = Vue.extend(mergeHelpComponent); + return new Component({ + el: document.createElement('div'), + propsData: props, + }); +}; + +describe('MRWidgetMergeHelp', () => { + describe('props', () => { + it('should have props', () => { + const { missingBranch } = mergeHelpComponent.props; + const MissingBranchTypeClass = missingBranch.type; + + expect(new MissingBranchTypeClass() instanceof String).toBeTruthy(); + expect(missingBranch.required).toBeFalsy(); + expect(missingBranch.default).toEqual(''); + }); + }); + + describe('template', () => { + let vm; + let el; + + beforeEach(() => { + vm = createComponent(); + el = vm.$el; + }); + + it('should have the correct elements', () => { + expect(el.classList.contains('mr-widget-help')).toBeTruthy(); + expect(el.textContent).toContain(text); + }); + + it('should not show missing branch name if missingBranch props is not provided', (done) => { + vm.missingBranch = null; + Vue.nextTick(() => { + expect(el.textContent).not.toContain(text); + done(); + }); + }); + }); +}); 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 new file mode 100644 index 00000000000..647b59520f8 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js @@ -0,0 +1,131 @@ +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'; + +const createComponent = (mr) => { + const Component = Vue.extend(pipelineComponent); + return new Component({ + el: document.createElement('div'), + propsData: { mr }, + }); +}; + +describe('MRWidgetPipeline', () => { + describe('props', () => { + it('should have props', () => { + const { mr } = pipelineComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + }); + }); + + describe('components', () => { + it('should have components added', () => { + expect(pipelineComponent.components['pipeline-stage']).toBeDefined(); + expect(pipelineComponent.components.ciIcon).toBeDefined(); + }); + }); + + 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('hasCIError', () => { + it('should return false when there is no CI error', () => { + const vm = createComponent({ + pipeline: mockData.pipeline, + hasCI: true, + ciStatus: 'success', + }); + + expect(vm.hasCIError).toBeFalsy(); + }); + + it('should return true when there is a CI error', () => { + const vm = createComponent({ + pipeline: mockData.pipeline, + hasCI: true, + ciStatus: null, + }); + + expect(vm.hasCIError).toBeTruthy(); + }); + }); + }); + + describe('template', () => { + let vm; + let el; + const { pipeline } = mockData; + const mr = { + hasCI: true, + ciStatus: 'success', + pipelineDetailedStatus: pipeline.details.status, + pipeline, + }; + + beforeEach(() => { + vm = createComponent(mr); + el = vm.$el; + }); + + it('should render template elements correctly', () => { + expect(el.classList.contains('mr-widget-heading')).toBeTruthy(); + expect(el.querySelectorAll('.ci-status-icon.ci-status-icon-success').length).toEqual(1); + expect(el.querySelector('.pipeline-id').textContent).toContain(`#${pipeline.id}`); + expect(el.innerText).toContain('passed'); + expect(el.innerText).toContain('with stages'); + expect(el.querySelector('.pipeline-id').getAttribute('href')).toEqual(pipeline.path); + expect(el.querySelectorAll('.stage-container').length).toEqual(2); + expect(el.querySelector('.js-ci-error')).toEqual(null); + expect(el.querySelector('.js-commit-link').getAttribute('href')).toEqual(pipeline.commit.commit_path); + expect(el.querySelector('.js-commit-link').textContent).toContain(pipeline.commit.short_id); + expect(el.querySelector('.js-mr-coverage').textContent).toContain(`Coverage ${pipeline.coverage}%.`); + }); + + it('should list single stage', (done) => { + pipeline.details.stages.splice(0, 1); + + Vue.nextTick(() => { + expect(el.querySelectorAll('.stage-container button').length).toEqual(1); + expect(el.innerText).toContain('with stage'); + done(); + }); + }); + + it('should not have stages when there is no stage', (done) => { + vm.mr.pipeline.details.stages = []; + + Vue.nextTick(() => { + expect(el.querySelectorAll('.stage-container button').length).toEqual(0); + done(); + }); + }); + + it('should not have coverage text when pipeline has no coverage info', (done) => { + vm.mr.pipeline.coverage = null; + + Vue.nextTick(() => { + expect(el.querySelector('.js-mr-coverage')).toEqual(null); + done(); + }); + }); + + it('should show CI error when there is a CI error', (done) => { + vm.mr.ciStatus = null; + + Vue.nextTick(() => { + expect(el.querySelectorAll('.js-ci-error').length).toEqual(1); + expect(el.innerText).toContain('Could not connect to the CI server'); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_related_links_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_related_links_spec.js new file mode 100644 index 00000000000..f6e0c3dfb74 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_related_links_spec.js @@ -0,0 +1,138 @@ +import Vue from 'vue'; +import relatedLinksComponent from '~/vue_merge_request_widget/components/mr_widget_related_links'; + +const createComponent = (data) => { + const Component = Vue.extend(relatedLinksComponent); + + return new Component({ + el: document.createElement('div'), + propsData: data, + }); +}; + +describe('MRWidgetRelatedLinks', () => { + describe('props', () => { + it('should have props', () => { + const { relatedLinks } = relatedLinksComponent.props; + + expect(relatedLinks).toBeDefined(); + expect(relatedLinks.type instanceof Object).toBeTruthy(); + expect(relatedLinks.required).toBeTruthy(); + }); + }); + + describe('computed', () => { + describe('hasLinks', () => { + it('should return correct value when we have links reference', () => { + const data = { + relatedLinks: { + closing: '/foo', + mentioned: '/foo', + assignToMe: '/foo', + }, + }; + const vm = createComponent(data); + expect(vm.hasLinks).toBeTruthy(); + + vm.relatedLinks.closing = null; + expect(vm.hasLinks).toBeTruthy(); + + vm.relatedLinks.mentioned = null; + expect(vm.hasLinks).toBeTruthy(); + + vm.relatedLinks.assignToMe = null; + expect(vm.hasLinks).toBeFalsy(); + }); + }); + }); + + describe('methods', () => { + const data = { + relatedLinks: { + closing: '<a href="#">#23</a> and <a>#42</a>', + mentioned: '<a href="#">#7</a>', + }, + }; + const vm = createComponent(data); + + describe('hasMultipleIssues', () => { + it('should return true if the given text has multiple issues', () => { + expect(vm.hasMultipleIssues(data.relatedLinks.closing)).toBeTruthy(); + }); + + it('should return false if the given text has one issue', () => { + expect(vm.hasMultipleIssues(data.relatedLinks.mentioned)).toBeFalsy(); + }); + }); + + describe('issueLabel', () => { + it('should return true if the given text has multiple issues', () => { + expect(vm.issueLabel('closing')).toEqual('issues'); + }); + + it('should return false if the given text has one issue', () => { + expect(vm.issueLabel('mentioned')).toEqual('issue'); + }); + }); + + describe('verbLabel', () => { + it('should return true if the given text has multiple issues', () => { + expect(vm.verbLabel('closing')).toEqual('are'); + }); + + it('should return false if the given text has one issue', () => { + expect(vm.verbLabel('mentioned')).toEqual('is'); + }); + }); + }); + + describe('template', () => { + it('should have only have closing issues text', () => { + const vm = createComponent({ + relatedLinks: { + closing: '<a href="#">#23</a> and <a>#42</a>', + }, + }); + const content = vm.$el.textContent.replace(/\n(\s)+/g, ' ').trim(); + + expect(content).toContain('Closes issues #23 and #42'); + expect(content).not.toContain('mentioned'); + }); + + it('should have only have mentioned issues text', () => { + const vm = createComponent({ + relatedLinks: { + mentioned: '<a href="#">#7</a>', + }, + }); + + expect(vm.$el.innerText).toContain('issue #7'); + expect(vm.$el.innerText).toContain('is mentioned but will not be closed.'); + expect(vm.$el.innerText).not.toContain('Closes'); + }); + + it('should have closing and mentioned issues at the same time', () => { + const vm = createComponent({ + relatedLinks: { + closing: '<a href="#">#7</a>', + mentioned: '<a href="#">#23</a> and <a>#42</a>', + }, + }); + const content = vm.$el.textContent.replace(/\n(\s)+/g, ' ').trim(); + + expect(content).toContain('Closes issue #7.'); + expect(content).toContain('issues #23 and #42'); + expect(content).toContain('are mentioned but will not be closed.'); + }); + + it('should have assing issues link', () => { + const vm = createComponent({ + relatedLinks: { + assignToMe: '<a href="#">Assign yourself to these issues</a>', + }, + }); + + expect(vm.$el.innerText).toContain('Assign yourself to these issues'); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_archived_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_archived_spec.js new file mode 100644 index 00000000000..cac2f561a0b --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_archived_spec.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import archivedComponent from '~/vue_merge_request_widget/components/states/mr_widget_archived'; + +describe('MRWidgetArchived', () => { + describe('template', () => { + it('should have correct elements', () => { + const Component = Vue.extend(archivedComponent); + const el = new Component({ + el: document.createElement('div'), + }).$el; + + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.querySelector('button').classList.contains('btn-success')).toBeTruthy(); + expect(el.querySelector('button').disabled).toBeTruthy(); + expect(el.innerText).toContain('This project is archived, write access has been disabled.'); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js new file mode 100644 index 00000000000..47b4ba893e0 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import autoMergeFailedComponent from '~/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed'; + +const mergeError = 'This is the merge error'; + +describe('MRWidgetAutoMergeFailed', () => { + describe('props', () => { + it('should have props', () => { + const mrProp = autoMergeFailedComponent.props.mr; + + expect(mrProp.type instanceof Object).toBeTruthy(); + expect(mrProp.required).toBeTruthy(); + }); + }); + + describe('template', () => { + const Component = Vue.extend(autoMergeFailedComponent); + const vm = new Component({ + el: document.createElement('div'), + propsData: { + mr: { mergeError }, + }, + }); + + it('should have correct elements', () => { + expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy(); + expect(vm.$el.innerText).toContain('This merge request failed to be merged automatically.'); + expect(vm.$el.innerText).toContain(mergeError); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js new file mode 100644 index 00000000000..3be11d47227 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js @@ -0,0 +1,19 @@ +import Vue from 'vue'; +import checkingComponent from '~/vue_merge_request_widget/components/states/mr_widget_checking'; + +describe('MRWidgetChecking', () => { + describe('template', () => { + it('should have correct elements', () => { + const Component = Vue.extend(checkingComponent); + const el = new Component({ + el: document.createElement('div'), + }).$el; + + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.querySelector('button').classList.contains('btn-success')).toBeTruthy(); + expect(el.querySelector('button').disabled).toBeTruthy(); + expect(el.innerText).toContain('Checking ability to merge automatically.'); + expect(el.querySelector('i')).toBeDefined(); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_closed_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_closed_spec.js new file mode 100644 index 00000000000..47303d1e80f --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_closed_spec.js @@ -0,0 +1,51 @@ +import Vue from 'vue'; +import closedComponent from '~/vue_merge_request_widget/components/states/mr_widget_closed'; + +const mr = { + targetBranch: 'good-branch', + targetBranchPath: '/good-branch', + closedBy: { + name: 'Fatih Acet', + username: 'fatihacet', + }, + updatedAt: '2017-03-23T20:08:08.845Z', + closedAt: '1 day ago', +}; + +const createComponent = () => { + const Component = Vue.extend(closedComponent); + + return new Component({ + el: document.createElement('div'), + propsData: { mr }, + }).$el; +}; + +describe('MRWidgetClosed', () => { + describe('props', () => { + it('should have props', () => { + const mrProp = closedComponent.props.mr; + + expect(mrProp.type instanceof Object).toBeTruthy(); + expect(mrProp.required).toBeTruthy(); + }); + }); + + describe('components', () => { + it('should have components added', () => { + expect(closedComponent.components['mr-widget-author-and-time']).toBeDefined(); + }); + }); + + describe('template', () => { + it('should have correct elements', () => { + const el = createComponent(); + + expect(el.querySelector('h4').textContent).toContain('Closed by'); + expect(el.querySelector('h4').textContent).toContain(mr.closedBy.name); + expect(el.textContent).toContain('The changes were not merged into'); + expect(el.querySelector('.label-branch').getAttribute('href')).toEqual(mr.targetBranchPath); + expect(el.querySelector('.label-branch').textContent).toContain(mr.targetBranch); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js new file mode 100644 index 00000000000..e7ae85caec4 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js @@ -0,0 +1,69 @@ +import Vue from 'vue'; +import conflictsComponent from '~/vue_merge_request_widget/components/states/mr_widget_conflicts'; + +const path = '/conflicts'; +const createComponent = () => { + const Component = Vue.extend(conflictsComponent); + + return new Component({ + el: document.createElement('div'), + propsData: { + mr: { + canMerge: true, + conflictResolutionPath: path, + }, + }, + }); +}; + +describe('MRWidgetConflicts', () => { + describe('props', () => { + it('should have props', () => { + const { mr } = conflictsComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + }); + }); + + describe('template', () => { + it('should have correct elements', () => { + const el = createComponent().$el; + const resolveButton = el.querySelectorAll('.btn-group .btn')[0]; + const mergeLocallyButton = el.querySelectorAll('.btn-group .btn')[1]; + + expect(el.textContent).toContain('There are merge conflicts.'); + expect(el.textContent).not.toContain('ask someone with write access'); + expect(el.querySelector('.btn-success').disabled).toBeTruthy(); + expect(el.querySelectorAll('.btn-group .btn').length).toBe(2); + expect(resolveButton.textContent).toContain('Resolve conflicts'); + expect(resolveButton.getAttribute('href')).toEqual(path); + expect(mergeLocallyButton.textContent).toContain('Merge locally'); + }); + + describe('when user does not have permission to merge', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + vm.mr.canMerge = false; + }); + + it('should show proper message', (done) => { + Vue.nextTick(() => { + expect(vm.$el.textContent).toContain('ask someone with write access'); + done(); + }); + }); + + it('should not have action buttons', (done) => { + Vue.nextTick(() => { + expect(vm.$el.querySelectorAll('.btn').length).toBe(1); + expect(vm.$el.querySelector('a.js-resolve-conflicts-button')).toEqual(null); + expect(vm.$el.querySelector('a.js-merge-locally-button')).toEqual(null); + done(); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js new file mode 100644 index 00000000000..587b83430d9 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js @@ -0,0 +1,122 @@ +import Vue from 'vue'; +import failedToMergeComponent from '~/vue_merge_request_widget/components/states/mr_widget_failed_to_merge'; +import eventHub from '~/vue_merge_request_widget/event_hub'; + +const mr = { + mergeError: 'Merge error happened.', +}; +const createComponent = () => { + const Component = Vue.extend(failedToMergeComponent); + return new Component({ + el: document.createElement('div'), + propsData: { mr }, + }); +}; + +describe('MRWidgetFailedToMerge', () => { + describe('data', () => { + it('should have default data', () => { + const data = failedToMergeComponent.data(); + + expect(data.timer).toEqual(10); + expect(data.isRefreshing).toBeFalsy(); + }); + }); + + describe('computed', () => { + describe('timerText', () => { + it('should return correct timer text', () => { + const vm = createComponent(); + expect(vm.timerText).toEqual('10 seconds'); + + vm.timer = 1; + expect(vm.timerText).toEqual('a second'); + }); + }); + }); + + describe('created', () => { + it('should disable polling', () => { + spyOn(eventHub, '$emit'); + createComponent(); + + expect(eventHub.$emit).toHaveBeenCalledWith('DisablePolling'); + }); + }); + + describe('methods', () => { + describe('refresh', () => { + it('should emit event to request component refresh', () => { + spyOn(eventHub, '$emit'); + const vm = createComponent(); + + expect(vm.isRefreshing).toBeFalsy(); + + vm.refresh(); + expect(vm.isRefreshing).toBeTruthy(); + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + expect(eventHub.$emit).toHaveBeenCalledWith('EnablePolling'); + }); + }); + + describe('updateTimer', () => { + it('should update timer and emit event when timer end', () => { + const vm = createComponent(); + spyOn(vm, 'refresh'); + + expect(vm.timer).toEqual(10); + + for (let i = 0; i < 10; i++) { // eslint-disable-line + expect(vm.timer).toEqual(10 - i); + vm.updateTimer(); + } + + expect(vm.refresh).toHaveBeenCalled(); + }); + }); + }); + + describe('template', () => { + let vm; + let el; + + beforeEach(() => { + vm = createComponent(); + el = vm.$el; + }); + + it('should have correct elements', (done) => { + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.innerText).toContain('Merge error happened.'); + expect(el.innerText).toContain('Refreshing in 10 seconds'); + expect(el.innerText).not.toContain('Merge failed.'); + expect(el.querySelector('button').getAttribute('disabled')).toBeTruthy(); + expect(el.querySelector('button').innerText).toContain('Merge'); + expect(el.querySelector('.js-refresh-button').innerText).toContain('Refresh now'); + expect(el.querySelector('.js-refresh-label')).toEqual(null); + expect(el.innerText).not.toContain('Refreshing now...'); + setTimeout(() => { + expect(el.innerText).toContain('Refreshing in 9 seconds'); + done(); + }, 1010); + }); + + it('should just generic merge failed message if merge_error is not available', (done) => { + vm.mr.mergeError = null; + + Vue.nextTick(() => { + expect(el.innerText).toContain('Merge failed.'); + expect(el.innerText).not.toContain('Merge error happened.'); + done(); + }); + }); + + it('should show refresh label when refresh requested', () => { + vm.refresh(); + Vue.nextTick(() => { + expect(el.innerText).not.toContain('Merge failed. Refreshing'); + expect(el.innerText).toContain('Refreshing now...'); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_locked_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_locked_spec.js new file mode 100644 index 00000000000..fb2ef606604 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_locked_spec.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import lockedComponent from '~/vue_merge_request_widget/components/states/mr_widget_locked'; + +describe('MRWidgetLocked', () => { + describe('props', () => { + it('should have props', () => { + const { mr } = lockedComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + }); + }); + + describe('template', () => { + it('should have correct elements', () => { + const Component = Vue.extend(lockedComponent); + const mr = { + targetBranchPath: '/branch-path', + targetBranch: 'branch', + }; + const el = new Component({ + el: document.createElement('div'), + propsData: { mr }, + }).$el; + + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.innerText).toContain('it is locked'); + expect(el.innerText).toContain('changes will be merged into'); + expect(el.querySelector('.label-branch a').getAttribute('href')).toEqual(mr.targetBranchPath); + expect(el.querySelector('.label-branch a').textContent).toContain(mr.targetBranch); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js new file mode 100644 index 00000000000..8d8b90cea16 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js @@ -0,0 +1,213 @@ +import Vue from 'vue'; +import mwpsComponent from '~/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds'; +import eventHub from '~/vue_merge_request_widget/event_hub'; + +const targetBranchPath = '/foo/bar'; +const targetBranch = 'foo'; +const sha = '1EA2EZ34'; + +const createComponent = () => { + const Component = Vue.extend(mwpsComponent); + const mr = { + shouldRemoveSourceBranch: false, + canRemoveSourceBranch: true, + canCancelAutomaticMerge: true, + mergeUserId: 1, + currentUserId: 1, + setToMWPSBy: {}, + sha, + targetBranchPath, + targetBranch, + }; + + const service = { + cancelAutomaticMerge() {}, + mergeResource: { + save() {}, + }, + }; + + return new Component({ + el: document.createElement('div'), + propsData: { mr, service }, + }); +}; + +describe('MRWidgetMergeWhenPipelineSucceeds', () => { + describe('props', () => { + it('should have props', () => { + const { mr, service } = mwpsComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + + expect(service.type instanceof Object).toBeTruthy(); + expect(service.required).toBeTruthy(); + }); + }); + + describe('components', () => { + it('should have components added', () => { + expect(mwpsComponent.components['mr-widget-author']).toBeDefined(); + }); + }); + + describe('data', () => { + it('should have default data', () => { + const data = mwpsComponent.data(); + + expect(data.isCancellingAutoMerge).toBeFalsy(); + expect(data.isRemovingSourceBranch).toBeFalsy(); + }); + }); + + describe('computed', () => { + describe('canRemoveSourceBranch', () => { + it('should return true when user is able to remove source branch', () => { + const vm = createComponent(); + + expect(vm.canRemoveSourceBranch).toBeTruthy(); + }); + + it('should return false when user id is not the same with who set the MWPS', () => { + const vm = createComponent(); + + vm.mr.mergeUserId = 2; + expect(vm.canRemoveSourceBranch).toBeFalsy(); + + vm.mr.currentUserId = 2; + expect(vm.canRemoveSourceBranch).toBeTruthy(); + + vm.mr.currentUserId = 3; + expect(vm.canRemoveSourceBranch).toBeFalsy(); + }); + + it('should return false when shouldRemoveSourceBranch set to false', () => { + const vm = createComponent(); + + vm.mr.shouldRemoveSourceBranch = true; + expect(vm.canRemoveSourceBranch).toBeFalsy(); + }); + + it('should return false if user is not able to remove the source branch', () => { + const vm = createComponent(); + + vm.mr.canRemoveSourceBranch = false; + expect(vm.canRemoveSourceBranch).toBeFalsy(); + }); + }); + }); + + describe('methods', () => { + describe('cancelAutomaticMerge', () => { + it('should set flag and call service then tell main component to update the widget with data', (done) => { + const vm = createComponent(); + const mrObj = { + is_new_mr_data: true, + }; + spyOn(eventHub, '$emit'); + spyOn(vm.service, 'cancelAutomaticMerge').and.returnValue(new Promise((resolve) => { + resolve({ + json() { + return mrObj; + }, + }); + })); + + vm.cancelAutomaticMerge(); + setTimeout(() => { + expect(vm.isCancellingAutoMerge).toBeTruthy(); + expect(eventHub.$emit).toHaveBeenCalledWith('UpdateWidgetData', mrObj); + done(); + }, 333); + }); + }); + + describe('removeSourceBranch', () => { + it('should set flag and call service then request main component to update the widget', (done) => { + const vm = createComponent(); + spyOn(eventHub, '$emit'); + spyOn(vm.service.mergeResource, 'save').and.returnValue(new Promise((resolve) => { + resolve({ + json() { + return { + status: 'merge_when_pipeline_succeeds', + }; + }, + }); + })); + + vm.removeSourceBranch(); + setTimeout(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + expect(vm.service.mergeResource.save).toHaveBeenCalledWith({ + sha, + merge_when_pipeline_succeeds: true, + should_remove_source_branch: true, + }); + done(); + }, 333); + }); + }); + }); + + describe('template', () => { + let vm; + let el; + + beforeEach(() => { + vm = createComponent(); + el = vm.$el; + }); + + it('should have correct elements', () => { + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.innerText).toContain('to be merged automatically when the pipeline succeeds.'); + expect(el.innerText).toContain('The changes will be merged into'); + expect(el.innerText).toContain(targetBranch); + expect(el.innerText).toContain('The source branch will not be removed.'); + expect(el.querySelector('.js-cancel-auto-merge').innerText).toContain('Cancel automatic merge'); + expect(el.querySelector('.js-cancel-auto-merge').getAttribute('disabled')).toBeFalsy(); + expect(el.querySelector('.js-remove-source-branch').innerText).toContain('Remove source branch'); + expect(el.querySelector('.js-remove-source-branch').getAttribute('disabled')).toBeFalsy(); + }); + + it('should disable cancel auto merge button when the action is in progress', (done) => { + vm.isCancellingAutoMerge = true; + + Vue.nextTick(() => { + expect(el.querySelector('.js-cancel-auto-merge').getAttribute('disabled')).toBeTruthy(); + done(); + }); + }); + + it('should show source branch will be removed text when it source branch set to remove', (done) => { + vm.mr.shouldRemoveSourceBranch = true; + + Vue.nextTick(() => { + const normalizedText = el.innerText.replace(/\s+/g, ' '); + expect(normalizedText).toContain('The source branch will be removed.'); + expect(normalizedText).not.toContain('The source branch will not be removed.'); + done(); + }); + }); + + it('should not show remove source branch button when user not able to remove source branch', (done) => { + vm.mr.currentUserId = 4; + + Vue.nextTick(() => { + expect(el.querySelector('.js-remove-source-branch')).toEqual(null); + done(); + }); + }); + + it('should disable remove source branch button when the action is in progress', (done) => { + vm.isRemovingSourceBranch = true; + + Vue.nextTick(() => { + expect(el.querySelector('.js-remove-source-branch').getAttribute('disabled')).toBeTruthy(); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js new file mode 100644 index 00000000000..6628010112d --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js @@ -0,0 +1,174 @@ +import Vue from 'vue'; +import mergedComponent from '~/vue_merge_request_widget/components/states/mr_widget_merged'; +import eventHub from '~/vue_merge_request_widget/event_hub'; + +const targetBranch = 'foo'; + +const createComponent = () => { + const Component = Vue.extend(mergedComponent); + const mr = { + isRemovingSourceBranch: false, + cherryPickInForkPath: false, + canCherryPickInCurrentMR: true, + revertInForkPath: false, + canRevertInCurrentMR: true, + canRemoveSourceBranch: true, + sourceBranchRemoved: true, + mergedBy: {}, + mergedAt: '', + updatedAt: '', + targetBranch, + }; + + const service = { + removeSourceBranch() {}, + }; + + return new Component({ + el: document.createElement('div'), + propsData: { mr, service }, + }); +}; + +describe('MRWidgetMerged', () => { + describe('props', () => { + it('should have props', () => { + const { mr, service } = mergedComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + + expect(service.type instanceof Object).toBeTruthy(); + expect(service.required).toBeTruthy(); + }); + }); + + describe('components', () => { + it('should have components added', () => { + expect(mergedComponent.components['mr-widget-author-and-time']).toBeDefined(); + }); + }); + + describe('data', () => { + it('should have default data', () => { + const data = mergedComponent.data(); + + expect(data.isMakingRequest).toBeFalsy(); + }); + }); + + describe('computed', () => { + describe('shouldShowRemoveSourceBranch', () => { + it('should correct value when fields changed', () => { + const vm = createComponent(); + vm.mr.sourceBranchRemoved = false; + expect(vm.shouldShowRemoveSourceBranch).toBeTruthy(); + + vm.mr.sourceBranchRemoved = true; + expect(vm.shouldShowRemoveSourceBranch).toBeFalsy(); + + vm.mr.sourceBranchRemoved = false; + vm.mr.canRemoveSourceBranch = false; + expect(vm.shouldShowRemoveSourceBranch).toBeFalsy(); + + vm.mr.canRemoveSourceBranch = true; + vm.isMakingRequest = true; + expect(vm.shouldShowRemoveSourceBranch).toBeFalsy(); + + vm.mr.isRemovingSourceBranch = true; + vm.mr.canRemoveSourceBranch = true; + vm.isMakingRequest = true; + expect(vm.shouldShowRemoveSourceBranch).toBeFalsy(); + }); + }); + describe('shouldShowSourceBranchRemoving', () => { + it('should correct value when fields changed', () => { + const vm = createComponent(); + vm.mr.sourceBranchRemoved = false; + expect(vm.shouldShowSourceBranchRemoving).toBeFalsy(); + + vm.mr.sourceBranchRemoved = true; + expect(vm.shouldShowRemoveSourceBranch).toBeFalsy(); + + vm.mr.sourceBranchRemoved = false; + vm.isMakingRequest = true; + expect(vm.shouldShowSourceBranchRemoving).toBeTruthy(); + + vm.isMakingRequest = false; + vm.mr.isRemovingSourceBranch = true; + expect(vm.shouldShowSourceBranchRemoving).toBeTruthy(); + }); + }); + }); + + describe('methods', () => { + describe('removeSourceBranch', () => { + it('should set flag and call service then request main component to update the widget', (done) => { + const vm = createComponent(); + spyOn(eventHub, '$emit'); + spyOn(vm.service, 'removeSourceBranch').and.returnValue(new Promise((resolve) => { + resolve({ + json() { + return { + message: 'Branch was removed', + }; + }, + }); + })); + + vm.removeSourceBranch(); + setTimeout(() => { + const args = eventHub.$emit.calls.argsFor(0); + expect(vm.isMakingRequest).toBeTruthy(); + expect(args[0]).toEqual('MRWidgetUpdateRequested'); + expect(args[1]).not.toThrow(); + done(); + }, 333); + }); + }); + }); + + describe('template', () => { + let vm; + let el; + + beforeEach(() => { + vm = createComponent(); + el = vm.$el; + }); + + it('should have correct elements', () => { + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.querySelector('.js-mr-widget-author')).toBeDefined(); + expect(el.innerText).toContain('The changes were merged into'); + expect(el.innerText).toContain(targetBranch); + expect(el.innerText).toContain('The source branch has been removed.'); + expect(el.innerText).toContain('Revert'); + expect(el.innerText).toContain('Cherry-pick'); + expect(el.innerText).not.toContain('You can remove source branch now.'); + expect(el.innerText).not.toContain('The source branch is being removed.'); + }); + + it('should not show source branch removed text', (done) => { + vm.mr.sourceBranchRemoved = false; + + Vue.nextTick(() => { + expect(el.innerText).toContain('You can remove source branch now.'); + expect(el.innerText).not.toContain('The source branch has been removed.'); + done(); + }); + }); + + it('should show source branch removing text', (done) => { + vm.mr.isRemovingSourceBranch = true; + vm.mr.sourceBranchRemoved = false; + + Vue.nextTick(() => { + expect(el.innerText).toContain('The source branch is being removed.'); + expect(el.innerText).not.toContain('You can remove source branch now.'); + expect(el.innerText).not.toContain('The source branch has been removed.'); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js new file mode 100644 index 00000000000..98674d12afb --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js @@ -0,0 +1,55 @@ +import Vue from 'vue'; +import missingBranchComponent from '~/vue_merge_request_widget/components/states/mr_widget_missing_branch'; + +const createComponent = () => { + const Component = Vue.extend(missingBranchComponent); + const mr = { + sourceBranchRemoved: true, + }; + + return new Component({ + el: document.createElement('div'), + propsData: { mr }, + }); +}; + +describe('MRWidgetMissingBranch', () => { + describe('props', () => { + it('should have props', () => { + const mrProp = missingBranchComponent.props.mr; + + expect(mrProp.type instanceof Object).toBeTruthy(); + expect(mrProp.required).toBeTruthy(); + }); + }); + + describe('components', () => { + it('should have components added', () => { + expect(missingBranchComponent.components['mr-widget-merge-help']).toBeDefined(); + }); + }); + + describe('computed', () => { + describe('missingBranchName', () => { + it('should return proper branch name', () => { + const vm = createComponent(); + expect(vm.missingBranchName).toEqual('source'); + + vm.mr.sourceBranchRemoved = false; + expect(vm.missingBranchName).toEqual('target'); + }); + }); + }); + + describe('template', () => { + it('should have correct elements', () => { + const el = createComponent().$el; + const content = el.textContent.replace(/\n(\s)+/g, ' ').trim(); + + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.querySelector('button').getAttribute('disabled')).toBeTruthy(); + expect(content).toContain('source branch does not exist.'); + expect(content).toContain('Please restore the source branch or use a different source branch.'); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js new file mode 100644 index 00000000000..61e00f4cf79 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +import notAllowedComponent from '~/vue_merge_request_widget/components/states/mr_widget_not_allowed'; + +describe('MRWidgetNotAllowed', () => { + describe('template', () => { + const Component = Vue.extend(notAllowedComponent); + const vm = new Component({ + el: document.createElement('div'), + }); + it('should have correct elements', () => { + expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy(); + expect(vm.$el.innerText).toContain('Ready to be merged automatically.'); + expect(vm.$el.innerText).toContain('Ask someone with write access to this repository to merge this request.'); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js new file mode 100644 index 00000000000..d40c67b189d --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +import nothingToMergeComponent from '~/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge'; + +describe('MRWidgetNothingToMerge', () => { + describe('template', () => { + const Component = Vue.extend(nothingToMergeComponent); + const vm = new Component({ + el: document.createElement('div'), + }); + it('should have correct elements', () => { + expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy(); + expect(vm.$el.innerText).toContain('There is nothing to merge from source branch into target branch.'); + expect(vm.$el.innerText).toContain('Please push new commits or use a different branch.'); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js new file mode 100644 index 00000000000..b293d118571 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import pipelineBlockedComponent from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked'; + +describe('MRWidgetPipelineBlocked', () => { + describe('template', () => { + const Component = Vue.extend(pipelineBlockedComponent); + const vm = new Component({ + el: document.createElement('div'), + }); + it('should have correct elements', () => { + expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy(); + expect(vm.$el.innerText).toContain('Pipeline blocked. The pipeline for this merge request requires a manual action to proceed.'); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_failed_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_failed_spec.js new file mode 100644 index 00000000000..807fba705d4 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_failed_spec.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import pipelineFailedComponent from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_failed'; + +describe('MRWidgetPipelineFailed', () => { + describe('template', () => { + const Component = Vue.extend(pipelineFailedComponent); + const vm = new Component({ + el: document.createElement('div'), + }); + it('should have correct elements', () => { + expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy(); + expect(vm.$el.innerText).toContain('The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure.'); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js new file mode 100644 index 00000000000..74df99415c9 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -0,0 +1,389 @@ +import Vue from 'vue'; +import readyToMergeComponent from '~/vue_merge_request_widget/components/states/mr_widget_ready_to_merge'; +import eventHub from '~/vue_merge_request_widget/event_hub'; +import * as simplePoll from '~/lib/utils/simple_poll'; + +const commitMessage = 'This is the commit message'; +const commitMessageWithDescription = 'This is the commit message description'; +const createComponent = () => { + const Component = Vue.extend(readyToMergeComponent); + const mr = { + isPipelineActive: false, + pipeline: null, + isPipelineFailed: false, + onlyAllowMergeIfPipelineSucceeds: false, + hasCI: false, + ciStatus: null, + sha: '12345678', + commitMessage, + commitMessageWithDescription, + }; + + const service = { + merge() {}, + poll() {}, + }; + + return new Component({ + el: document.createElement('div'), + propsData: { mr, service }, + }); +}; + +describe('MRWidgetReadyToMerge', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + describe('props', () => { + it('should have props', () => { + const { mr, service } = readyToMergeComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + + expect(service.type instanceof Object).toBeTruthy(); + expect(service.required).toBeTruthy(); + }); + }); + + describe('data', () => { + it('should have default data', () => { + expect(vm.removeSourceBranch).toBeTruthy(true); + expect(vm.mergeWhenBuildSucceeds).toBeFalsy(); + expect(vm.useCommitMessageWithDescription).toBeFalsy(); + expect(vm.setToMergeWhenPipelineSucceeds).toBeFalsy(); + expect(vm.showCommitMessageEditor).toBeFalsy(); + expect(vm.isMakingRequest).toBeFalsy(); + expect(vm.isMergingImmediately).toBeFalsy(); + expect(vm.commitMessage).toBe(vm.mr.commitMessage); + expect(vm.successSvg).toBeDefined(); + expect(vm.warningSvg).toBeDefined(); + }); + }); + + describe('computed', () => { + describe('commitMessageLinkTitle', () => { + const withDesc = 'Include description in commit message'; + const withoutDesc = "Don't include description in commit message"; + + it('should return message wit description', () => { + expect(vm.commitMessageLinkTitle).toEqual(withDesc); + }); + + it('should return message without description', () => { + vm.useCommitMessageWithDescription = true; + expect(vm.commitMessageLinkTitle).toEqual(withoutDesc); + }); + }); + + describe('mergeButtonClass', () => { + const defaultClass = 'btn btn-success accept-merge-request'; + const failedClass = `${defaultClass} btn-danger`; + const inActionClass = `${defaultClass} btn-info`; + + it('should return default class', () => { + vm.mr.pipeline = true; + expect(vm.mergeButtonClass).toEqual(defaultClass); + }); + + it('should return failed class when MR has CI but also has an unknown status', () => { + vm.mr.hasCI = true; + expect(vm.mergeButtonClass).toEqual(failedClass); + }); + + it('should return default class when MR has no pipeline', () => { + expect(vm.mergeButtonClass).toEqual(defaultClass); + }); + + it('should return in action class when pipeline is active', () => { + vm.mr.pipeline = {}; + vm.mr.isPipelineActive = true; + expect(vm.mergeButtonClass).toEqual(inActionClass); + }); + + it('should return failed class when pipeline is failed', () => { + vm.mr.pipeline = {}; + vm.mr.isPipelineFailed = true; + expect(vm.mergeButtonClass).toEqual(failedClass); + }); + }); + + describe('mergeButtonText', () => { + it('should return Merge', () => { + expect(vm.mergeButtonText).toEqual('Merge'); + }); + + it('should return Merge in progress', () => { + vm.isMergingImmediately = true; + expect(vm.mergeButtonText).toEqual('Merge in progress'); + }); + + it('should return Merge when pipeline succeeds', () => { + vm.isMergingImmediately = false; + vm.mr.isPipelineActive = true; + expect(vm.mergeButtonText).toEqual('Merge when pipeline succeeds'); + }); + }); + + describe('shouldShowMergeOptionsDropdown', () => { + it('should return false with initial data', () => { + expect(vm.shouldShowMergeOptionsDropdown).toBeFalsy(); + }); + + it('should return true when pipeline active', () => { + vm.mr.isPipelineActive = true; + expect(vm.shouldShowMergeOptionsDropdown).toBeTruthy(); + }); + + it('should return false when pipeline active but only merge when pipeline succeeds set in project options', () => { + vm.mr.isPipelineActive = true; + vm.mr.onlyAllowMergeIfPipelineSucceeds = true; + expect(vm.shouldShowMergeOptionsDropdown).toBeFalsy(); + }); + }); + + describe('isMergeButtonDisabled', () => { + it('should return false with initial data', () => { + expect(vm.isMergeButtonDisabled).toBeFalsy(); + }); + + it('should return true when there is no commit message', () => { + vm.commitMessage = ''; + expect(vm.isMergeButtonDisabled).toBeTruthy(); + }); + + it('should return true if merge is not allowed', () => { + vm.mr.onlyAllowMergeIfPipelineSucceeds = true; + vm.mr.isPipelineFailed = true; + expect(vm.isMergeButtonDisabled).toBeTruthy(); + }); + + it('should return true when there vm instance is making request', () => { + vm.isMakingRequest = true; + expect(vm.isMergeButtonDisabled).toBeTruthy(); + }); + }); + }); + + describe('methods', () => { + describe('isMergeAllowed', () => { + it('should return false with initial data', () => { + expect(vm.isMergeAllowed()).toBeTruthy(); + }); + + it('should return false when MR is set only merge when pipeline succeeds', () => { + vm.mr.onlyAllowMergeIfPipelineSucceeds = true; + expect(vm.isMergeAllowed()).toBeTruthy(); + }); + + it('should return true true', () => { + vm.mr.onlyAllowMergeIfPipelineSucceeds = true; + vm.mr.isPipelineFailed = true; + expect(vm.isMergeAllowed()).toBeFalsy(); + }); + }); + + describe('updateCommitMessage', () => { + it('should revert flag and change commitMessage', () => { + expect(vm.useCommitMessageWithDescription).toBeFalsy(); + expect(vm.commitMessage).toEqual(commitMessage); + vm.updateCommitMessage(); + expect(vm.useCommitMessageWithDescription).toBeTruthy(); + expect(vm.commitMessage).toEqual(commitMessageWithDescription); + vm.updateCommitMessage(); + expect(vm.useCommitMessageWithDescription).toBeFalsy(); + expect(vm.commitMessage).toEqual(commitMessage); + }); + }); + + describe('toggleCommitMessageEditor', () => { + it('should toggle showCommitMessageEditor flag', () => { + expect(vm.showCommitMessageEditor).toBeFalsy(); + vm.toggleCommitMessageEditor(); + expect(vm.showCommitMessageEditor).toBeTruthy(); + }); + }); + + describe('handleMergeButtonClick', () => { + const returnPromise = status => new Promise((resolve) => { + resolve({ + json() { + return { status }; + }, + }); + }); + + it('should handle merge when pipeline succeeds', (done) => { + spyOn(eventHub, '$emit'); + spyOn(vm.service, 'merge').and.returnValue(returnPromise('merge_when_pipeline_succeeds')); + vm.removeSourceBranch = false; + vm.handleMergeButtonClick(true); + + setTimeout(() => { + expect(vm.setToMergeWhenPipelineSucceeds).toBeTruthy(); + expect(vm.isMakingRequest).toBeTruthy(); + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + + const params = vm.service.merge.calls.argsFor(0)[0]; + expect(params.sha).toEqual(vm.mr.sha); + expect(params.commit_message).toEqual(vm.mr.commitMessage); + expect(params.should_remove_source_branch).toBeFalsy(); + expect(params.merge_when_pipeline_succeeds).toBeTruthy(); + done(); + }, 333); + }); + + it('should handle merge failed', (done) => { + spyOn(eventHub, '$emit'); + spyOn(vm.service, 'merge').and.returnValue(returnPromise('failed')); + vm.handleMergeButtonClick(false, true); + + setTimeout(() => { + expect(vm.setToMergeWhenPipelineSucceeds).toBeFalsy(); + expect(vm.isMakingRequest).toBeTruthy(); + expect(eventHub.$emit).toHaveBeenCalledWith('FailedToMerge', undefined); + + const params = vm.service.merge.calls.argsFor(0)[0]; + expect(params.should_remove_source_branch).toBeTruthy(); + expect(params.merge_when_pipeline_succeeds).toBeFalsy(); + done(); + }, 333); + }); + + it('should handle merge action accepted case', (done) => { + spyOn(vm.service, 'merge').and.returnValue(returnPromise('success')); + spyOn(vm, 'initiateMergePolling'); + vm.handleMergeButtonClick(); + + setTimeout(() => { + expect(vm.setToMergeWhenPipelineSucceeds).toBeFalsy(); + expect(vm.isMakingRequest).toBeTruthy(); + expect(vm.initiateMergePolling).toHaveBeenCalled(); + + const params = vm.service.merge.calls.argsFor(0)[0]; + expect(params.should_remove_source_branch).toBeTruthy(); + expect(params.merge_when_pipeline_succeeds).toBeFalsy(); + done(); + }, 333); + }); + }); + + describe('initiateMergePolling', () => { + it('should call simplePoll', () => { + spyOn(simplePoll, 'default'); + vm.initiateMergePolling(); + expect(simplePoll.default).toHaveBeenCalled(); + }); + }); + + describe('handleMergePolling', () => { + const returnPromise = state => new Promise((resolve) => { + resolve({ + json() { + return { state, source_branch_exists: true }; + }, + }); + }); + + it('should call start and stop polling when MR merged', (done) => { + spyOn(eventHub, '$emit'); + spyOn(vm.service, 'poll').and.returnValue(returnPromise('merged')); + spyOn(vm, 'initiateRemoveSourceBranchPolling'); + + let cpc = false; // continuePollingCalled + let spc = false; // stopPollingCalled + + vm.handleMergePolling(() => { cpc = true; }, () => { spc = true; }); + setTimeout(() => { + expect(vm.service.poll).toHaveBeenCalled(); + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + expect(eventHub.$emit).toHaveBeenCalledWith('FetchActionsContent'); + expect(vm.initiateRemoveSourceBranchPolling).toHaveBeenCalled(); + expect(cpc).toBeFalsy(); + expect(spc).toBeTruthy(); + + done(); + }, 333); + }); + + it('should continue polling until MR is merged', (done) => { + spyOn(vm.service, 'poll').and.returnValue(returnPromise('some_other_state')); + spyOn(vm, 'initiateRemoveSourceBranchPolling'); + + let cpc = false; // continuePollingCalled + let spc = false; // stopPollingCalled + + vm.handleMergePolling(() => { cpc = true; }, () => { spc = true; }); + setTimeout(() => { + expect(cpc).toBeTruthy(); + expect(spc).toBeFalsy(); + + done(); + }, 333); + }); + }); + + describe('initiateRemoveSourceBranchPolling', () => { + it('should emit event and call simplePoll', () => { + spyOn(eventHub, '$emit'); + spyOn(simplePoll, 'default'); + + vm.initiateRemoveSourceBranchPolling(); + expect(eventHub.$emit).toHaveBeenCalledWith('SetBranchRemoveFlag', [true]); + expect(simplePoll.default).toHaveBeenCalled(); + }); + }); + + describe('handleRemoveBranchPolling', () => { + const returnPromise = state => new Promise((resolve) => { + resolve({ + json() { + return { source_branch_exists: state }; + }, + }); + }); + + it('should call start and stop polling when MR merged', (done) => { + spyOn(eventHub, '$emit'); + spyOn(vm.service, 'poll').and.returnValue(returnPromise(false)); + + let cpc = false; // continuePollingCalled + let spc = false; // stopPollingCalled + + vm.handleRemoveBranchPolling(() => { cpc = true; }, () => { spc = true; }); + setTimeout(() => { + expect(vm.service.poll).toHaveBeenCalled(); + + const args = eventHub.$emit.calls.argsFor(0); + expect(args[0]).toEqual('MRWidgetUpdateRequested'); + expect(args[1]).toBeDefined(); + args[1](); + expect(eventHub.$emit).toHaveBeenCalledWith('SetBranchRemoveFlag', [false]); + + expect(cpc).toBeFalsy(); + expect(spc).toBeTruthy(); + + done(); + }, 333); + }); + + it('should continue polling until MR is merged', (done) => { + spyOn(vm.service, 'poll').and.returnValue(returnPromise(true)); + + let cpc = false; // continuePollingCalled + let spc = false; // stopPollingCalled + + vm.handleRemoveBranchPolling(() => { cpc = true; }, () => { spc = true; }); + setTimeout(() => { + expect(cpc).toBeTruthy(); + expect(spc).toBeFalsy(); + + done(); + }, 333); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js new file mode 100644 index 00000000000..5fb1d69a8b3 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import shaMismatchComponent from '~/vue_merge_request_widget/components/states/mr_widget_sha_mismatch'; + +describe('MRWidgetSHAMismatch', () => { + describe('template', () => { + const Component = Vue.extend(shaMismatchComponent); + const vm = new Component({ + el: document.createElement('div'), + }); + it('should have correct elements', () => { + expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy(); + expect(vm.$el.innerText).toContain('The source branch HEAD has recently changed. Please reload the page and review the changes before merging.'); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js new file mode 100644 index 00000000000..fe87f110354 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js @@ -0,0 +1,47 @@ +import Vue from 'vue'; +import unresolvedDiscussionsComponent from '~/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions'; + +describe('MRWidgetUnresolvedDiscussions', () => { + describe('props', () => { + it('should have props', () => { + const { mr } = unresolvedDiscussionsComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + }); + }); + + describe('template', () => { + let el; + let vm; + const path = 'foo/bar'; + + beforeEach(() => { + const Component = Vue.extend(unresolvedDiscussionsComponent); + const mr = { + createIssueToResolveDiscussionsPath: path, + }; + vm = new Component({ + el: document.createElement('div'), + propsData: { mr }, + }); + el = vm.$el; + }); + + it('should have correct elements', () => { + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.innerText).toContain('There are unresolved discussions. Please resolve these discussions'); + expect(el.innerText).toContain('Create an issue to resolve them later'); + expect(el.querySelector('.js-create-issue').getAttribute('href')).toEqual(path); + }); + + it('should not show create issue button if user cannot create issue', (done) => { + vm.mr.createIssueToResolveDiscussionsPath = ''; + + Vue.nextTick(() => { + expect(el.querySelector('.js-create-issue')).toEqual(null); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_wip_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_wip_spec.js new file mode 100644 index 00000000000..45bd1a69964 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_wip_spec.js @@ -0,0 +1,96 @@ +import Vue from 'vue'; +import wipComponent from '~/vue_merge_request_widget/components/states/mr_widget_wip'; +import eventHub from '~/vue_merge_request_widget/event_hub'; + +const createComponent = () => { + const Component = Vue.extend(wipComponent); + const mr = { + title: 'The best MR ever', + removeWIPPath: '/path/to/remove/wip', + }; + const service = { + removeWIP() {}, + }; + return new Component({ + el: document.createElement('div'), + propsData: { mr, service }, + }); +}; + +describe('MRWidgetWIP', () => { + describe('props', () => { + it('should have props', () => { + const { mr, service } = wipComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + + expect(service.type instanceof Object).toBeTruthy(); + expect(service.required).toBeTruthy(); + }); + }); + + describe('data', () => { + it('should have default data', () => { + const vm = createComponent(); + expect(vm.isMakingRequest).toBeFalsy(); + }); + }); + + describe('methods', () => { + const mrObj = { + is_new_mr_data: true, + }; + + describe('removeWIP', () => { + it('should make a request to service and handle response', (done) => { + const vm = createComponent(); + + spyOn(window, 'Flash').and.returnValue(true); + spyOn(eventHub, '$emit'); + spyOn(vm.service, 'removeWIP').and.returnValue(new Promise((resolve) => { + resolve({ + json() { + return mrObj; + }, + }); + })); + + vm.removeWIP(); + setTimeout(() => { + expect(vm.isMakingRequest).toBeTruthy(); + expect(eventHub.$emit).toHaveBeenCalledWith('UpdateWidgetData', mrObj); + expect(window.Flash).toHaveBeenCalledWith('The merge request can now be merged.', 'notice'); + done(); + }, 333); + }); + }); + }); + + describe('template', () => { + let vm; + let el; + + beforeEach(() => { + vm = createComponent(); + el = vm.$el; + }); + + it('should have correct elements', () => { + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.innerText).toContain('This merge request is currently Work In Progress and therefore unable to merge'); + expect(el.querySelector('button').getAttribute('disabled')).toBeTruthy(); + expect(el.querySelector('button').innerText).toContain('Merge'); + expect(el.querySelector('.js-remove-wip').innerText).toContain('Resolve WIP status'); + }); + + it('should not show removeWIP button is user cannot update MR', (done) => { + vm.mr.removeWIPPath = ''; + + Vue.nextTick(() => { + expect(el.querySelector('.js-remove-wip')).toEqual(null); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/mock_data.js b/spec/javascripts/vue_mr_widget/mock_data.js new file mode 100644 index 00000000000..e6f96d5588b --- /dev/null +++ b/spec/javascripts/vue_mr_widget/mock_data.js @@ -0,0 +1,214 @@ +/* eslint-disable */ + +export default { + "id": 132, + "iid": 22, + "assignee_id": null, + "author_id": 1, + "description": "", + "lock_version": null, + "milestone_id": null, + "position": 0, + "state": "merged", + "title": "Update README.md", + "updated_by_id": null, + "created_at": "2017-04-07T12:27:26.718Z", + "updated_at": "2017-04-07T15:39:25.852Z", + "deleted_at": null, + "time_estimate": 0, + "total_time_spent": 0, + "human_time_estimate": null, + "human_total_time_spent": null, + "in_progress_merge_commit_sha": null, + "locked_at": null, + "merge_commit_sha": "53027d060246c8f47e4a9310fb332aa52f221775", + "merge_error": null, + "merge_params": { + "force_remove_source_branch": null + }, + "merge_status": "can_be_merged", + "merge_user_id": null, + "merge_when_pipeline_succeeds": false, + "source_branch": "daaaa", + "source_project_id": 19, + "target_branch": "master", + "target_project_id": 19, + "merge_event": { + "author": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "updated_at": "2017-04-07T15:39:25.696Z" + }, + "closed_event": null, + "author": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "merge_user": null, + "diff_head_sha": "104096c51715e12e7ae41f9333e9fa35b73f385d", + "diff_head_commit_short_id": "104096c5", + "merge_commit_message": "Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22", + "pipeline": { + "id": 172, + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "active": false, + "coverage": "92.16", + "path": "/root/acets-app/pipelines/172", + "details": { + "status": { + "icon": "icon_status_success", + "favicon": "favicon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/acets-app/pipelines/172" + }, + "duration": null, + "finished_at": "2017-04-07T14:00:14.256Z", + "stages": [ + { + "name": "build", + "title": "build: failed", + "status": { + "icon": "icon_status_failed", + "favicon": "favicon_status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "has_details": true, + "details_path": "/root/acets-app/pipelines/172#build" + }, + "path": "/root/acets-app/pipelines/172#build", + "dropdown_path": "/root/acets-app/pipelines/172/stage.json?stage=build" + }, + { + "name": "review", + "title": "review: skipped", + "status": { + "icon": "icon_status_skipped", + "favicon": "favicon_status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "has_details": true, + "details_path": "/root/acets-app/pipelines/172#review" + }, + "path": "/root/acets-app/pipelines/172#review", + "dropdown_path": "/root/acets-app/pipelines/172/stage.json?stage=review" + } + ], + "artifacts": [ + + ], + "manual_actions": [ + { + "name": "stop_review", + "path": "/root/acets-app/builds/1427/play", + "playable": false + } + ] + }, + "flags": { + "latest": false, + "triggered": false, + "stuck": false, + "yaml_errors": false, + "retryable": true, + "cancelable": false + }, + "ref": { + "name": "daaaa", + "path": "/root/acets-app/tree/daaaa", + "tag": false, + "branch": true + }, + "commit": { + "id": "104096c51715e12e7ae41f9333e9fa35b73f385d", + "short_id": "104096c5", + "title": "Update README.md", + "created_at": "2017-04-07T15:27:18.000+03:00", + "parent_ids": [ + "2396536178668d8930c29d904e53bd4d06228b32" + ], + "message": "Update README.md", + "author_name": "Administrator", + "author_email": "admin@example.com", + "authored_date": "2017-04-07T15:27:18.000+03:00", + "committer_name": "Administrator", + "committer_email": "admin@example.com", + "committed_date": "2017-04-07T15:27:18.000+03:00", + "author": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "author_gravatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "commit_url": "http://localhost:3000/root/acets-app/commit/104096c51715e12e7ae41f9333e9fa35b73f385d", + "commit_path": "/root/acets-app/commit/104096c51715e12e7ae41f9333e9fa35b73f385d" + }, + "retry_path": "/root/acets-app/pipelines/172/retry", + "created_at": "2017-04-07T12:27:19.520Z", + "updated_at": "2017-04-07T15:28:44.800Z" + }, + "work_in_progress": false, + "source_branch_exists": false, + "mergeable_discussions_state": true, + "conflicts_can_be_resolved_in_ui": false, + "branch_missing": true, + "commits_count": 1, + "has_conflicts": false, + "can_be_merged": true, + "has_ci": true, + "ci_status": "success", + "pipeline_status_path": "/root/acets-app/merge_requests/22/pipeline_status", + "issues_links": { + "closing": "", + "mentioned_but_not_closing": "" + }, + "current_user": { + "can_resolve_conflicts": true, + "can_remove_source_branch": false, + "can_revert_on_current_merge_request": true, + "can_cherry_pick_on_current_merge_request": true + }, + "target_branch_path": "/root/acets-app/branches/master", + "source_branch_path": "/root/acets-app/branches/daaaa", + "conflict_resolution_ui_path": "/root/acets-app/merge_requests/22/conflicts", + "remove_wip_path": "/root/acets-app/merge_requests/22/remove_wip", + "cancel_merge_when_pipeline_succeeds_path": "/root/acets-app/merge_requests/22/cancel_merge_when_pipeline_succeeds", + "create_issue_to_resolve_discussions_path": "/root/acets-app/issues/new?merge_request_to_resolve_discussions_of=22", + "merge_path": "/root/acets-app/merge_requests/22/merge", + "cherry_pick_in_fork_path": "/root/acets-app/forks?continue%5Bnotice%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+has+been+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.+Try+to+revert+this+commit+again.&continue%5Bnotice_now%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+is+being+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.&continue%5Bto%5D=%2Froot%2Facets-app%2Fmerge_requests%2F22&namespace_key=1", + "revert_in_fork_path": "/root/acets-app/forks?continue%5Bnotice%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+has+been+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.+Try+to+cherry-pick+this+commit+again.&continue%5Bnotice_now%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+is+being+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.&continue%5Bto%5D=%2Froot%2Facets-app%2Fmerge_requests%2F22&namespace_key=1", + "email_patches_path": "/root/acets-app/merge_requests/22.patch", + "plain_diff_path": "/root/acets-app/merge_requests/22.diff", + "ci_status_path": "/root/acets-app/merge_requests/22/ci_status", + "status_path": "/root/acets-app/merge_requests/22.json", + "merge_check_path": "/root/acets-app/merge_requests/22/merge_check", + "ci_environments_status_url": "/root/acets-app/merge_requests/22/ci_environments_status", + "project_archived": false, + "merge_commit_message_with_description": "Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22", + "diverged_commits_count": 0, + "only_allow_merge_if_pipeline_succeeds": false, + "commit_change_content_path": "/root/acets-app/merge_requests/22/commit_change_content" +} diff --git a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js new file mode 100644 index 00000000000..22ee7dcf0e7 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js @@ -0,0 +1,326 @@ +import Vue from 'vue'; +import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; +import mrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options'; +import eventHub from '~/vue_merge_request_widget/event_hub'; +import mockData from './mock_data'; + +const createComponent = () => { + delete mrWidgetOptions.el; // Prevent component mounting + gl.mrWidgetData = mockData; + const Component = Vue.extend(mrWidgetOptions); + return new Component(); +}; + +const returnPromise = data => new Promise((resolve) => { + resolve({ + json() { + return data; + }, + body: data, + }); +}); + +describe('mrWidgetOptions', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + describe('data', () => { + it('should instantiate Store and Service', () => { + expect(vm.mr).toBeDefined(); + expect(vm.service).toBeDefined(); + }); + }); + + describe('computed', () => { + describe('componentName', () => { + it('should return merged component', () => { + expect(vm.componentName).toEqual('mr-widget-merged'); + }); + + it('should return conflicts component', () => { + vm.mr.state = 'conflicts'; + expect(vm.componentName).toEqual('mr-widget-conflicts'); + }); + }); + + describe('shouldRenderMergeHelp', () => { + it('should return false for the initial merged state', () => { + expect(vm.shouldRenderMergeHelp).toBeFalsy(); + }); + + it('should return true for a state which requires help widget', () => { + vm.mr.state = 'conflicts'; + expect(vm.shouldRenderMergeHelp).toBeTruthy(); + }); + }); + + describe('shouldRenderPipelines', () => { + it('should return true for the initial data', () => { + expect(vm.shouldRenderPipelines).toBeTruthy(); + }); + + it('should return true when pipeline is empty but MR.hasCI is set to true', () => { + vm.mr.pipeline = {}; + expect(vm.shouldRenderPipelines).toBeTruthy(); + }); + + it('should return true when pipeline available', () => { + vm.mr.hasCI = false; + expect(vm.shouldRenderPipelines).toBeTruthy(); + }); + + it('should return false when there is no pipeline', () => { + vm.mr.pipeline = {}; + vm.mr.hasCI = false; + expect(vm.shouldRenderPipelines).toBeFalsy(); + }); + }); + + describe('shouldRenderRelatedLinks', () => { + it('should return false for the initial data', () => { + expect(vm.shouldRenderRelatedLinks).toBeFalsy(); + }); + + it('should return true if there is relatedLinks in MR', () => { + vm.mr.relatedLinks = {}; + expect(vm.shouldRenderRelatedLinks).toBeTruthy(); + }); + }); + + describe('shouldRenderDeployments', () => { + it('should return false for the initial data', () => { + expect(vm.shouldRenderDeployments).toBeFalsy(); + }); + + it('should return true if there is deployments', () => { + vm.mr.deployments.push({}, {}); + expect(vm.shouldRenderDeployments).toBeTruthy(); + }); + }); + }); + + describe('methods', () => { + describe('checkStatus', () => { + it('should tell service to check status', (done) => { + spyOn(vm.service, 'checkStatus').and.returnValue(returnPromise(mockData)); + spyOn(vm.mr, 'setData'); + let isCbExecuted = false; + const cb = () => { + isCbExecuted = true; + }; + + vm.checkStatus(cb); + + setTimeout(() => { + expect(vm.service.checkStatus).toHaveBeenCalled(); + expect(vm.mr.setData).toHaveBeenCalled(); + expect(isCbExecuted).toBeTruthy(); + done(); + }, 333); + }); + }); + + describe('initPolling', () => { + it('should call SmartInterval', () => { + spyOn(gl, 'SmartInterval').and.returnValue({ + resume() {}, + stopTimer() {}, + }); + vm.initPolling(); + + expect(vm.pollingInterval).toBeDefined(); + expect(gl.SmartInterval).toHaveBeenCalled(); + }); + }); + + describe('initDeploymentsPolling', () => { + it('should call SmartInterval', () => { + spyOn(gl, 'SmartInterval'); + vm.initDeploymentsPolling(); + + expect(vm.deploymentsInterval).toBeDefined(); + expect(gl.SmartInterval).toHaveBeenCalled(); + }); + }); + + describe('fetchDeployments', () => { + it('should fetch deployments', (done) => { + spyOn(vm.service, 'fetchDeployments').and.returnValue(returnPromise([{ deployment: 1 }])); + + vm.fetchDeployments(); + + setTimeout(() => { + expect(vm.service.fetchDeployments).toHaveBeenCalled(); + expect(vm.mr.deployments.length).toEqual(1); + expect(vm.mr.deployments[0].deployment).toEqual(1); + done(); + }, 333); + }); + }); + + describe('fetchActionsContent', () => { + it('should fetch content of Cherry Pick and Revert modals', (done) => { + spyOn(vm.service, 'fetchMergeActionsContent').and.returnValue(returnPromise('hello world')); + + vm.fetchActionsContent(); + + setTimeout(() => { + expect(vm.service.fetchMergeActionsContent).toHaveBeenCalled(); + expect(document.body.textContent).toContain('hello world'); + done(); + }, 333); + }); + }); + + describe('bindEventHubListeners', () => { + it('should bind eventHub listeners', () => { + spyOn(vm, 'checkStatus').and.returnValue(() => {}); + spyOn(vm.service, 'checkStatus').and.returnValue(returnPromise(mockData)); + spyOn(vm, 'fetchActionsContent'); + spyOn(vm.mr, 'setData'); + spyOn(vm, 'resumePolling'); + spyOn(vm, 'stopPolling'); + spyOn(eventHub, '$on'); + + vm.bindEventHubListeners(); + + eventHub.$emit('SetBranchRemoveFlag', ['flag']); + expect(vm.mr.isRemovingSourceBranch).toEqual('flag'); + + eventHub.$emit('FailedToMerge'); + expect(vm.mr.state).toEqual('failedToMerge'); + + eventHub.$emit('UpdateWidgetData', mockData); + expect(vm.mr.setData).toHaveBeenCalledWith(mockData); + + eventHub.$emit('EnablePolling'); + expect(vm.resumePolling).toHaveBeenCalled(); + + eventHub.$emit('DisablePolling'); + expect(vm.stopPolling).toHaveBeenCalled(); + + const listenersWithServiceRequest = { + MRWidgetUpdateRequested: true, + FetchActionsContent: true, + }; + + const allArgs = eventHub.$on.calls.allArgs(); + allArgs.forEach((params) => { + const eventName = params[0]; + const callback = params[1]; + + if (listenersWithServiceRequest[eventName]) { + listenersWithServiceRequest[eventName] = callback; + } + }); + + listenersWithServiceRequest.MRWidgetUpdateRequested(); + expect(vm.checkStatus).toHaveBeenCalled(); + + listenersWithServiceRequest.FetchActionsContent(); + expect(vm.fetchActionsContent).toHaveBeenCalled(); + }); + }); + + describe('handleMounted', () => { + it('should call required methods to do the initial kick-off', () => { + spyOn(vm, 'checkStatus'); + spyOn(vm, 'initDeploymentsPolling'); + spyOn(vm, 'setFavicon'); + + vm.handleMounted(); + + expect(vm.checkStatus).toHaveBeenCalled(); + expect(vm.setFavicon).toHaveBeenCalled(); + expect(vm.initDeploymentsPolling).toHaveBeenCalled(); + }); + }); + + describe('setFavicon', () => { + it('should call setFavicon method', () => { + spyOn(gl.utils, 'setFavicon'); + vm.setFavicon(); + + expect(gl.utils.setFavicon).toHaveBeenCalledWith(vm.mr.ciStatusFaviconPath); + }); + + it('should not call setFavicon when there is no ciStatusFaviconPath', () => { + spyOn(gl.utils, 'setFavicon'); + vm.mr.ciStatusFaviconPath = null; + vm.setFavicon(); + + expect(gl.utils.setFavicon).not.toHaveBeenCalled(); + }); + }); + + describe('resumePolling', () => { + it('should call stopTimer on pollingInterval', () => { + spyOn(vm.pollingInterval, 'resume'); + + vm.resumePolling(); + expect(vm.pollingInterval.resume).toHaveBeenCalled(); + }); + }); + + describe('stopPolling', () => { + it('should call stopTimer on pollingInterval', () => { + spyOn(vm.pollingInterval, 'stopTimer'); + + vm.stopPolling(); + expect(vm.pollingInterval.stopTimer).toHaveBeenCalled(); + }); + }); + + describe('createService', () => { + it('should instantiate a Service', () => { + const endpoints = { + mergePath: '/nice/path', + mergeCheckPath: '/nice/path', + cancelAutoMergePath: '/nice/path', + removeWIPPath: '/nice/path', + sourceBranchPath: '/nice/path', + ciEnvironmentsStatusPath: '/nice/path', + statusPath: '/nice/path', + mergeActionsContentPath: '/nice/path', + }; + + const serviceInstance = vm.createService(endpoints); + const isInstanceOfMRService = serviceInstance instanceof MRWidgetService; + expect(isInstanceOfMRService).toBe(true); + Object.keys(serviceInstance).forEach((key) => { + expect(serviceInstance[key]).toBeDefined(); + }); + }); + }); + }); + + describe('components', () => { + it('should register all components', () => { + const comps = mrWidgetOptions.components; + expect(comps['mr-widget-header']).toBeDefined(); + expect(comps['mr-widget-merge-help']).toBeDefined(); + expect(comps['mr-widget-pipeline']).toBeDefined(); + expect(comps['mr-widget-deployment']).toBeDefined(); + expect(comps['mr-widget-related-links']).toBeDefined(); + expect(comps['mr-widget-merged']).toBeDefined(); + expect(comps['mr-widget-closed']).toBeDefined(); + expect(comps['mr-widget-locked']).toBeDefined(); + expect(comps['mr-widget-failed-to-merge']).toBeDefined(); + expect(comps['mr-widget-wip']).toBeDefined(); + expect(comps['mr-widget-archived']).toBeDefined(); + expect(comps['mr-widget-conflicts']).toBeDefined(); + expect(comps['mr-widget-nothing-to-merge']).toBeDefined(); + expect(comps['mr-widget-not-allowed']).toBeDefined(); + expect(comps['mr-widget-missing-branch']).toBeDefined(); + expect(comps['mr-widget-ready-to-merge']).toBeDefined(); + expect(comps['mr-widget-checking']).toBeDefined(); + expect(comps['mr-widget-unresolved-discussions']).toBeDefined(); + expect(comps['mr-widget-pipeline-blocked']).toBeDefined(); + expect(comps['mr-widget-pipeline-failed']).toBeDefined(); + expect(comps['mr-widget-merge-when-pipeline-succeeds']).toBeDefined(); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/services/mr_widget_service_spec.js b/spec/javascripts/vue_mr_widget/services/mr_widget_service_spec.js new file mode 100644 index 00000000000..b63633c03b8 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/services/mr_widget_service_spec.js @@ -0,0 +1,46 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; +import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; + +Vue.use(VueResource); + +describe('MRWidgetService', () => { + const mr = { + mergePath: './', + mergeCheckPath: './', + cancelAutoMergePath: './', + removeWIPPath: './', + sourceBranchPath: './', + ciEnvironmentsStatusPath: './', + statusPath: './', + mergeActionsContentPath: './', + isServiceStore: true, + }; + + it('should have store and resources created in constructor', () => { + const service = new MRWidgetService(mr); + + expect(service.mergeResource).toBeDefined(); + expect(service.mergeCheckResource).toBeDefined(); + expect(service.cancelAutoMergeResource).toBeDefined(); + expect(service.removeWIPResource).toBeDefined(); + expect(service.removeSourceBranchResource).toBeDefined(); + expect(service.deploymentsResource).toBeDefined(); + expect(service.pollResource).toBeDefined(); + expect(service.mergeActionsContentResource).toBeDefined(); + }); + + it('should have methods defined', () => { + const service = new MRWidgetService(mr); + + expect(service.merge()).toBeDefined(); + expect(service.cancelAutomaticMerge()).toBeDefined(); + expect(service.removeWIP()).toBeDefined(); + expect(service.removeSourceBranch()).toBeDefined(); + expect(service.fetchDeployments()).toBeDefined(); + expect(service.poll()).toBeDefined(); + expect(service.checkStatus()).toBeDefined(); + expect(service.fetchMergeActionsContent()).toBeDefined(); + expect(MRWidgetService.stopEnvironment()).toBeDefined(); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/stores/get_state_key_spec.js b/spec/javascripts/vue_mr_widget/stores/get_state_key_spec.js new file mode 100644 index 00000000000..9a331d99865 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/stores/get_state_key_spec.js @@ -0,0 +1,65 @@ +import getStateKey from '~/vue_merge_request_widget/stores/get_state_key'; + +describe('getStateKey', () => { + it('should return proper state name', () => { + const context = { + mergeStatus: 'checked', + mergeWhenPipelineSucceeds: false, + canMerge: true, + onlyAllowMergeIfPipelineSucceeds: false, + isPipelineFailed: false, + hasMergeableDiscussionsState: false, + isPipelineBlocked: false, + canBeMerged: false, + }; + const data = { + project_archived: false, + branch_missing: false, + commits_count: 2, + has_conflicts: false, + work_in_progress: false, + }; + const bound = getStateKey.bind(context, data); + expect(bound()).toEqual(null); + + context.canBeMerged = true; + expect(bound()).toEqual('readyToMerge'); + + context.hasSHAChanged = true; + expect(bound()).toEqual('shaMismatch'); + + context.isPipelineBlocked = true; + expect(bound()).toEqual('pipelineBlocked'); + + context.hasMergeableDiscussionsState = true; + expect(bound()).toEqual('unresolvedDiscussions'); + + context.onlyAllowMergeIfPipelineSucceeds = true; + context.isPipelineFailed = true; + expect(bound()).toEqual('pipelineFailed'); + + context.canMerge = false; + expect(bound()).toEqual('notAllowedToMerge'); + + context.mergeWhenPipelineSucceeds = true; + expect(bound()).toEqual('mergeWhenPipelineSucceeds'); + + data.work_in_progress = true; + expect(bound()).toEqual('workInProgress'); + + data.has_conflicts = true; + expect(bound()).toEqual('conflicts'); + + context.mergeStatus = 'unchecked'; + expect(bound()).toEqual('checking'); + + data.commits_count = 0; + expect(bound()).toEqual('nothingToMerge'); + + data.branch_missing = true; + expect(bound()).toEqual('missingBranch'); + + data.project_archived = true; + expect(bound()).toEqual('archived'); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js b/spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js new file mode 100644 index 00000000000..56dd0198ae2 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js @@ -0,0 +1,22 @@ +import MergeRequestStore from '~/vue_merge_request_widget/stores/mr_widget_store'; +import mockData from '../mock_data'; + +describe('MergeRequestStore', () => { + describe('setData', () => { + let store; + + beforeEach(() => { + store = new MergeRequestStore(mockData); + }); + + it('should set hasSHAChanged when the diff SHA changes', () => { + store.setData({ ...mockData, diff_head_sha: 'a-different-string' }); + expect(store.hasSHAChanged).toBe(true); + }); + + it('should not set hasSHAChanged when other data changes', () => { + store.setData({ ...mockData, work_in_progress: !mockData.work_in_progress }); + expect(store.hasSHAChanged).toBe(false); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/ci_action_icons_spec.js b/spec/javascripts/vue_shared/ci_action_icons_spec.js index 2e89a07e76e..3d53a5ab24d 100644 --- a/spec/javascripts/vue_shared/ci_action_icons_spec.js +++ b/spec/javascripts/vue_shared/ci_action_icons_spec.js @@ -2,6 +2,7 @@ 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', () => { @@ -19,4 +20,8 @@ describe('getActionIcon', () => { 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/components/ci_badge_link_spec.js b/spec/javascripts/vue_shared/components/ci_badge_link_spec.js new file mode 100644 index 00000000000..daed4da3e15 --- /dev/null +++ b/spec/javascripts/vue_shared/components/ci_badge_link_spec.js @@ -0,0 +1,89 @@ +import Vue from 'vue'; +import ciBadge from '~/vue_shared/components/ci_badge_link.vue'; + +describe('CI Badge Link Component', () => { + let CIBadge; + + const statuses = { + canceled: { + text: 'canceled', + label: 'canceled', + group: 'canceled', + icon: 'icon_status_canceled', + details_path: 'status/canceled', + }, + created: { + text: 'created', + label: 'created', + group: 'created', + icon: 'icon_status_created', + details_path: 'status/created', + }, + failed: { + text: 'failed', + label: 'failed', + group: 'failed', + icon: 'icon_status_failed', + details_path: 'status/failed', + }, + manual: { + text: 'manual', + label: 'manual action', + group: 'manual', + icon: 'icon_status_manual', + details_path: 'status/manual', + }, + pending: { + text: 'pending', + label: 'pending', + group: 'pending', + icon: 'icon_status_pending', + details_path: 'status/pending', + }, + running: { + text: 'running', + label: 'running', + group: 'running', + icon: 'icon_status_running', + details_path: 'status/running', + }, + skipped: { + text: 'skipped', + label: 'skipped', + group: 'skipped', + icon: 'icon_status_skipped', + details_path: 'status/skipped', + }, + success_warining: { + text: 'passed', + label: 'passed', + group: 'success_with_warnings', + icon: 'icon_status_warning', + details_path: 'status/warning', + }, + success: { + text: 'passed', + label: 'passed', + group: 'passed', + icon: 'icon_status_success', + details_path: 'status/passed', + }, + }; + + it('should render each status badge', () => { + CIBadge = Vue.extend(ciBadge); + Object.keys(statuses).map((status) => { + const vm = new CIBadge({ + propsData: { + status: statuses[status], + }, + }).$mount(); + + expect(vm.$el.getAttribute('href')).toEqual(statuses[status].details_path); + expect(vm.$el.textContent.trim()).toEqual(statuses[status].text); + expect(vm.$el.getAttribute('class')).toEqual(`ci-status ci-${statuses[status].group}`); + expect(vm.$el.querySelector('svg')).toBeDefined(); + return vm; + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/ci_icon_spec.js b/spec/javascripts/vue_shared/components/ci_icon_spec.js index 98dc6caa622..d8664408595 100644 --- a/spec/javascripts/vue_shared/components/ci_icon_spec.js +++ b/spec/javascripts/vue_shared/components/ci_icon_spec.js @@ -25,6 +25,7 @@ describe('CI Icon component', () => { propsData: { status: { icon: 'icon_status_success', + group: 'success', }, }, }).$mount(); @@ -37,6 +38,7 @@ describe('CI Icon component', () => { propsData: { status: { icon: 'icon_status_failed', + group: 'failed', }, }, }).$mount(); @@ -49,6 +51,7 @@ describe('CI Icon component', () => { propsData: { status: { icon: 'icon_status_warning', + group: 'warning', }, }, }).$mount(); @@ -61,6 +64,7 @@ describe('CI Icon component', () => { propsData: { status: { icon: 'icon_status_pending', + group: 'pending', }, }, }).$mount(); @@ -73,6 +77,7 @@ describe('CI Icon component', () => { propsData: { status: { icon: 'icon_status_running', + group: 'running', }, }, }).$mount(); @@ -85,6 +90,7 @@ describe('CI Icon component', () => { propsData: { status: { icon: 'icon_status_created', + group: 'created', }, }, }).$mount(); @@ -97,6 +103,7 @@ describe('CI Icon component', () => { propsData: { status: { icon: 'icon_status_skipped', + group: 'skipped', }, }, }).$mount(); @@ -109,6 +116,7 @@ describe('CI Icon component', () => { propsData: { status: { icon: 'icon_status_canceled', + group: 'canceled', }, }, }).$mount(); @@ -121,6 +129,7 @@ describe('CI Icon component', () => { propsData: { status: { icon: 'icon_status_manual', + group: 'manual', }, }, }).$mount(); diff --git a/spec/javascripts/vue_shared/components/commit_spec.js b/spec/javascripts/vue_shared/components/commit_spec.js index df547299d75..242010ba688 100644 --- a/spec/javascripts/vue_shared/components/commit_spec.js +++ b/spec/javascripts/vue_shared/components/commit_spec.js @@ -61,16 +61,16 @@ describe('Commit component', () => { }); it('should render a link to the ref url', () => { - expect(component.$el.querySelector('.branch-name').getAttribute('href')).toEqual(props.commitRef.ref_url); + expect(component.$el.querySelector('.ref-name').getAttribute('href')).toEqual(props.commitRef.ref_url); }); it('should render the ref name', () => { - expect(component.$el.querySelector('.branch-name').textContent).toContain(props.commitRef.name); + expect(component.$el.querySelector('.ref-name').textContent).toContain(props.commitRef.name); }); it('should render the commit short sha with a link to the commit url', () => { - expect(component.$el.querySelector('.commit-id').getAttribute('href')).toEqual(props.commitUrl); - expect(component.$el.querySelector('.commit-id').textContent).toContain(props.shortSha); + expect(component.$el.querySelector('.commit-sha').getAttribute('href')).toEqual(props.commitUrl); + expect(component.$el.querySelector('.commit-sha').textContent).toContain(props.shortSha); }); it('should render the given commitIconSvg', () => { diff --git a/spec/javascripts/vue_shared/components/loading_icon_spec.js b/spec/javascripts/vue_shared/components/loading_icon_spec.js new file mode 100644 index 00000000000..1baf3537741 --- /dev/null +++ b/spec/javascripts/vue_shared/components/loading_icon_spec.js @@ -0,0 +1,53 @@ +import Vue from 'vue'; +import loadingIcon from '~/vue_shared/components/loading_icon.vue'; + +describe('Loading Icon Component', () => { + let LoadingIconComponent; + + beforeEach(() => { + LoadingIconComponent = Vue.extend(loadingIcon); + }); + + it('should render a spinner font awesome icon', () => { + const component = new LoadingIconComponent().$mount(); + + expect( + component.$el.querySelector('i').getAttribute('class'), + ).toEqual('fa fa-spin fa-spinner fa-1x'); + + expect(component.$el.tagName).toEqual('DIV'); + expect(component.$el.classList.contains('text-center')).toEqual(true); + }); + + it('should render accessibility attributes', () => { + const component = new LoadingIconComponent().$mount(); + + const icon = component.$el.querySelector('i'); + expect(icon.getAttribute('aria-hidden')).toEqual('true'); + expect(icon.getAttribute('aria-label')).toEqual('Loading'); + }); + + it('should render the provided label', () => { + const component = new LoadingIconComponent({ + propsData: { + label: 'This is a loading icon', + }, + }).$mount(); + + expect( + component.$el.querySelector('i').getAttribute('aria-label'), + ).toEqual('This is a loading icon'); + }); + + it('should render the provided size', () => { + const component = new LoadingIconComponent({ + propsData: { + size: '2', + }, + }).$mount(); + + expect( + component.$el.querySelector('i').classList.contains('fa-2x'), + ).toEqual(true); + }); +}); diff --git a/spec/javascripts/vue_shared/components/memory_graph_spec.js b/spec/javascripts/vue_shared/components/memory_graph_spec.js new file mode 100644 index 00000000000..d46a3f2328e --- /dev/null +++ b/spec/javascripts/vue_shared/components/memory_graph_spec.js @@ -0,0 +1,143 @@ +import Vue from 'vue'; +import memoryGraphComponent from '~/vue_shared/components/memory_graph'; +import { mockMetrics, mockMedian, mockMedianIndex } from './mock_data'; + +const defaultHeight = '25'; +const defaultWidth = '100'; + +const createComponent = () => { + const Component = Vue.extend(memoryGraphComponent); + + return new Component({ + el: document.createElement('div'), + propsData: { + metrics: [], + deploymentTime: 0, + width: '', + height: '', + pathD: '', + pathViewBox: '', + dotX: '', + dotY: '', + }, + }); +}; + +describe('MemoryGraph', () => { + let vm; + let el; + + beforeEach(() => { + vm = createComponent(); + el = vm.$el; + }); + + describe('props', () => { + it('should have props with defaults', (done) => { + const { metrics, deploymentTime, width, height } = memoryGraphComponent.props; + + Vue.nextTick(() => { + const typeClassMatcher = (propItem, expectedType) => { + const PropItemTypeClass = propItem.type; + expect(new PropItemTypeClass() instanceof expectedType).toBeTruthy(); + expect(propItem.required).toBeTruthy(); + }; + + typeClassMatcher(metrics, Array); + typeClassMatcher(deploymentTime, Number); + typeClassMatcher(width, String); + typeClassMatcher(height, String); + done(); + }); + }); + }); + + describe('data', () => { + it('should have default data', () => { + const data = memoryGraphComponent.data(); + const dataValidator = (dataItem, expectedType, defaultVal) => { + expect(typeof dataItem).toBe(expectedType); + expect(dataItem).toBe(defaultVal); + }; + + dataValidator(data.pathD, 'string', ''); + dataValidator(data.pathViewBox, 'string', ''); + dataValidator(data.dotX, 'string', ''); + dataValidator(data.dotY, 'string', ''); + }); + }); + + describe('computed', () => { + describe('getFormattedMedian', () => { + it('should show human readable median value based on provided median timestamp', () => { + vm.deploymentTime = mockMedian; + const formattedMedian = vm.getFormattedMedian; + expect(formattedMedian.indexOf('Deployed') > -1).toBeTruthy(); + expect(formattedMedian.indexOf('ago') > -1).toBeTruthy(); + }); + }); + }); + + describe('methods', () => { + describe('getMedianMetricIndex', () => { + it('should return index of closest metric timestamp to that of median', () => { + const matchingIndex = vm.getMedianMetricIndex(mockMedian, mockMetrics); + expect(matchingIndex).toBe(mockMedianIndex); + }); + }); + + describe('getGraphPlotValues', () => { + it('should return Object containing values to plot graph', () => { + const plotValues = vm.getGraphPlotValues(mockMedian, mockMetrics); + expect(plotValues.pathD).toBeDefined(); + expect(Array.isArray(plotValues.pathD)).toBeTruthy(); + + expect(plotValues.pathViewBox).toBeDefined(); + expect(typeof plotValues.pathViewBox).toBe('object'); + + expect(plotValues.dotX).toBeDefined(); + expect(typeof plotValues.dotX).toBe('number'); + + expect(plotValues.dotY).toBeDefined(); + expect(typeof plotValues.dotY).toBe('number'); + }); + }); + }); + + describe('template', () => { + it('should render template elements correctly', () => { + expect(el.classList.contains('memory-graph-container')).toBeTruthy(); + expect(el.querySelector('svg')).toBeDefined(); + }); + + it('should render graph when renderGraph is called internally', (done) => { + const { pathD, pathViewBox, dotX, dotY } = vm.getGraphPlotValues(mockMedian, mockMetrics); + vm.height = defaultHeight; + vm.width = defaultWidth; + vm.pathD = `M ${pathD}`; + vm.pathViewBox = `0 0 ${pathViewBox.lineWidth} ${pathViewBox.diff}`; + vm.dotX = dotX; + vm.dotY = dotY; + + Vue.nextTick(() => { + const svgEl = el.querySelector('svg'); + expect(svgEl).toBeDefined(); + expect(svgEl.getAttribute('height')).toBe(defaultHeight); + expect(svgEl.getAttribute('width')).toBe(defaultWidth); + + const pathEl = el.querySelector('path'); + expect(pathEl).toBeDefined(); + expect(pathEl.getAttribute('d')).toBe(`M ${pathD}`); + expect(pathEl.getAttribute('viewBox')).toBe(`0 0 ${pathViewBox.lineWidth} ${pathViewBox.diff}`); + + const circleEl = el.querySelector('circle'); + expect(circleEl).toBeDefined(); + expect(circleEl.getAttribute('r')).toBe('1.5'); + expect(circleEl.getAttribute('tranform')).toBe('translate(0 -1)'); + expect(circleEl.getAttribute('cx')).toBe(`${dotX}`); + expect(circleEl.getAttribute('cy')).toBe(`${dotY}`); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/mock_data.js b/spec/javascripts/vue_shared/components/mock_data.js new file mode 100644 index 00000000000..0d781bdca74 --- /dev/null +++ b/spec/javascripts/vue_shared/components/mock_data.js @@ -0,0 +1,69 @@ +/* eslint-disable */ + +export const mockMetrics = [ + [1493716685, '4.30859375'], + [1493716745, '4.30859375'], + [1493716805, '4.30859375'], + [1493716865, '4.30859375'], + [1493716925, '4.30859375'], + [1493716985, '4.30859375'], + [1493717045, '4.30859375'], + [1493717105, '4.30859375'], + [1493717165, '4.30859375'], + [1493717225, '4.30859375'], + [1493717285, '4.30859375'], + [1493717345, '4.30859375'], + [1493717405, '4.30859375'], + [1493717465, '4.30859375'], + [1493717525, '4.30859375'], + [1493717585, '4.30859375'], + [1493717645, '4.30859375'], + [1493717705, '4.30859375'], + [1493717765, '4.30859375'], + [1493717825, '4.30859375'], + [1493717885, '4.30859375'], + [1493717945, '4.30859375'], + [1493718005, '4.30859375'], + [1493718065, '4.30859375'], + [1493718125, '4.30859375'], + [1493718185, '4.30859375'], + [1493718245, '4.30859375'], + [1493718305, '4.234375'], + [1493718365, '4.234375'], + [1493718425, '4.234375'], + [1493718485, '4.234375'], + [1493718545, '4.243489583333333'], + [1493718605, '4.2109375'], + [1493718665, '4.2109375'], + [1493718725, '4.2109375'], + [1493718785, '4.26171875'], + [1493718845, '4.26171875'], + [1493718905, '4.26171875'], + [1493718965, '4.26171875'], + [1493719025, '4.26171875'], + [1493719085, '4.26171875'], + [1493719145, '4.26171875'], + [1493719205, '4.26171875'], + [1493719265, '4.26171875'], + [1493719325, '4.26171875'], + [1493719385, '4.26171875'], + [1493719445, '4.26171875'], + [1493719505, '4.26171875'], + [1493719565, '4.26171875'], + [1493719625, '4.26171875'], + [1493719685, '4.26171875'], + [1493719745, '4.26171875'], + [1493719805, '4.26171875'], + [1493719865, '4.26171875'], + [1493719925, '4.26171875'], + [1493719985, '4.26171875'], + [1493720045, '4.26171875'], + [1493720105, '4.26171875'], + [1493720165, '4.26171875'], + [1493720225, '4.26171875'], + [1493720285, '4.26171875'], +]; + +export const mockMedian = 1493718485; + +export const mockMedianIndex = 30; diff --git a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js index 699625cdbb7..14280751053 100644 --- a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js +++ b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js @@ -1,27 +1,47 @@ import Vue from 'vue'; import tableRowComp from '~/vue_shared/components/pipelines_table_row'; -import pipeline from '../../commit/pipelines/mock_data'; describe('Pipelines Table Row', () => { - let component; - - beforeEach(() => { + const jsonFixtureName = 'pipelines/pipelines.json'; + const buildComponent = (pipeline) => { const PipelinesTableRowComponent = Vue.extend(tableRowComp); - - component = new PipelinesTableRowComponent({ + return new PipelinesTableRowComponent({ el: document.querySelector('.test-dom-element'), propsData: { pipeline, service: {}, }, }).$mount(); + }; + + let component; + let pipeline; + let pipelineWithoutAuthor; + let pipelineWithoutCommit; + + preloadFixtures(jsonFixtureName); + + beforeEach(() => { + const pipelines = getJSONFixture(jsonFixtureName).pipelines; + pipeline = pipelines.find(p => p.id === 1); + pipelineWithoutAuthor = pipelines.find(p => p.id === 2); + pipelineWithoutCommit = pipelines.find(p => p.id === 3); + }); + + afterEach(() => { + component.$destroy(); }); it('should render a table row', () => { + component = buildComponent(pipeline); expect(component.$el).toEqual('TR'); }); describe('status column', () => { + beforeEach(() => { + component = buildComponent(pipeline); + }); + it('should render a pipeline link', () => { expect( component.$el.querySelector('td.commit-link a').getAttribute('href'), @@ -36,6 +56,10 @@ describe('Pipelines Table Row', () => { }); describe('information column', () => { + beforeEach(() => { + component = buildComponent(pipeline); + }); + it('should render a pipeline link', () => { expect( component.$el.querySelector('td:nth-child(2) a').getAttribute('href'), @@ -63,13 +87,59 @@ describe('Pipelines Table Row', () => { describe('commit column', () => { it('should render link to commit', () => { - expect( - component.$el.querySelector('td:nth-child(3) .commit-id').getAttribute('href'), - ).toEqual(pipeline.commit.commit_path); + component = buildComponent(pipeline); + + const commitLink = component.$el.querySelector('.branch-commit .commit-sha'); + expect(commitLink.getAttribute('href')).toEqual(pipeline.commit.commit_path); + }); + + const findElements = () => { + const commitTitleElement = component.$el.querySelector('.branch-commit .commit-title'); + const commitAuthorElement = commitTitleElement.querySelector('a.avatar-image-container'); + + if (!commitAuthorElement) { + return { commitAuthorElement }; + } + + const commitAuthorLink = commitAuthorElement.getAttribute('href'); + const commitAuthorName = commitAuthorElement.querySelector('img.avatar').getAttribute('title'); + + return { commitAuthorElement, commitAuthorLink, commitAuthorName }; + }; + + it('renders nothing without commit', () => { + expect(pipelineWithoutCommit.commit).toBe(null); + component = buildComponent(pipelineWithoutCommit); + + const { commitAuthorElement } = findElements(); + + expect(commitAuthorElement).toBe(null); + }); + + it('renders commit author', () => { + component = buildComponent(pipeline); + const { commitAuthorLink, commitAuthorName } = findElements(); + + expect(commitAuthorLink).toEqual(pipeline.commit.author.web_url); + expect(commitAuthorName).toEqual(pipeline.commit.author.username); + }); + + it('renders commit with unregistered author', () => { + expect(pipelineWithoutAuthor.commit.author).toBe(null); + component = buildComponent(pipelineWithoutAuthor); + + const { commitAuthorLink, commitAuthorName } = findElements(); + + expect(commitAuthorLink).toEqual(`mailto:${pipelineWithoutAuthor.commit.author_email}`); + expect(commitAuthorName).toEqual(pipelineWithoutAuthor.commit.author_name); }); }); describe('stages column', () => { + beforeEach(() => { + component = buildComponent(pipeline); + }); + it('should render an icon for each stage', () => { expect( component.$el.querySelectorAll('td:nth-child(4) .js-builds-dropdown-button').length, @@ -78,6 +148,10 @@ describe('Pipelines Table Row', () => { }); describe('actions column', () => { + beforeEach(() => { + component = buildComponent(pipeline); + }); + it('should render the provided actions', () => { expect( component.$el.querySelectorAll('td:nth-child(6) ul li').length, diff --git a/spec/javascripts/vue_shared/components/pipelines_table_spec.js b/spec/javascripts/vue_shared/components/pipelines_table_spec.js index 4d3ced944d7..6cc178b8f1d 100644 --- a/spec/javascripts/vue_shared/components/pipelines_table_spec.js +++ b/spec/javascripts/vue_shared/components/pipelines_table_spec.js @@ -1,13 +1,19 @@ import Vue from 'vue'; import pipelinesTableComp from '~/vue_shared/components/pipelines_table'; import '~/lib/utils/datetime_utility'; -import pipeline from '../../commit/pipelines/mock_data'; describe('Pipelines Table', () => { + const jsonFixtureName = 'pipelines/pipelines.json'; + + let pipeline; let PipelinesTableComponent; + preloadFixtures(jsonFixtureName); + beforeEach(() => { PipelinesTableComponent = Vue.extend(pipelinesTableComp); + const pipelines = getJSONFixture(jsonFixtureName).pipelines; + pipeline = pipelines.find(p => p.id === 1); }); describe('table', () => { diff --git a/spec/javascripts/vue_shared/components/table_pagination_spec.js b/spec/javascripts/vue_shared/components/table_pagination_spec.js index 96038718191..895e1c585b4 100644 --- a/spec/javascripts/vue_shared/components/table_pagination_spec.js +++ b/spec/javascripts/vue_shared/components/table_pagination_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import paginationComp from '~/vue_shared/components/table_pagination'; +import paginationComp from '~/vue_shared/components/table_pagination.vue'; import '~/lib/utils/common_utils'; describe('Pagination component', () => { diff --git a/spec/lib/banzai/filter/external_link_filter_spec.rb b/spec/lib/banzai/filter/external_link_filter_spec.rb index d9e4525cb28..6f6c215be87 100644 --- a/spec/lib/banzai/filter/external_link_filter_spec.rb +++ b/spec/lib/banzai/filter/external_link_filter_spec.rb @@ -1,5 +1,22 @@ require 'spec_helper' +shared_examples 'an external link with rel attribute' do + it 'adds rel="nofollow" to external links' do + expect(doc.at_css('a')).to have_attribute('rel') + expect(doc.at_css('a')['rel']).to include 'nofollow' + end + + it 'adds rel="noreferrer" to external links' do + expect(doc.at_css('a')).to have_attribute('rel') + expect(doc.at_css('a')['rel']).to include 'noreferrer' + end + + it 'adds rel="noopener" to external links' do + expect(doc.at_css('a')).to have_attribute('rel') + expect(doc.at_css('a')['rel']).to include 'noopener' + end +end + describe Banzai::Filter::ExternalLinkFilter, lib: true do include FilterSpecHelper @@ -22,49 +39,51 @@ describe Banzai::Filter::ExternalLinkFilter, lib: true do context 'for root links on document' do let(:doc) { filter %q(<a href="https://google.com/">Google</a>) } - it 'adds rel="nofollow" to external links' do - expect(doc.at_css('a')).to have_attribute('rel') - expect(doc.at_css('a')['rel']).to include 'nofollow' - end - - it 'adds rel="noreferrer" to external links' do - expect(doc.at_css('a')).to have_attribute('rel') - expect(doc.at_css('a')['rel']).to include 'noreferrer' - end + it_behaves_like 'an external link with rel attribute' end context 'for nested links on document' do let(:doc) { filter %q(<p><a href="https://google.com/">Google</a></p>) } - it 'adds rel="nofollow" to external links' do - expect(doc.at_css('a')).to have_attribute('rel') - expect(doc.at_css('a')['rel']).to include 'nofollow' + it_behaves_like 'an external link with rel attribute' + end + + context 'for invalid urls' do + it 'skips broken hrefs' do + doc = filter %q(<p><a href="don't crash on broken urls">Google</a></p>) + expected = %q(<p><a href="don't%20crash%20on%20broken%20urls">Google</a></p>) + + expect(doc.to_html).to eq(expected) end + end + + context 'for links with a username' do + context 'with a valid username' do + let(:doc) { filter %q(<a href="https://user@google.com/">Google</a>) } - it 'adds rel="noreferrer" to external links' do - expect(doc.at_css('a')).to have_attribute('rel') - expect(doc.at_css('a')['rel']).to include 'noreferrer' + it_behaves_like 'an external link with rel attribute' + end + + context 'with an impersonated username' do + let(:internal) { Gitlab.config.gitlab.url } + + let(:doc) { filter %Q(<a href="https://#{internal}@example.com" target="_blank">Reverse Tabnabbing</a>) } + + it_behaves_like 'an external link with rel attribute' end end context 'for non-lowercase scheme links' do - let(:doc_with_http) { filter %q(<p><a href="httP://google.com/">Google</a></p>) } - let(:doc_with_https) { filter %q(<p><a href="hTTpS://google.com/">Google</a></p>) } - - it 'adds rel="nofollow" to external links' do - expect(doc_with_http.at_css('a')).to have_attribute('rel') - expect(doc_with_https.at_css('a')).to have_attribute('rel') + context 'with http' do + let(:doc) { filter %q(<p><a href="httP://google.com/">Google</a></p>) } - expect(doc_with_http.at_css('a')['rel']).to include 'nofollow' - expect(doc_with_https.at_css('a')['rel']).to include 'nofollow' + it_behaves_like 'an external link with rel attribute' end - it 'adds rel="noreferrer" to external links' do - expect(doc_with_http.at_css('a')).to have_attribute('rel') - expect(doc_with_https.at_css('a')).to have_attribute('rel') + context 'with https' do + let(:doc) { filter %q(<p><a href="hTTpS://google.com/">Google</a></p>) } - expect(doc_with_http.at_css('a')['rel']).to include 'noreferrer' - expect(doc_with_https.at_css('a')['rel']).to include 'noreferrer' + it_behaves_like 'an external link with rel attribute' end it 'skips internal links' do @@ -84,14 +103,6 @@ describe Banzai::Filter::ExternalLinkFilter, lib: true do context 'for protocol-relative links' do let(:doc) { filter %q(<p><a href="//google.com/">Google</a></p>) } - it 'adds rel="nofollow" to external links' do - expect(doc.at_css('a')).to have_attribute('rel') - expect(doc.at_css('a')['rel']).to include 'nofollow' - end - - it 'adds rel="noreferrer" to external links' do - expect(doc.at_css('a')).to have_attribute('rel') - expect(doc.at_css('a')['rel']).to include 'noreferrer' - end + it_behaves_like 'an external link with rel attribute' end end diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index 53abc056602..fe2c00bb2ca 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -225,7 +225,7 @@ module Ci before_script: ["pwd"], rspec: { script: "rspec", type: "test", only: %w(master deploy) }, staging: { script: "deploy", type: "deploy", only: %w(master deploy) }, - production: { script: "deploy", type: "deploy", only: ["master@path", "deploy"] }, + production: { script: "deploy", type: "deploy", only: ["master@path", "deploy"] } }) config_processor = GitlabCiYamlProcessor.new(config, 'fork') @@ -381,7 +381,7 @@ module Ci before_script: ["pwd"], rspec: { script: "rspec", type: "test", except: ["master", "deploy", "test@fork"] }, staging: { script: "deploy", type: "deploy", except: ["master"] }, - production: { script: "deploy", type: "deploy", except: ["master@fork"] }, + production: { script: "deploy", type: "deploy", except: ["master@fork"] } }) config_processor = GitlabCiYamlProcessor.new(config, 'fork') @@ -716,7 +716,7 @@ module Ci expect(config_processor.builds_for_stage_and_ref("test", "master").first[:options][:cache]).to eq( paths: ["logs/", "binaries/"], untracked: true, - key: 'key', + key: 'key' ) end @@ -734,7 +734,7 @@ module Ci expect(config_processor.builds_for_stage_and_ref("test", "master").first[:options][:cache]).to eq( paths: ["logs/", "binaries/"], untracked: true, - key: 'key', + key: 'key' ) end @@ -743,7 +743,7 @@ module Ci cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'global' }, rspec: { script: "rspec", - cache: { paths: ["test/"], untracked: false, key: 'local' }, + cache: { paths: ["test/"], untracked: false, key: 'local' } } }) @@ -753,7 +753,7 @@ module Ci expect(config_processor.builds_for_stage_and_ref("test", "master").first[:options][:cache]).to eq( paths: ["test/"], untracked: false, - key: 'local', + key: 'local' ) end end diff --git a/spec/lib/expand_variables_spec.rb b/spec/lib/expand_variables_spec.rb index 90628917943..7faa0f31b68 100644 --- a/spec/lib/expand_variables_spec.rb +++ b/spec/lib/expand_variables_spec.rb @@ -25,7 +25,7 @@ describe ExpandVariables do result: 'keyvalueresult', variables: [ { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' }, + { key: 'variable2', value: 'result' } ] }, { value: 'key${variable}${variable2}', result: 'keyvalueresult', @@ -37,7 +37,7 @@ describe ExpandVariables do result: 'keyresultvalue', variables: [ { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' }, + { key: 'variable2', value: 'result' } ] }, { value: 'key${variable2}${variable}', result: 'keyresultvalue', @@ -49,7 +49,7 @@ describe ExpandVariables do result: 'review/feature/add-review-apps', variables: [ { key: 'CI_COMMIT_REF_NAME', value: 'feature/add-review-apps' } - ] }, + ] } ] tests.each do |test| diff --git a/spec/lib/gitlab/asciidoc_spec.rb b/spec/lib/gitlab/asciidoc_spec.rb index 0f47fb2fbd9..2c7ebb15fd7 100644 --- a/spec/lib/gitlab/asciidoc_spec.rb +++ b/spec/lib/gitlab/asciidoc_spec.rb @@ -22,7 +22,22 @@ module Gitlab expect(Asciidoctor).to receive(:convert) .with(input, expected_asciidoc_opts).and_return(html) - expect(render(input)).to eq(html) + expect(render(input, context)).to eq(html) + end + + context "with asciidoc_opts" do + it "merges the options with default ones" do + expected_asciidoc_opts = { + safe: :secure, + backend: :gitlab_html5, + attributes: described_class::DEFAULT_ADOC_ATTRS + } + + expect(Asciidoctor).to receive(:convert) + .with(input, expected_asciidoc_opts).and_return(html) + + render(input, context) + end end context "XSS" do @@ -33,7 +48,7 @@ module Gitlab }, 'images' => { input: 'image:https://localhost.com/image.png[Alt text" onerror="alert(7)]', - output: "<div>\n<p><span><img src=\"https://localhost.com/image.png\" alt=\"Alt text\"></span></p>\n</div>" + output: "<img src=\"https://localhost.com/image.png\" alt=\"Alt text\">" }, 'pre' => { input: '```mypre"><script>alert(3)</script>', @@ -43,10 +58,18 @@ module Gitlab links.each do |name, data| it "does not convert dangerous #{name} into HTML" do - expect(render(data[:input])).to eq(data[:output]) + expect(render(data[:input], context)).to include(data[:output]) end end end + + context 'external links' do + it 'adds the `rel` attribute to the link' do + output = render('link:https://google.com[Google]', context) + + expect(output).to include('rel="nofollow noreferrer noopener"') + end + end end def render(*args) diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index d4a43192d03..50bc3ef1b7c 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -175,7 +175,7 @@ describe Gitlab::Auth, lib: true do user = create( :user, username: 'normal_user', - password: 'my-secret', + password: 'my-secret' ) expect(gl_auth.find_for_git_client(user.username, user.password, project: nil, ip: 'ip')) @@ -186,7 +186,7 @@ describe Gitlab::Auth, lib: true do user = create( :user, username: 'oauth2', - password: 'my-secret', + password: 'my-secret' ) expect(gl_auth.find_for_git_client(user.username, user.password, project: nil, ip: 'ip')) diff --git a/spec/lib/gitlab/backup/manager_spec.rb b/spec/lib/gitlab/backup/manager_spec.rb index f84782ab440..c59ff7fb290 100644 --- a/spec/lib/gitlab/backup/manager_spec.rb +++ b/spec/lib/gitlab/backup/manager_spec.rb @@ -151,7 +151,7 @@ describe Backup::Manager, lib: true do allow(Dir).to receive(:glob).and_return( [ '1451606400_2016_01_01_gitlab_backup.tar', - '1451520000_2015_12_31_gitlab_backup.tar', + '1451520000_2015_12_31_gitlab_backup.tar' ] ) end diff --git a/spec/lib/gitlab/ci/config/entry/global_spec.rb b/spec/lib/gitlab/ci/config/entry/global_spec.rb index 684d01e9056..23270ad5053 100644 --- a/spec/lib/gitlab/ci/config/entry/global_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/global_spec.rb @@ -113,7 +113,7 @@ describe Gitlab::Ci::Config::Entry::Global do describe '#variables_value' do it 'returns variables' do - expect(global.variables_value).to eq(VAR: 'value') + expect(global.variables_value).to eq('VAR' => 'value') end end @@ -154,7 +154,7 @@ describe Gitlab::Ci::Config::Entry::Global do services: ['postgres:9.1', 'mysql:5.5'], stage: 'test', cache: { key: 'k', untracked: true, paths: ['public/'] }, - variables: { VAR: 'value' }, + variables: { 'VAR' => 'value' }, ignore: false, after_script: ['make clean'] }, spinach: { name: :spinach, @@ -167,7 +167,7 @@ describe Gitlab::Ci::Config::Entry::Global do cache: { key: 'k', untracked: true, paths: ['public/'] }, variables: {}, ignore: false, - after_script: ['make clean'] }, + after_script: ['make clean'] } ) end end diff --git a/spec/lib/gitlab/ci/config/entry/variables_spec.rb b/spec/lib/gitlab/ci/config/entry/variables_spec.rb index f15f02f403e..84bfef9e8ad 100644 --- a/spec/lib/gitlab/ci/config/entry/variables_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/variables_spec.rb @@ -13,6 +13,14 @@ describe Gitlab::Ci::Config::Entry::Variables do it 'returns hash with key value strings' do expect(entry.value).to eq config end + + context 'with numeric keys and values in the config' do + let(:config) { { 10 => 20 } } + + it 'converts numeric key and numeric value into strings' do + expect(entry.value).to eq('10' => '20') + end + end end describe '#errors' do diff --git a/spec/lib/gitlab/conflict/file_collection_spec.rb b/spec/lib/gitlab/conflict/file_collection_spec.rb index 39d892c18c0..27f23ea70dc 100644 --- a/spec/lib/gitlab/conflict/file_collection_spec.rb +++ b/spec/lib/gitlab/conflict/file_collection_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Gitlab::Conflict::FileCollection, lib: true do let(:merge_request) { create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start') } - let(:file_collection) { Gitlab::Conflict::FileCollection.new(merge_request) } + let(:file_collection) { described_class.read_only(merge_request) } describe '#files' do it 'returns an array of Conflict::Files' do diff --git a/spec/lib/gitlab/contributions_calendar_spec.rb b/spec/lib/gitlab/contributions_calendar_spec.rb index e18a219ef36..79632e2b6a3 100644 --- a/spec/lib/gitlab/contributions_calendar_spec.rb +++ b/spec/lib/gitlab/contributions_calendar_spec.rb @@ -47,7 +47,7 @@ describe Gitlab::ContributionsCalendar do action: Event::CREATED, target: @targets[project], author: contributor, - created_at: day, + created_at: day ) end diff --git a/spec/lib/gitlab/cycle_analytics/events_spec.rb b/spec/lib/gitlab/cycle_analytics/events_spec.rb index 9d2ba481919..3610a0354e8 100644 --- a/spec/lib/gitlab/cycle_analytics/events_spec.rb +++ b/spec/lib/gitlab/cycle_analytics/events_spec.rb @@ -11,8 +11,6 @@ describe 'cycle analytics events' do end before do - allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([context]) - setup(context) end @@ -132,6 +130,8 @@ describe 'cycle analytics events' do end before do + merge_request.update(head_pipeline: pipeline) + create(:ci_build, pipeline: pipeline, status: :success, author: user) create(:ci_build, pipeline: pipeline, status: :success, author: user) @@ -228,6 +228,8 @@ describe 'cycle analytics events' do end before do + merge_request.update(head_pipeline: pipeline) + create(:ci_build, pipeline: pipeline, status: :success, author: user) create(:ci_build, pipeline: pipeline, status: :success, author: user) @@ -332,7 +334,7 @@ describe 'cycle analytics events' do def setup(context) milestone = create(:milestone, project: project) context.update(milestone: milestone) - mr = create_merge_request_closing_issue(context) + mr = create_merge_request_closing_issue(context, commit_message: "References #{context.to_reference}") ProcessCommitWorker.new.perform(project.id, user.id, mr.commits.last.to_hash) end diff --git a/spec/lib/gitlab/data_builder/push_spec.rb b/spec/lib/gitlab/data_builder/push_spec.rb index dbcfb9b7400..e59cba35b2f 100644 --- a/spec/lib/gitlab/data_builder/push_spec.rb +++ b/spec/lib/gitlab/data_builder/push_spec.rb @@ -35,6 +35,7 @@ describe Gitlab::DataBuilder::Push, lib: true do it { expect(data[:ref]).to eq('refs/tags/v1.1.0') } it { expect(data[:user_id]).to eq(user.id) } it { expect(data[:user_name]).to eq(user.name) } + it { expect(data[:user_username]).to eq(user.username) } it { expect(data[:user_email]).to eq(user.email) } it { expect(data[:user_avatar]).to eq(user.avatar_url) } it { expect(data[:project_id]).to eq(project.id) } diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 737fac14f92..dfa3ae9142e 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -382,13 +382,16 @@ describe Gitlab::Database::MigrationHelpers, lib: true do expect(model).to receive(:add_column). with(:users, :new, :integer, limit: old_column.limit, - default: old_column.default, - null: old_column.null, precision: old_column.precision, scale: old_column.scale) + expect(model).to receive(:change_column_default). + with(:users, :new, old_column.default) + expect(model).to receive(:update_column_in_batches) + expect(model).to receive(:change_column_null).with(:users, :new, false) + expect(model).to receive(:copy_indexes).with(:users, :old, :new) expect(model).to receive(:copy_foreign_keys).with(:users, :old, :new) @@ -406,13 +409,16 @@ describe Gitlab::Database::MigrationHelpers, lib: true do expect(model).to receive(:add_column). with(:users, :new, :integer, limit: old_column.limit, - default: old_column.default, - null: old_column.null, precision: old_column.precision, scale: old_column.scale) + expect(model).to receive(:change_column_default). + with(:users, :new, old_column.default) + expect(model).to receive(:update_column_in_batches) + expect(model).to receive(:change_column_null).with(:users, :new, false) + expect(model).to receive(:copy_indexes).with(:users, :old, :new) expect(model).to receive(:copy_foreign_keys).with(:users, :old, :new) diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb index a25c5da488a..ec444942804 100644 --- a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb +++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb @@ -23,6 +23,7 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do found_ids = subject.namespaces_for_paths(type: :child). map(&:id) + expect(found_ids).to contain_exactly(child.id) end end @@ -39,6 +40,22 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do found_ids = subject.namespaces_for_paths(type: :child). map(&:id) + + expect(found_ids).to contain_exactly(namespace.id) + end + + it 'has no namespaces that look the same' do + _root_namespace = create(:namespace, path: 'THE-path') + _similar_path = create(:namespace, + path: 'not-really-the-path', + parent: create(:namespace)) + namespace = create(:namespace, + path: 'the-path', + parent: create(:namespace)) + + found_ids = subject.namespaces_for_paths(type: :child). + map(&:id) + expect(found_ids).to contain_exactly(namespace.id) end end @@ -53,6 +70,20 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do found_ids = subject.namespaces_for_paths(type: :top_level). map(&:id) + + expect(found_ids).to contain_exactly(root_namespace.id) + end + + it 'has no namespaces that just look the same' do + root_namespace = create(:namespace, path: 'the-path') + _similar_path = create(:namespace, path: 'not-really-the-path') + _child_namespace = create(:namespace, + path: 'the-path', + parent: create(:namespace)) + + found_ids = subject.namespaces_for_paths(type: :top_level). + map(&:id) + expect(found_ids).to contain_exactly(root_namespace.id) end end diff --git a/spec/lib/gitlab/dependency_linker/gemfile_linker_spec.rb b/spec/lib/gitlab/dependency_linker/gemfile_linker_spec.rb new file mode 100644 index 00000000000..2e52097a946 --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/gemfile_linker_spec.rb @@ -0,0 +1,60 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::GemfileLinker, lib: true do + describe '.support?' do + it 'supports Gemfile' do + expect(described_class.support?('Gemfile')).to be_truthy + end + + it 'supports gems.rb' do + expect(described_class.support?('gems.rb')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('Gemfile.lock')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { 'Gemfile' } + + let(:file_content) do + <<-CONTENT.strip_heredoc + source 'https://rubygems.org' + + gem "rails", '4.2.6', github: "rails/rails" + gem 'rails-deprecated_sanitizer', '~> 1.0.3' + gem 'responders', '~> 2.0', :github => 'rails/responders' + gem 'sprockets', '~> 3.6.0', git: 'https://gitlab.example.com/gems/sprockets' + gem 'default_value_for', '~> 3.0.0' + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="noopener noreferrer" target="_blank">#{name}</a>} + end + + it 'links sources' do + expect(subject).to include(link('https://rubygems.org', 'https://rubygems.org')) + end + + it 'links dependencies' do + expect(subject).to include(link('rails', 'https://rubygems.org/gems/rails')) + expect(subject).to include(link('rails-deprecated_sanitizer', 'https://rubygems.org/gems/rails-deprecated_sanitizer')) + expect(subject).to include(link('responders', 'https://rubygems.org/gems/responders')) + expect(subject).to include(link('sprockets', 'https://rubygems.org/gems/sprockets')) + expect(subject).to include(link('default_value_for', 'https://rubygems.org/gems/default_value_for')) + end + + it 'links GitHub repos' do + expect(subject).to include(link('rails/rails', 'https://github.com/rails/rails')) + expect(subject).to include(link('rails/responders', 'https://github.com/rails/responders')) + end + + it 'links Git repos' do + expect(subject).to include(link('https://gitlab.example.com/gems/sprockets', 'https://gitlab.example.com/gems/sprockets')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker_spec.rb b/spec/lib/gitlab/dependency_linker_spec.rb new file mode 100644 index 00000000000..03d5b61d70c --- /dev/null +++ b/spec/lib/gitlab/dependency_linker_spec.rb @@ -0,0 +1,13 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker, lib: true do + describe '.link' do + it 'links using GemfileLinker' do + blob_name = 'Gemfile' + + expect(described_class::GemfileLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + end +end diff --git a/spec/lib/gitlab/diff/highlight_spec.rb b/spec/lib/gitlab/diff/highlight_spec.rb index c6bd4e81f4f..7d7d4a55e63 100644 --- a/spec/lib/gitlab/diff/highlight_spec.rb +++ b/spec/lib/gitlab/diff/highlight_spec.rb @@ -34,7 +34,7 @@ describe Gitlab::Diff::Highlight, lib: true do end it 'highlights and marks added lines' do - code = %Q{+<span id="LC9" class="line" lang="ruby"> <span class="k">raise</span> <span class="no"><span class='idiff left'>RuntimeError</span></span><span class="p"><span class='idiff'>,</span></span><span class='idiff right'> </span><span class="s2">"System commands must be given as an array of strings"</span></span>\n} + code = %Q{+<span id="LC9" class="line" lang="ruby"> <span class="k">raise</span> <span class="no"><span class="idiff left">RuntimeError</span></span><span class="p"><span class="idiff">,</span></span><span class="idiff right"> </span><span class="s2">"System commands must be given as an array of strings"</span></span>\n} expect(subject[5].text).to eq(code) end @@ -67,7 +67,7 @@ describe Gitlab::Diff::Highlight, lib: true do end it 'marks added lines' do - code = %q{+ raise <span class='idiff left right'>RuntimeError, </span>"System commands must be given as an array of strings"} + code = %q{+ raise <span class="idiff left right">RuntimeError, </span>"System commands must be given as an array of strings"} expect(subject[5].text).to eq(code) expect(subject[5].text).to be_html_safe diff --git a/spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb b/spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb new file mode 100644 index 00000000000..d6e8b8ac4b2 --- /dev/null +++ b/spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb @@ -0,0 +1,14 @@ +require 'spec_helper' + +describe Gitlab::Diff::InlineDiffMarkdownMarker, lib: true do + describe '#mark' do + let(:raw) { "abc 'def'" } + let(:inline_diffs) { [2..5] } + let(:subject) { described_class.new(raw).mark(inline_diffs, mode: :deletion) } + + it 'marks the range' do + expect(subject).to eq("ab{-c 'd-}ef'") + expect(subject).to be_html_safe + end + end +end diff --git a/spec/lib/gitlab/diff/inline_diff_marker_spec.rb b/spec/lib/gitlab/diff/inline_diff_marker_spec.rb index 198ff977f24..95da344802d 100644 --- a/spec/lib/gitlab/diff/inline_diff_marker_spec.rb +++ b/spec/lib/gitlab/diff/inline_diff_marker_spec.rb @@ -1,26 +1,26 @@ require 'spec_helper' describe Gitlab::Diff::InlineDiffMarker, lib: true do - describe '#inline_diffs' do + describe '#mark' do context "when the rich text is html safe" do - let(:raw) { "abc 'def'" } + let(:raw) { "abc 'def'" } let(:rich) { %{<span class="abc">abc</span><span class="space"> </span><span class="def">'def'</span>}.html_safe } let(:inline_diffs) { [2..5] } - let(:subject) { Gitlab::Diff::InlineDiffMarker.new(raw, rich).mark(inline_diffs) } + let(:subject) { described_class.new(raw, rich).mark(inline_diffs) } - it 'marks the inline diffs' do - expect(subject).to eq(%{<span class="abc">ab<span class='idiff left'>c</span></span><span class="space"><span class='idiff'> </span></span><span class="def"><span class='idiff right'>'d</span>ef'</span>}) + it 'marks the range' do + expect(subject).to eq(%{<span class="abc">ab<span class="idiff left">c</span></span><span class="space"><span class="idiff"> </span></span><span class="def"><span class="idiff right">'d</span>ef'</span>}) expect(subject).to be_html_safe end end context "when the text text is not html safe" do - let(:raw) { "abc 'def'" } + let(:raw) { "abc 'def'" } let(:inline_diffs) { [2..5] } - let(:subject) { Gitlab::Diff::InlineDiffMarker.new(raw).mark(inline_diffs) } + let(:subject) { described_class.new(raw).mark(inline_diffs) } - it 'marks the inline diffs' do - expect(subject).to eq(%{ab<span class='idiff left right'>c 'd</span>ef'}) + it 'marks the range' do + expect(subject).to eq(%{ab<span class="idiff left right">c 'd</span>ef'}) expect(subject).to be_html_safe end end diff --git a/spec/lib/gitlab/diff/position_tracer_spec.rb b/spec/lib/gitlab/diff/position_tracer_spec.rb index a10a251dc4a..4d202a76e1b 100644 --- a/spec/lib/gitlab/diff/position_tracer_spec.rb +++ b/spec/lib/gitlab/diff/position_tracer_spec.rb @@ -1372,7 +1372,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do nil, { old_path: file_name, new_path: file_name, old_line: 5, new_line: 5 }, { old_path: file_name, old_line: 6 }, - { new_path: file_name, new_line: 7 }, + { new_path: file_name, new_line: 7 } ] expect_positions(old_position_attrs, new_position_attrs) @@ -1444,7 +1444,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do nil, { old_path: file_name, new_path: file_name, old_line: 5, new_line: 5 }, { old_path: file_name, old_line: 6 }, - { new_path: file_name, new_line: 7 }, + { new_path: file_name, new_line: 7 } ] expect_positions(old_position_attrs, new_position_attrs) @@ -1498,7 +1498,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do { old_path: file_name, new_path: file_name, old_line: 5, new_line: 4 }, { old_path: file_name, new_path: file_name, old_line: 6, new_line: 5 }, nil, - { new_path: file_name, new_line: 6 }, + { new_path: file_name, new_line: 6 } ] expect_positions(old_position_attrs, new_position_attrs) @@ -1746,7 +1746,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do { old_path: file_name, new_path: file_name, old_line: 4, new_line: 5 }, { old_path: file_name, old_line: 5 }, { new_path: file_name, new_line: 6 }, - { new_path: file_name, new_line: 7 }, + { new_path: file_name, new_line: 7 } ] expect_positions(old_position_attrs, new_position_attrs) diff --git a/spec/lib/gitlab/etag_caching/router_spec.rb b/spec/lib/gitlab/etag_caching/router_spec.rb index f3dacb4ef04..5ae4a19263c 100644 --- a/spec/lib/gitlab/etag_caching/router_spec.rb +++ b/spec/lib/gitlab/etag_caching/router_spec.rb @@ -14,7 +14,7 @@ describe Gitlab::EtagCaching::Router do it 'matches issue title endpoint' do env = build_env( - '/my-group/my-project/issues/123/rendered_title' + '/my-group/my-project/issues/123/realtime_changes' ) result = described_class.match(env) diff --git a/spec/lib/gitlab/file_finder_spec.rb b/spec/lib/gitlab/file_finder_spec.rb new file mode 100644 index 00000000000..5a32ffd462c --- /dev/null +++ b/spec/lib/gitlab/file_finder_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +describe Gitlab::FileFinder, lib: true do + describe '#find' do + let(:project) { create(:project, :public, :repository) } + let(:finder) { described_class.new(project, project.default_branch) } + + it 'finds by name' do + results = finder.find('files') + expect(results.map(&:first)).to include('files/images/wm.svg') + end + + it 'finds by content' do + results = finder.find('files') + + blob = results.select { |result| result.first == "CHANGELOG" }.flatten.last + + expect(blob.filename).to eq("CHANGELOG") + end + end +end diff --git a/spec/lib/gitlab/git/diff_spec.rb b/spec/lib/gitlab/git/diff_spec.rb index 7253a2edeff..4189aaef643 100644 --- a/spec/lib/gitlab/git/diff_spec.rb +++ b/spec/lib/gitlab/git/diff_spec.rb @@ -120,7 +120,7 @@ EOT new_mode: 0100644, from_id: '357406f3075a57708d0163752905cc1576fceacc', to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0', - raw_chunks: raw_chunks, + raw_chunks: raw_chunks ) ) end diff --git a/spec/lib/gitlab/git/encoding_helper_spec.rb b/spec/lib/gitlab/git/encoding_helper_spec.rb index f6ac7b23d1d..1a3bf802a07 100644 --- a/spec/lib/gitlab/git/encoding_helper_spec.rb +++ b/spec/lib/gitlab/git/encoding_helper_spec.rb @@ -19,8 +19,8 @@ describe Gitlab::Git::EncodingHelper do [ 'removes invalid bytes from ASCII-8bit encoded multibyte string. This can occur when a git diff match line truncates in the middle of a multibyte character. This occurs after the second word in this example. The test string is as short as we can get while still triggering the error condition when not looking at `detect[:confidence]`.', "mu ns\xC3\n Lorem ipsum dolor sit amet, consectetur adipisicing ut\xC3\xA0y\xC3\xB9abcd\xC3\xB9efg kia elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non p\n {: .normal_pn}\n \n-Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in\n# *Lorem ipsum\xC3\xB9l\xC3\xB9l\xC3\xA0 dolor\xC3\xB9k\xC3\xB9 sit\xC3\xA8b\xC3\xA8 N\xC3\xA8 amet b\xC3\xA0d\xC3\xAC*\n+# *consectetur\xC3\xB9l\xC3\xB9l\xC3\xA0 adipisicing\xC3\xB9k\xC3\xB9 elit\xC3\xA8b\xC3\xA8 N\xC3\xA8 sed do\xC3\xA0d\xC3\xAC*{: .italic .smcaps}\n \n \xEF\x9B\xA1 eiusmod tempor incididunt, ut\xC3\xAAn\xC3\xB9 labore et dolore. Tw\xC4\x83nj\xC3\xAC magna aliqua. Ut enim ad minim veniam\n {: .normal}\n@@ -9,5 +9,5 @@ quis nostrud\xC3\xAAt\xC3\xB9 exercitiation ullamco laboris m\xC3\xB9s\xC3\xB9k\xC3\xB9abc\xC3\xB9 nisi ".force_encoding('ASCII-8BIT'), - "mu ns\n Lorem ipsum dolor sit amet, consectetur adipisicing ut\xC3\xA0y\xC3\xB9abcd\xC3\xB9efg kia elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non p\n {: .normal_pn}\n \n-Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in\n# *Lorem ipsum\xC3\xB9l\xC3\xB9l\xC3\xA0 dolor\xC3\xB9k\xC3\xB9 sit\xC3\xA8b\xC3\xA8 N\xC3\xA8 amet b\xC3\xA0d\xC3\xAC*\n+# *consectetur\xC3\xB9l\xC3\xB9l\xC3\xA0 adipisicing\xC3\xB9k\xC3\xB9 elit\xC3\xA8b\xC3\xA8 N\xC3\xA8 sed do\xC3\xA0d\xC3\xAC*{: .italic .smcaps}\n \n \xEF\x9B\xA1 eiusmod tempor incididunt, ut\xC3\xAAn\xC3\xB9 labore et dolore. Tw\xC4\x83nj\xC3\xAC magna aliqua. Ut enim ad minim veniam\n {: .normal}\n@@ -9,5 +9,5 @@ quis nostrud\xC3\xAAt\xC3\xB9 exercitiation ullamco laboris m\xC3\xB9s\xC3\xB9k\xC3\xB9abc\xC3\xB9 nisi ", - ], + "mu ns\n Lorem ipsum dolor sit amet, consectetur adipisicing ut\xC3\xA0y\xC3\xB9abcd\xC3\xB9efg kia elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non p\n {: .normal_pn}\n \n-Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in\n# *Lorem ipsum\xC3\xB9l\xC3\xB9l\xC3\xA0 dolor\xC3\xB9k\xC3\xB9 sit\xC3\xA8b\xC3\xA8 N\xC3\xA8 amet b\xC3\xA0d\xC3\xAC*\n+# *consectetur\xC3\xB9l\xC3\xB9l\xC3\xA0 adipisicing\xC3\xB9k\xC3\xB9 elit\xC3\xA8b\xC3\xA8 N\xC3\xA8 sed do\xC3\xA0d\xC3\xAC*{: .italic .smcaps}\n \n \xEF\x9B\xA1 eiusmod tempor incididunt, ut\xC3\xAAn\xC3\xB9 labore et dolore. Tw\xC4\x83nj\xC3\xAC magna aliqua. Ut enim ad minim veniam\n {: .normal}\n@@ -9,5 +9,5 @@ quis nostrud\xC3\xAAt\xC3\xB9 exercitiation ullamco laboris m\xC3\xB9s\xC3\xB9k\xC3\xB9abc\xC3\xB9 nisi " + ] ].each do |description, test_string, xpect| it description do expect(ext_class.encode!(test_string)).to eq(xpect) @@ -37,18 +37,18 @@ describe Gitlab::Git::EncodingHelper do [ "encodes valid utf8 encoded string to utf8", "λ, λ, λ".encode("UTF-8"), - "λ, λ, λ".encode("UTF-8"), + "λ, λ, λ".encode("UTF-8") ], [ "encodes valid ASCII-8BIT encoded string to utf8", "ascii only".encode("ASCII-8BIT"), - "ascii only".encode("UTF-8"), + "ascii only".encode("UTF-8") ], [ "encodes valid ISO-8859-1 encoded string to utf8", "Rüby ist eine Programmiersprache. Wir verlängern den text damit ICU die Sprache erkennen kann.".encode("ISO-8859-1", "UTF-8"), - "Rüby ist eine Programmiersprache. Wir verlängern den text damit ICU die Sprache erkennen kann.".encode("UTF-8"), - ], + "Rüby ist eine Programmiersprache. Wir verlängern den text damit ICU die Sprache erkennen kann.".encode("UTF-8") + ] ].each do |description, test_string, xpect| it description do r = ext_class.encode_utf8(test_string.force_encoding('UTF-8')) @@ -77,8 +77,8 @@ describe Gitlab::Git::EncodingHelper do [ 'removes invalid bytes from ASCII-8bit encoded multibyte string.', "Lorem ipsum\xC3\n dolor sit amet, xy\xC3\xA0y\xC3\xB9abcd\xC3\xB9efg".force_encoding('ASCII-8BIT'), - "Lorem ipsum\n dolor sit amet, xyàyùabcdùefg", - ], + "Lorem ipsum\n dolor sit amet, xyàyùabcdùefg" + ] ].each do |description, test_string, xpect| it description do expect(ext_class.encode!(test_string)).to eq(xpect) diff --git a/spec/lib/gitlab/git/util_spec.rb b/spec/lib/gitlab/git/util_spec.rb index 69d3ca55397..88c871855df 100644 --- a/spec/lib/gitlab/git/util_spec.rb +++ b/spec/lib/gitlab/git/util_spec.rb @@ -6,7 +6,7 @@ describe Gitlab::Git::Util do ["", 0], ["foo", 1], ["foo\n", 1], - ["foo\n\n", 2], + ["foo\n\n", 2] ].each do |string, line_count| it "counts #{line_count} lines in #{string.inspect}" do expect(described_class.count_lines(string)).to eq(line_count) diff --git a/spec/lib/gitlab/gitaly_client/commit_spec.rb b/spec/lib/gitlab/gitaly_client/commit_spec.rb index abe08ccdfa1..cf1bc74779e 100644 --- a/spec/lib/gitlab/gitaly_client/commit_spec.rb +++ b/spec/lib/gitlab/gitaly_client/commit_spec.rb @@ -1,23 +1,24 @@ require 'spec_helper' describe Gitlab::GitalyClient::Commit do - describe '.diff_from_parent' do - let(:diff_stub) { double('Gitaly::Diff::Stub') } - let(:project) { create(:project, :repository) } - let(:repository_message) { project.repository.gitaly_repository } - let(:commit) { project.commit('913c66a37b4a45b9769037c55c2d238bd0942d2e') } + let(:diff_stub) { double('Gitaly::Diff::Stub') } + let(:project) { create(:project, :repository) } + let(:repository) { project.repository } + let(:repository_message) { repository.gitaly_repository } + let(:commit) { project.commit('913c66a37b4a45b9769037c55c2d238bd0942d2e') } + describe '#diff_from_parent' do context 'when a commit has a parent' do it 'sends an RPC request with the parent ID as left commit' do request = Gitaly::CommitDiffRequest.new( repository: repository_message, left_commit_id: 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660', - right_commit_id: commit.id, + right_commit_id: commit.id ) expect_any_instance_of(Gitaly::Diff::Stub).to receive(:commit_diff).with(request) - described_class.diff_from_parent(commit) + described_class.new(repository).diff_from_parent(commit) end end @@ -27,17 +28,17 @@ describe Gitlab::GitalyClient::Commit do request = Gitaly::CommitDiffRequest.new( repository: repository_message, left_commit_id: '4b825dc642cb6eb9a060e54bf8d69288fbee4904', - right_commit_id: initial_commit.id, + right_commit_id: initial_commit.id ) expect_any_instance_of(Gitaly::Diff::Stub).to receive(:commit_diff).with(request) - described_class.diff_from_parent(initial_commit) + described_class.new(repository).diff_from_parent(initial_commit) end end it 'returns a Gitlab::Git::DiffCollection' do - ret = described_class.diff_from_parent(commit) + ret = described_class.new(repository).diff_from_parent(commit) expect(ret).to be_kind_of(Gitlab::Git::DiffCollection) end @@ -47,7 +48,38 @@ describe Gitlab::GitalyClient::Commit do expect(Gitlab::Git::DiffCollection).to receive(:new).with(kind_of(Enumerable), options) - described_class.diff_from_parent(commit, options) + described_class.new(repository).diff_from_parent(commit, options) + end + end + + describe '#commit_deltas' do + context 'when a commit has a parent' do + it 'sends an RPC request with the parent ID as left commit' do + request = Gitaly::CommitDeltaRequest.new( + repository: repository_message, + left_commit_id: 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660', + right_commit_id: commit.id + ) + + expect_any_instance_of(Gitaly::Diff::Stub).to receive(:commit_delta).with(request).and_return([]) + + described_class.new(repository).commit_deltas(commit) + end + end + + context 'when a commit does not have a parent' do + it 'sends an RPC request with empty tree ref as left commit' do + initial_commit = project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863') + request = Gitaly::CommitDeltaRequest.new( + repository: repository_message, + left_commit_id: '4b825dc642cb6eb9a060e54bf8d69288fbee4904', + right_commit_id: initial_commit.id + ) + + expect_any_instance_of(Gitaly::Diff::Stub).to receive(:commit_delta).with(request).and_return([]) + + described_class.new(repository).commit_deltas(initial_commit) + end end end end diff --git a/spec/lib/gitlab/gitaly_client_spec.rb b/spec/lib/gitlab/gitaly_client_spec.rb index 55fcf91fb6e..08ee0dff6b2 100644 --- a/spec/lib/gitlab/gitaly_client_spec.rb +++ b/spec/lib/gitlab/gitaly_client_spec.rb @@ -1,14 +1,19 @@ require 'spec_helper' describe Gitlab::GitalyClient, lib: true do - describe '.new_channel' do + describe '.stub' do + before { described_class.clear_stubs! } + context 'when passed a UNIX socket address' do - it 'passes the address as-is to GRPC::Core::Channel initializer' do + it 'passes the address as-is to GRPC' do address = 'unix:/tmp/gitaly.sock' + allow(Gitlab.config.repositories).to receive(:storages).and_return({ + 'default' => { 'gitaly_address' => address } + }) - expect(GRPC::Core::Channel).to receive(:new).with(address, any_args) + expect(Gitaly::Commit::Stub).to receive(:new).with(address, any_args) - described_class.new_channel(address) + described_class.stub(:commit, 'default') end end @@ -17,9 +22,13 @@ describe Gitlab::GitalyClient, lib: true do address = 'localhost:9876' prefixed_address = "tcp://#{address}" - expect(GRPC::Core::Channel).to receive(:new).with(address, any_args) + allow(Gitlab.config.repositories).to receive(:storages).and_return({ + 'default' => { 'gitaly_address' => prefixed_address } + }) + + expect(Gitaly::Commit::Stub).to receive(:new).with(address, any_args) - described_class.new_channel(prefixed_address) + described_class.stub(:commit, 'default') end end end diff --git a/spec/lib/gitlab/highlight_spec.rb b/spec/lib/gitlab/highlight_spec.rb index e49799ad105..e57b3053871 100644 --- a/spec/lib/gitlab/highlight_spec.rb +++ b/spec/lib/gitlab/highlight_spec.rb @@ -57,4 +57,15 @@ describe Gitlab::Highlight, lib: true do end end end + + describe '#highlight' do + subject { described_class.highlight(file_name, file_content, nowrap: false) } + + it 'links dependencies via DependencyLinker' do + expect(Gitlab::DependencyLinker).to receive(:link). + with('file.name', 'Contents', anything).and_call_original + + described_class.highlight('file.name', 'Contents') + end + end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 688e731bf15..34f617e23a5 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -85,6 +85,7 @@ merge_requests: - merge_requests_closing_issues - metrics - timelogs +- head_pipeline merge_request_diff: - merge_request pipelines: @@ -102,6 +103,7 @@ pipelines: - manual_actions - artifacts - pipeline_schedule +- merge_requests statuses: - project - pipeline diff --git a/spec/lib/gitlab/import_export/relation_factory_spec.rb b/spec/lib/gitlab/import_export/relation_factory_spec.rb index 06cd8ab87ed..744fed44925 100644 --- a/spec/lib/gitlab/import_export/relation_factory_spec.rb +++ b/spec/lib/gitlab/import_export/relation_factory_spec.rb @@ -95,7 +95,7 @@ describe Gitlab::ImportExport::RelationFactory, lib: true do 'random_id' => 99, 'milestone_id' => 99, 'project_id' => 99, - 'user_id' => 99, + 'user_id' => 99 } end diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 3af2a172e6d..2b95f76e045 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -158,6 +158,7 @@ MergeRequest: - time_estimate - last_edited_at - last_edited_by_id +- head_pipeline_id MergeRequestDiff: - id - state @@ -230,6 +231,7 @@ CommitStatus: - lock_version - coverage_regex - auto_canceled_by_id +- retried Ci::Variable: - id - project_id @@ -315,6 +317,7 @@ ProjectHook: - token - group_id - confidential_issues_events +- repository_update_events ProtectedBranch: - id - project_id diff --git a/spec/lib/gitlab/other_markup_spec.rb b/spec/lib/gitlab/other_markup_spec.rb index d6d53e8586c..c0f5fa9dc1f 100644 --- a/spec/lib/gitlab/other_markup_spec.rb +++ b/spec/lib/gitlab/other_markup_spec.rb @@ -13,7 +13,7 @@ describe Gitlab::OtherMarkup, lib: true do } links.each do |name, data| it "does not convert dangerous #{name} into HTML" do - expect(render(data[:file], data[:input])).to eq(data[:output]) + expect(render(data[:file], data[:input], context)).to eq(data[:output]) end end end diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index a7c8e7f1f57..1b8690ba613 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -22,11 +22,40 @@ describe Gitlab::ProjectSearchResults, lib: true do end describe 'blob search' do - let(:project) { create(:project, :repository) } - let(:results) { described_class.new(user, project, 'files').objects('blobs') } + let(:project) { create(:project, :public, :repository) } + + subject(:results) { described_class.new(user, project, 'files').objects('blobs') } + + context 'when repository is disabled' do + let(:project) { create(:project, :public, :repository, :repository_disabled) } + + it 'hides blobs from members' do + project.add_reporter(user) + + is_expected.to be_empty + end + + it 'hides blobs from non-members' do + is_expected.to be_empty + end + end + + context 'when repository is internal' do + let(:project) { create(:project, :public, :repository, :repository_private) } + + it 'finds blobs for members' do + project.add_reporter(user) + + is_expected.not_to be_empty + end + + it 'hides blobs from non-members' do + is_expected.to be_empty + end + end it 'finds by name' do - expect(results).to include(["files/images/wm.svg", nil]) + expect(results.map(&:first)).to include('files/images/wm.svg') end it 'finds by content' do @@ -70,6 +99,46 @@ describe Gitlab::ProjectSearchResults, lib: true do end end + describe 'wiki search' do + let(:project) { create(:project, :public) } + let(:wiki) { build(:project_wiki, project: project) } + let!(:wiki_page) { wiki.create_page('Title', 'Content') } + + subject(:results) { described_class.new(user, project, 'Content').objects('wiki_blobs') } + + context 'when wiki is disabled' do + let(:project) { create(:project, :public, :wiki_disabled) } + + it 'hides wiki blobs from members' do + project.add_reporter(user) + + is_expected.to be_empty + end + + it 'hides wiki blobs from non-members' do + is_expected.to be_empty + end + end + + context 'when wiki is internal' do + let(:project) { create(:project, :public, :wiki_private) } + + it 'finds wiki blobs for members' do + project.add_reporter(user) + + is_expected.not_to be_empty + end + + it 'hides wiki blobs from non-members' do + is_expected.to be_empty + end + end + + it 'finds by content' do + expect(results).to include("master:Title.md:1:Content\n") + end + end + it 'does not list issues on private projects' do issue = create(:issue, project: project) @@ -79,7 +148,6 @@ describe Gitlab::ProjectSearchResults, lib: true do end describe 'confidential issues' do - let(:project) { create(:empty_project) } let(:query) { 'issue' } let(:author) { create(:user) } let(:assignee) { create(:user) } @@ -277,6 +345,7 @@ describe Gitlab::ProjectSearchResults, lib: true do context 'by commit hash' do let(:project) { create(:project, :public, :repository) } let(:commit) { project.repository.commit('0b4bc9a') } + commit_hashes = { short: '0b4bc9a', full: '0b4bc9a49b562e85de7cc9e834518ea6828729b9' } commit_hashes.each do |type, commit_hash| diff --git a/spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb b/spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb new file mode 100644 index 00000000000..d957dd932c4 --- /dev/null +++ b/spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe Gitlab::Prometheus::Queries::DeploymentQuery, lib: true do + let(:environment) { create(:environment, slug: 'environment-slug') } + let(:deployment) { create(:deployment, environment: environment) } + + let(:client) { double('prometheus_client') } + subject { described_class.new(client) } + + around do |example| + time_without_subsecond_values = Time.local(2008, 9, 1, 12, 0, 0) + Timecop.freeze(time_without_subsecond_values) { example.run } + end + + it 'sends appropriate queries to prometheus' do + start_time = (deployment.created_at - 30.minutes).to_f + stop_time = (deployment.created_at + 30.minutes).to_f + created_at = deployment.created_at.to_f + + expect(client).to receive(:query_range).with('avg(container_memory_usage_bytes{container_name!="POD",environment="environment-slug"}) / 2^20', + start: start_time, stop: stop_time) + expect(client).to receive(:query).with('avg(avg_over_time(container_memory_usage_bytes{container_name!="POD",environment="environment-slug"}[30m]))', + time: created_at) + expect(client).to receive(:query).with('avg(avg_over_time(container_memory_usage_bytes{container_name!="POD",environment="environment-slug"}[30m]))', + time: stop_time) + + expect(client).to receive(:query_range).with('avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="environment-slug"}[2m])) * 100', + start: start_time, stop: stop_time) + expect(client).to receive(:query).with('avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="environment-slug"}[30m])) * 100', + time: created_at) + expect(client).to receive(:query).with('avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="environment-slug"}[30m])) * 100', + time: stop_time) + + expect(subject.query(deployment.id)).to eq(memory_values: nil, memory_before: nil, memory_after: nil, + cpu_values: nil, cpu_before: nil, cpu_after: nil) + end +end diff --git a/spec/lib/gitlab/prometheus_spec.rb b/spec/lib/gitlab/prometheus_client_spec.rb index fc453a2704b..2d8bd2f6b97 100644 --- a/spec/lib/gitlab/prometheus_spec.rb +++ b/spec/lib/gitlab/prometheus_client_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Prometheus, lib: true do +describe Gitlab::PrometheusClient, lib: true do include PrometheusHelpers subject { described_class.new(api_url: 'https://prometheus.example.com') } @@ -81,7 +81,11 @@ describe Gitlab::Prometheus, lib: true do describe '#query' do let(:prometheus_query) { prometheus_cpu_query('env-slug') } - let(:query_url) { prometheus_query_url(prometheus_query) } + let(:query_url) { prometheus_query_with_time_url(prometheus_query, Time.now.utc) } + + around do |example| + Timecop.freeze { example.run } + end context 'when request returns vector results' do it 'returns data from the API call' do @@ -123,6 +127,20 @@ describe Gitlab::Prometheus, lib: true do Timecop.freeze { example.run } end + context 'when non utc time is passed' do + let(:time_stop) { Time.now.in_time_zone("Warsaw") } + let(:time_start) { time_stop - 8.hours } + + let(:query_url) { prometheus_query_range_url(prometheus_query, start: time_start.utc.to_f, stop: time_stop.utc.to_f) } + + it 'passed dates are properly converted to utc' do + req_stub = stub_prometheus_request(query_url, body: prometheus_values_body('vector')) + + subject.query_range(prometheus_query, start: time_start, stop: time_stop) + expect(req_stub).to have_been_requested + end + end + context 'when a start time is passed' do let(:query_url) { prometheus_query_range_url(prometheus_query, start: 2.hours.ago) } diff --git a/spec/lib/gitlab/repo_path_spec.rb b/spec/lib/gitlab/repo_path_spec.rb index f94c9c2e315..f9025397107 100644 --- a/spec/lib/gitlab/repo_path_spec.rb +++ b/spec/lib/gitlab/repo_path_spec.rb @@ -29,7 +29,7 @@ describe ::Gitlab::RepoPath do before do allow(Gitlab.config.repositories).to receive(:storages).and_return({ 'storage1' => { 'path' => '/foo' }, - 'storage2' => { 'path' => '/bar' }, + 'storage2' => { 'path' => '/bar' } }) end diff --git a/spec/lib/gitlab/string_range_marker_spec.rb b/spec/lib/gitlab/string_range_marker_spec.rb new file mode 100644 index 00000000000..7c77772b3f6 --- /dev/null +++ b/spec/lib/gitlab/string_range_marker_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe Gitlab::StringRangeMarker, lib: true do + describe '#mark' do + context "when the rich text is html safe" do + let(:raw) { "abc <def>" } + let(:rich) { %{<span class="abc">abc</span><span class="space"> </span><span class="def"><def></span>}.html_safe } + let(:inline_diffs) { [2..5] } + subject do + described_class.new(raw, rich).mark(inline_diffs) do |text, left:, right:| + "LEFT#{text}RIGHT" + end + end + + it 'marks the inline diffs' do + expect(subject).to eq(%{<span class="abc">abLEFTcRIGHT</span><span class="space">LEFT RIGHT</span><span class="def">LEFT<dRIGHTef></span>}) + expect(subject).to be_html_safe + end + end + + context "when the rich text is not html safe" do + let(:raw) { "abc <def>" } + let(:inline_diffs) { [2..5] } + subject do + described_class.new(raw).mark(inline_diffs) do |text, left:, right:| + "LEFT#{text}RIGHT" + end + end + + it 'marks the inline diffs' do + expect(subject).to eq(%{abLEFTc <dRIGHTef>}) + expect(subject).to be_html_safe + end + end + end +end diff --git a/spec/lib/gitlab/string_regex_marker_spec.rb b/spec/lib/gitlab/string_regex_marker_spec.rb new file mode 100644 index 00000000000..2f5cf6c6e3b --- /dev/null +++ b/spec/lib/gitlab/string_regex_marker_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe Gitlab::StringRegexMarker, lib: true do + describe '#mark' do + let(:raw) { %{"name": "AFNetworking"} } + let(:rich) { %{<span class="key">"name"</span><span class="punctuation">: </span><span class="value">"AFNetworking"</span>}.html_safe } + subject do + described_class.new(raw, rich).mark(/"[^"]+":\s*"(?<name>[^"]+)"/, group: :name) do |text, left:, right:| + %{<a href="#">#{text}</a>} + end + end + + it 'marks the inline diffs' do + expect(subject).to eq(%{<span class="key">"name"</span><span class="punctuation">: </span><span class="value">"<a href="#">AFNetworking</a>"</span>}) + expect(subject).to be_html_safe + end + end +end diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index 9046d5c413f..2c46920456b 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -17,6 +17,7 @@ describe Gitlab::UsageData do edition version uuid + hostname )) end diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb index beb1791a429..fdbb55fc874 100644 --- a/spec/lib/gitlab/workhorse_spec.rb +++ b/spec/lib/gitlab/workhorse_spec.rb @@ -202,7 +202,7 @@ describe Gitlab::Workhorse, lib: true do context 'when Gitaly is enabled' do let(:gitaly_params) do { - GitalyAddress: Gitlab::GitalyClient.get_address('default'), + GitalyAddress: Gitlab::GitalyClient.address('default') } end @@ -214,7 +214,7 @@ describe Gitlab::Workhorse, lib: true do repo_param = { Repository: { path: repo_path, storage_name: 'default', - relative_path: project.full_path + '.git', + relative_path: project.full_path + '.git' } } expect(subject).to include(repo_param) diff --git a/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb b/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb new file mode 100644 index 00000000000..bd5f85b901d --- /dev/null +++ b/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20170508170547_add_head_pipeline_for_each_merge_request.rb') + +describe AddHeadPipelineForEachMergeRequest do + let(:migration) { described_class.new } + + let!(:project) { create(:empty_project) } + let!(:forked_project_link) { create(:forked_project_link, forked_from_project: project) } + let!(:other_project) { forked_project_link.forked_to_project } + + let!(:pipeline_1) { create(:ci_pipeline, project: project, ref: "branch_1") } + let!(:pipeline_2) { create(:ci_pipeline, project: other_project, ref: "branch_1") } + let!(:pipeline_3) { create(:ci_pipeline, project: other_project, ref: "branch_1") } + let!(:pipeline_4) { create(:ci_pipeline, project: project, ref: "branch_2") } + + let!(:mr_1) { create(:merge_request, source_project: project, target_project: project, source_branch: "branch_1", target_branch: "target_1") } + let!(:mr_2) { create(:merge_request, source_project: other_project, target_project: project, source_branch: "branch_1", target_branch: "target_2") } + let!(:mr_3) { create(:merge_request, source_project: project, target_project: project, source_branch: "branch_2", target_branch: "master") } + let!(:mr_4) { create(:merge_request, source_project: project, target_project: project, source_branch: "branch_3", target_branch: "master") } + + context "#up" do + context "when source_project and source_branch of pipeline are the same of merge request" do + it "sets head_pipeline_id of given merge requests" do + migration.up + + expect(mr_1.reload.head_pipeline_id).to eq(pipeline_1.id) + expect(mr_2.reload.head_pipeline_id).to eq(pipeline_3.id) + expect(mr_3.reload.head_pipeline_id).to eq(pipeline_4.id) + expect(mr_4.reload.head_pipeline_id).to be_nil + end + end + end +end diff --git a/spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb b/spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb new file mode 100644 index 00000000000..49e750a3f4d --- /dev/null +++ b/spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20170502101023_cleanup_namespaceless_pending_delete_projects.rb') + +describe CleanupNamespacelessPendingDeleteProjects do + before do + # Stub after_save callbacks that will fail when Project has no namespace + allow_any_instance_of(Project).to receive(:ensure_dir_exist).and_return(nil) + allow_any_instance_of(Project).to receive(:update_project_statistics).and_return(nil) + end + + describe '#up' do + it 'only cleans up pending delete projects' do + create(:empty_project) + create(:empty_project, pending_delete: true) + project = build(:empty_project, pending_delete: true, namespace_id: nil) + project.save(validate: false) + + expect(NamespacelessProjectDestroyWorker).to receive(:bulk_perform_async).with([[project.id]]) + + described_class.new.up + end + + it 'does nothing when no pending delete projects without namespace found' do + create(:empty_project) + create(:empty_project, pending_delete: true) + + expect(NamespacelessProjectDestroyWorker).not_to receive(:bulk_perform_async) + + described_class.new.up + end + end +end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index c2c19c62048..fa229542f70 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -89,7 +89,7 @@ describe ApplicationSetting, models: true do storages = { 'custom1' => 'tmp/tests/custom_repositories_1', 'custom2' => 'tmp/tests/custom_repositories_2', - 'custom3' => 'tmp/tests/custom_repositories_3', + 'custom3' => 'tmp/tests/custom_repositories_3' } allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) @@ -211,4 +211,66 @@ describe ApplicationSetting, models: true do expect(setting.domain_blacklist).to contain_exactly('example.com', 'test.com', 'foo.bar') end end + + describe 'usage ping settings' do + context 'when the usage ping is disabled in gitlab.yml' do + before do + allow(Settings.gitlab).to receive(:usage_ping_enabled).and_return(false) + end + + it 'does not allow the usage ping to be configured' do + expect(setting.usage_ping_can_be_configured?).to be_falsey + end + + context 'when the usage ping is disabled in the DB' do + before do + setting.usage_ping_enabled = false + end + + it 'returns false for usage_ping_enabled' do + expect(setting.usage_ping_enabled).to be_falsey + end + end + + context 'when the usage ping is enabled in the DB' do + before do + setting.usage_ping_enabled = true + end + + it 'returns false for usage_ping_enabled' do + expect(setting.usage_ping_enabled).to be_falsey + end + end + end + + context 'when the usage ping is enabled in gitlab.yml' do + before do + allow(Settings.gitlab).to receive(:usage_ping_enabled).and_return(true) + end + + it 'allows the usage ping to be configured' do + expect(setting.usage_ping_can_be_configured?).to be_truthy + end + + context 'when the usage ping is disabled in the DB' do + before do + setting.usage_ping_enabled = false + end + + it 'returns false for usage_ping_enabled' do + expect(setting.usage_ping_enabled).to be_falsey + end + end + + context 'when the usage ping is enabled in the DB' do + before do + setting.usage_ping_enabled = true + end + + it 'returns true for usage_ping_enabled' do + expect(setting.usage_ping_enabled).to be_truthy + end + end + end + end end diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index f84c6b48173..f19e1af65a6 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -271,6 +271,52 @@ describe Blob do end end + describe '#auxiliary_viewer' do + context 'when the blob has an external storage error' do + before do + project.lfs_enabled = false + end + + it 'returns nil' do + blob = fake_blob(path: 'LICENSE', lfs: true) + + expect(blob.auxiliary_viewer).to be_nil + end + end + + context 'when the blob is empty' do + it 'returns nil' do + blob = fake_blob(data: '') + + expect(blob.auxiliary_viewer).to be_nil + end + end + + context 'when the blob is stored externally' do + it 'returns a matching viewer' do + blob = fake_blob(path: 'LICENSE', lfs: true) + + expect(blob.auxiliary_viewer).to be_a(BlobViewer::License) + end + end + + context 'when the blob is binary' do + it 'returns nil' do + blob = fake_blob(path: 'LICENSE', binary: true) + + expect(blob.auxiliary_viewer).to be_nil + end + end + + context 'when the blob is text-based' do + it 'returns a matching text-based viewer' do + blob = fake_blob(path: 'LICENSE') + + expect(blob.auxiliary_viewer).to be_a(BlobViewer::License) + end + end + end + describe '#rendered_as_text?' do context 'when ignoring errors' do context 'when the simple viewer is text-based' do diff --git a/spec/models/blob_viewer/base_spec.rb b/spec/models/blob_viewer/base_spec.rb index 740ad9d275e..a6641970e1b 100644 --- a/spec/models/blob_viewer/base_spec.rb +++ b/spec/models/blob_viewer/base_spec.rb @@ -8,6 +8,7 @@ describe BlobViewer::Base, model: true do let(:viewer_class) do Class.new(described_class) do self.extensions = %w(pdf) + self.binary = true self.max_size = 1.megabyte self.absolute_max_size = 5.megabytes self.client_side = false @@ -18,14 +19,47 @@ describe BlobViewer::Base, model: true do describe '.can_render?' do context 'when the extension is supported' do - let(:blob) { fake_blob(path: 'file.pdf') } + context 'when the binaryness matches' do + let(:blob) { fake_blob(path: 'file.pdf', binary: true) } - it 'returns true' do - expect(viewer_class.can_render?(blob)).to be_truthy + it 'returns true' do + expect(viewer_class.can_render?(blob)).to be_truthy + end + end + + context 'when the binaryness does not match' do + let(:blob) { fake_blob(path: 'file.pdf', binary: false) } + + it 'returns false' do + expect(viewer_class.can_render?(blob)).to be_falsey + end + end + end + + context 'when the file type is supported' do + before do + viewer_class.file_type = :license + viewer_class.binary = false + end + + context 'when the binaryness matches' do + let(:blob) { fake_blob(path: 'LICENSE', binary: false) } + + it 'returns true' do + expect(viewer_class.can_render?(blob)).to be_truthy + end + end + + context 'when the binaryness does not match' do + let(:blob) { fake_blob(path: 'LICENSE', binary: true) } + + it 'returns false' do + expect(viewer_class.can_render?(blob)).to be_falsey + end end end - context 'when the extension is not supported' do + context 'when the extension and file type are not supported' do let(:blob) { fake_blob(path: 'file.txt') } it 'returns false' do @@ -153,34 +187,4 @@ describe BlobViewer::Base, model: true do end end end - - describe '#prepare!' do - context 'when the viewer is server side' do - let(:blob) { fake_blob(path: 'file.md') } - - before do - viewer_class.client_side = false - end - - it 'loads all blob data' do - expect(blob).to receive(:load_all_data!) - - viewer.prepare! - end - end - - context 'when the viewer is client side' do - let(:blob) { fake_blob(path: 'file.md') } - - before do - viewer_class.client_side = true - end - - it "doesn't load all blob data" do - expect(blob).not_to receive(:load_all_data!) - - viewer.prepare! - end - end - end end diff --git a/spec/models/blob_viewer/gitlab_ci_yml_spec.rb b/spec/models/blob_viewer/gitlab_ci_yml_spec.rb new file mode 100644 index 00000000000..0c6c24ece21 --- /dev/null +++ b/spec/models/blob_viewer/gitlab_ci_yml_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +describe BlobViewer::GitlabCiYml, model: true do + include FakeBlobHelpers + + let(:project) { build(:project) } + let(:data) { File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) } + let(:blob) { fake_blob(path: '.gitlab-ci.yml', data: data) } + subject { described_class.new(blob) } + + describe '#validation_message' do + it 'calls prepare! on the viewer' do + expect(subject).to receive(:prepare!) + + subject.validation_message + end + + context 'when the configuration is valid' do + it 'returns nil' do + expect(subject.validation_message).to be_nil + end + end + + context 'when the configuration is invalid' do + let(:data) { 'oof' } + + it 'returns the error message' do + expect(subject.validation_message).to eq('Invalid configuration format') + end + end + end +end diff --git a/spec/models/blob_viewer/license_spec.rb b/spec/models/blob_viewer/license_spec.rb new file mode 100644 index 00000000000..944ddd32b92 --- /dev/null +++ b/spec/models/blob_viewer/license_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +describe BlobViewer::License, model: true do + include FakeBlobHelpers + + let(:project) { create(:project, :repository) } + let(:blob) { fake_blob(path: 'LICENSE') } + subject { described_class.new(blob) } + + describe '#license' do + it 'returns the blob project repository license' do + expect(subject.license).not_to be_nil + expect(subject.license).to eq(project.repository.license) + end + end + + describe '#render_error' do + context 'when there is no license' do + before do + allow(project.repository).to receive(:license).and_return(nil) + end + + it 'returns :unknown_license' do + expect(subject.render_error).to eq(:unknown_license) + end + end + + context 'when there is a license' do + it 'returns nil' do + expect(subject.render_error).to be_nil + end + end + end +end diff --git a/spec/models/blob_viewer/route_map_spec.rb b/spec/models/blob_viewer/route_map_spec.rb new file mode 100644 index 00000000000..4854e0262d9 --- /dev/null +++ b/spec/models/blob_viewer/route_map_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe BlobViewer::RouteMap, model: true do + include FakeBlobHelpers + + let(:project) { build(:project) } + let(:data) do + <<-MAP.strip_heredoc + # Team data + - source: 'data/team.yml' + public: 'team/' + MAP + end + let(:blob) { fake_blob(path: '.gitlab/route-map.yml', data: data) } + subject { described_class.new(blob) } + + describe '#validation_message' do + it 'calls prepare! on the viewer' do + expect(subject).to receive(:prepare!) + + subject.validation_message + end + + context 'when the configuration is valid' do + it 'returns nil' do + expect(subject.validation_message).to be_nil + end + end + + context 'when the configuration is invalid' do + let(:data) { 'oof' } + + it 'returns the error message' do + expect(subject.validation_message).to eq('Route map is not an array') + end + end + end +end diff --git a/spec/models/blob_viewer/server_side_spec.rb b/spec/models/blob_viewer/server_side_spec.rb new file mode 100644 index 00000000000..ddca9b79390 --- /dev/null +++ b/spec/models/blob_viewer/server_side_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe BlobViewer::ServerSide, model: true do + include FakeBlobHelpers + + let(:project) { build(:empty_project) } + + let(:viewer_class) do + Class.new(BlobViewer::Base) do + include BlobViewer::ServerSide + end + end + + subject { viewer_class.new(blob) } + + describe '#prepare!' do + let(:blob) { fake_blob(path: 'file.txt') } + + it 'loads all blob data' do + expect(blob).to receive(:load_all_data!) + + subject.prepare! + end + end +end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 5231ce28c9d..e971b4bc3f9 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -972,7 +972,7 @@ describe Ci::Build, :models do 'fix-1-foo' => 'fix-1-foo', 'a' * 63 => 'a' * 63, 'a' * 64 => 'a' * 63, - 'FOO' => 'foo', + 'FOO' => 'foo' }.each do |ref, slug| it "transforms #{ref} to #{slug}" do build.ref = ref @@ -1144,7 +1144,7 @@ describe Ci::Build, :models do { key: 'CI_PIPELINE_ID', value: pipeline.id.to_s, public: true }, { key: 'CI_REGISTRY_USER', value: 'gitlab-ci-token', public: true }, { key: 'CI_REGISTRY_PASSWORD', value: build.token, public: false }, - { key: 'CI_REPOSITORY_URL', value: build.repo_url, public: false }, + { key: 'CI_REPOSITORY_URL', value: build.repo_url, public: false } ] end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 208c8cb1c3d..157d17fbb68 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -60,8 +60,8 @@ describe Ci::Pipeline, models: true do subject { pipeline.retried } before do - @build1 = FactoryGirl.create :ci_build, pipeline: pipeline, name: 'deploy' - @build2 = FactoryGirl.create :ci_build, pipeline: pipeline, name: 'deploy' + @build1 = create(:ci_build, pipeline: pipeline, name: 'deploy', retried: true) + @build2 = create(:ci_build, pipeline: pipeline, name: 'deploy') end it 'returns old builds' do @@ -70,31 +70,31 @@ describe Ci::Pipeline, models: true do end describe "coverage" do - let(:project) { FactoryGirl.create :empty_project, build_coverage_regex: "/.*/" } - let(:pipeline) { FactoryGirl.create :ci_empty_pipeline, project: project } + let(:project) { create(:empty_project, build_coverage_regex: "/.*/") } + let(:pipeline) { create(:ci_empty_pipeline, project: project) } it "calculates average when there are two builds with coverage" do - FactoryGirl.create :ci_build, name: "rspec", coverage: 30, pipeline: pipeline - FactoryGirl.create :ci_build, name: "rubocop", coverage: 40, pipeline: pipeline + create(:ci_build, name: "rspec", coverage: 30, pipeline: pipeline) + create(:ci_build, name: "rubocop", coverage: 40, pipeline: pipeline) expect(pipeline.coverage).to eq("35.00") end it "calculates average when there are two builds with coverage and one with nil" do - FactoryGirl.create :ci_build, name: "rspec", coverage: 30, pipeline: pipeline - FactoryGirl.create :ci_build, name: "rubocop", coverage: 40, pipeline: pipeline - FactoryGirl.create :ci_build, pipeline: pipeline + create(:ci_build, name: "rspec", coverage: 30, pipeline: pipeline) + create(:ci_build, name: "rubocop", coverage: 40, pipeline: pipeline) + create(:ci_build, pipeline: pipeline) expect(pipeline.coverage).to eq("35.00") end it "calculates average when there are two builds with coverage and one is retried" do - FactoryGirl.create :ci_build, name: "rspec", coverage: 30, pipeline: pipeline - FactoryGirl.create :ci_build, name: "rubocop", coverage: 30, pipeline: pipeline - FactoryGirl.create :ci_build, name: "rubocop", coverage: 40, pipeline: pipeline + create(:ci_build, name: "rspec", coverage: 30, pipeline: pipeline) + create(:ci_build, name: "rubocop", coverage: 30, pipeline: pipeline, retried: true) + create(:ci_build, name: "rubocop", coverage: 40, pipeline: pipeline) expect(pipeline.coverage).to eq("35.00") end it "calculates average when there is one build without coverage" do - FactoryGirl.create :ci_build, pipeline: pipeline + FactoryGirl.create(:ci_build, pipeline: pipeline) expect(pipeline.coverage).to be_nil end end @@ -222,13 +222,15 @@ describe Ci::Pipeline, models: true do %w(deploy running)]) end - context 'when commit status is retried' do + context 'when commit status is retried' do before do create(:commit_status, pipeline: pipeline, stage: 'build', name: 'mac', stage_idx: 0, status: 'success') + + pipeline.process! end it 'ignores the previous state' do @@ -489,6 +491,10 @@ describe Ci::Pipeline, models: true do context 'there are multiple of the same name' do let!(:manual2) { create(:ci_build, :manual, pipeline: pipeline, name: 'deploy') } + before do + manual.update(retried: true) + end + it 'returns latest one' do is_expected.to contain_exactly(manual2) end @@ -1044,8 +1050,8 @@ describe Ci::Pipeline, models: true do let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: 'master', sha: 'a288a022a53a5a944fae87bcec6efc87b7061808') } it "returns merge requests whose `diff_head_sha` matches the pipeline's SHA" do - merge_request = create(:merge_request, source_project: project, source_branch: pipeline.ref) allow_any_instance_of(MergeRequest).to receive(:diff_head_sha) { 'a288a022a53a5a944fae87bcec6efc87b7061808' } + merge_request = create(:merge_request, source_project: project, head_pipeline: pipeline, source_branch: pipeline.ref) expect(pipeline.merge_requests).to eq([merge_request]) end diff --git a/spec/models/ci/stage_spec.rb b/spec/models/ci/stage_spec.rb index 372b662fab2..8f6ab908987 100644 --- a/spec/models/ci/stage_spec.rb +++ b/spec/models/ci/stage_spec.rb @@ -102,6 +102,10 @@ describe Ci::Stage, models: true do context 'and builds are retried' do let!(:new_build) { create_job(:ci_build, status: :success) } + before do + stage_build.update(retried: true) + end + it "returns status of latest build" do is_expected.to eq('success') end diff --git a/spec/models/ci/variable_spec.rb b/spec/models/ci/variable_spec.rb index 048d25869bc..fe8c52d5353 100644 --- a/spec/models/ci/variable_spec.rb +++ b/spec/models/ci/variable_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Ci::Variable, models: true do - subject { Ci::Variable.new } + subject { build(:ci_variable) } let(:secret_value) { 'secret' } diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 852889d4540..72f83d63224 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -388,32 +388,4 @@ eos expect(described_class.valid_hash?('a' * 41)).to be false end end - - describe '#raw_diffs' do - context 'Gitaly commit_raw_diffs feature enabled' do - before do - allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:commit_raw_diffs).and_return(true) - end - - context 'when a truthy deltas_only is not passed to args' do - it 'fetches diffs from Gitaly server' do - expect(Gitlab::GitalyClient::Commit).to receive(:diff_from_parent). - with(commit) - - commit.raw_diffs - end - end - - context 'when a truthy deltas_only is passed to args' do - it 'fetches diffs using Rugged' do - opts = { deltas_only: true } - - expect(Gitlab::GitalyClient::Commit).not_to receive(:diff_from_parent) - expect(commit.raw).to receive(:diffs).with(opts) - - commit.raw_diffs(opts) - end - end - end - end end diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index 0ee85489574..6947affcc1e 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -157,9 +157,9 @@ describe CommitStatus, :models do subject { described_class.latest.order(:id) } let(:statuses) do - [create_status(name: 'aa', ref: 'bb', status: 'running'), - create_status(name: 'cc', ref: 'cc', status: 'pending'), - create_status(name: 'aa', ref: 'cc', status: 'success'), + [create_status(name: 'aa', ref: 'bb', status: 'running', retried: true), + create_status(name: 'cc', ref: 'cc', status: 'pending', retried: true), + create_status(name: 'aa', ref: 'cc', status: 'success', retried: true), create_status(name: 'cc', ref: 'bb', status: 'success'), create_status(name: 'aa', ref: 'bb', status: 'success')] end @@ -169,6 +169,22 @@ describe CommitStatus, :models do end end + describe '.retried' do + subject { described_class.retried.order(:id) } + + let(:statuses) do + [create_status(name: 'aa', ref: 'bb', status: 'running', retried: true), + create_status(name: 'cc', ref: 'cc', status: 'pending', retried: true), + create_status(name: 'aa', ref: 'cc', status: 'success', retried: true), + create_status(name: 'cc', ref: 'bb', status: 'success'), + create_status(name: 'aa', ref: 'bb', status: 'success')] + end + + it 'returns unique statuses' do + is_expected.to contain_exactly(*statuses.values_at(0, 1, 2)) + end + end + describe '.running_or_pending' do subject { described_class.running_or_pending.order(:id) } @@ -181,7 +197,7 @@ describe CommitStatus, :models do end it 'returns statuses that are running or pending' do - is_expected.to eq(statuses.values_at(0, 1)) + is_expected.to contain_exactly(*statuses.values_at(0, 1)) end end diff --git a/spec/models/concerns/mentionable_spec.rb b/spec/models/concerns/mentionable_spec.rb index 2092576e981..e382c7120de 100644 --- a/spec/models/concerns/mentionable_spec.rb +++ b/spec/models/concerns/mentionable_spec.rb @@ -163,3 +163,52 @@ describe Issue, "Mentionable" do end end end + +describe Commit, 'Mentionable' do + let(:project) { create(:project, :public, :repository) } + let(:commit) { project.commit } + + describe '#matches_cross_reference_regex?' do + it "is false when message doesn't reference anything" do + allow(commit.raw).to receive(:message).and_return "WIP: Do something" + + expect(commit.matches_cross_reference_regex?).to be false + end + + it 'is true if issue #number mentioned in title' do + allow(commit.raw).to receive(:message).and_return "#1" + + expect(commit.matches_cross_reference_regex?).to be true + end + + it 'is true if references an MR' do + allow(commit.raw).to receive(:message).and_return "See merge request !12" + + expect(commit.matches_cross_reference_regex?).to be true + end + + it 'is true if references a commit' do + allow(commit.raw).to receive(:message).and_return "a1b2c3d4" + + expect(commit.matches_cross_reference_regex?).to be true + end + + it 'is true if issue referenced by url' do + issue = create(:issue, project: project) + + allow(commit.raw).to receive(:message).and_return Gitlab::UrlBuilder.build(issue) + + expect(commit.matches_cross_reference_regex?).to be true + end + + context 'with external issue tracker' do + let(:project) { create(:jira_project) } + + it 'is true if external issues referenced' do + allow(commit.raw).to receive(:message).and_return 'JIRA-123' + + expect(commit.matches_cross_reference_regex?).to be true + end + end + end +end diff --git a/spec/models/cycle_analytics/test_spec.rb b/spec/models/cycle_analytics/test_spec.rb index c2ba012a0e6..d0b919efcf9 100644 --- a/spec/models/cycle_analytics/test_spec.rb +++ b/spec/models/cycle_analytics/test_spec.rb @@ -14,6 +14,7 @@ describe 'CycleAnalytics#test', feature: true do issue = context.create(:issue, project: context.project) merge_request = context.create_merge_request_closing_issue(issue) pipeline = context.create(:ci_pipeline, ref: merge_request.source_branch, sha: merge_request.diff_head_sha, project: context.project) + merge_request.update(head_pipeline: pipeline) { pipeline: pipeline, issue: issue } end, start_time_conditions: [["pipeline is started", -> (context, data) { data[:pipeline].run! }]], diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index 080ff2f3f43..4bda7d4314a 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -49,6 +49,34 @@ describe Deployment, models: true do end end + describe '#metrics' do + let(:deployment) { create(:deployment) } + + subject { deployment.metrics } + + context 'metrics are disabled' do + it { is_expected.to eq({}) } + end + + context 'metrics are enabled' do + let(:simple_metrics) do + { + success: true, + metrics: {}, + last_update: 42, + deployment_time: 1494408956 + } + end + + before do + allow(deployment.project).to receive_message_chain(:monitoring_service, :deployment_metrics) + .with(any_args).and_return(simple_metrics) + end + + it { is_expected.to eq(simple_metrics) } + end + end + describe '#stop_action' do let(:build) { create(:ci_build) } diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 28e5c3f80f4..12519de8636 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -393,7 +393,7 @@ describe Environment, models: true do it 'returns the metrics from the deployment service' do expect(project.monitoring_service) - .to receive(:metrics).with(environment) + .to receive(:environment_metrics).with(environment) .and_return(:fake_metrics) is_expected.to eq(:fake_metrics) @@ -438,7 +438,7 @@ describe Environment, models: true do "foo**bar" => "foo-bar" + SUFFIX, "*-foo" => "env-foo" + SUFFIX, "staging-12345678-" => "staging-12345678" + SUFFIX, - "staging-12345678-01234567" => "staging-12345678" + SUFFIX, + "staging-12345678-01234567" => "staging-12345678" + SUFFIX }.each do |name, matcher| it "returns a slug matching #{matcher}, given #{name}" do slug = described_class.new(name: name).generate_slug diff --git a/spec/models/global_milestone_spec.rb b/spec/models/global_milestone_spec.rb index 55b87d1c48a..a14efda3eda 100644 --- a/spec/models/global_milestone_spec.rb +++ b/spec/models/global_milestone_spec.rb @@ -137,7 +137,7 @@ describe GlobalMilestone, models: true do [ milestone1_project1, milestone1_project2, - milestone1_project3, + milestone1_project3 ] milestones_relation = Milestone.where(id: milestones.map(&:id)) diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 3d60e52f23f..6ca1eb0374d 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -178,16 +178,20 @@ describe Group, models: true do describe '#avatar_url' do let!(:group) { create(:group, :access_requestable, :with_avatar) } let(:user) { create(:user) } - subject { group.avatar_url } + let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" } + let(:avatar_path) { "/uploads/group/avatar/#{group.id}/dk.png" } context 'when avatar file is uploaded' do - before do - group.add_master(user) - end + before { group.add_master(user) } - let(:avatar_path) { "/uploads/group/avatar/#{group.id}/dk.png" } + it 'shows correct avatar url' do + expect(group.avatar_url).to eq(avatar_path) + expect(group.avatar_url(only_path: false)).to eq([gitlab_host, avatar_path].join) - it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } + allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host) + + expect(group.avatar_url).to eq([gitlab_host, avatar_path].join) + end end end diff --git a/spec/models/hooks/system_hook_spec.rb b/spec/models/hooks/system_hook_spec.rb index 8acec805584..4340170888d 100644 --- a/spec/models/hooks/system_hook_spec.rb +++ b/spec/models/hooks/system_hook_spec.rb @@ -1,6 +1,19 @@ require "spec_helper" describe SystemHook, models: true do + context 'default attributes' do + let(:system_hook) { build(:system_hook) } + + it 'sets defined default parameters' do + attrs = { + push_events: false, + repository_update_events: true, + enable_ssl_verification: true + } + expect(system_hook).to have_attributes(attrs) + end + end + describe "execute" do let(:system_hook) { create(:system_hook) } let(:user) { create(:user) } @@ -105,4 +118,12 @@ describe SystemHook, models: true do ).once end end + + describe '.repository_update_hooks' do + it 'returns hooks for repository update events only' do + hook = create(:system_hook, repository_update_events: true) + create(:system_hook, repository_update_events: false) + expect(SystemHook.repository_update_hooks).to eq([hook]) + end + end end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 725f5c2311f..bb4e70db2e9 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -38,46 +38,6 @@ describe Issue, models: true do end end - describe "before_save" do - describe "#update_cache_counts when an issue is reassigned" do - let(:issue) { create(:issue) } - let(:assignee) { create(:user) } - - context "when previous assignee exists" do - before do - issue.project.team << [assignee, :developer] - issue.assignees << assignee - end - - it "updates cache counts for new assignee" do - user = create(:user) - - expect(user).to receive(:update_cache_counts) - - issue.assignees << user - end - - it "updates cache counts for previous assignee" do - issue.assignees.first - - expect_any_instance_of(User).to receive(:update_cache_counts) - - issue.assignees.destroy_all - end - end - - context "when previous assignee does not exist" do - it "updates cache count for the new assignee" do - issue.assignees = [] - - expect_any_instance_of(User).to receive(:update_cache_counts) - - issue.assignees << assignee - end - end - end - end - describe '#card_attributes' do it 'includes the author name' do allow(subject).to receive(:author).and_return(double(name: 'Robert')) diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 6cf3dd30ead..ce870fcc1d3 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -87,48 +87,6 @@ describe MergeRequest, models: true do end end - describe "before_save" do - describe "#update_cache_counts when a merge request is reassigned" do - let(:project) { create :project } - let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } - let(:assignee) { create :user } - - context "when previous assignee exists" do - before do - project.team << [assignee, :developer] - merge_request.update(assignee: assignee) - end - - it "updates cache counts for new assignee" do - user = create(:user) - - expect(user).to receive(:update_cache_counts) - - merge_request.update(assignee: user) - end - - it "updates cache counts for previous assignee" do - old_assignee = merge_request.assignee - allow(User).to receive(:find_by_id).with(old_assignee.id).and_return(old_assignee) - - expect(old_assignee).to receive(:update_cache_counts) - - merge_request.update(assignee: nil) - end - end - - context "when previous assignee does not exist" do - it "updates cache count for the new assignee" do - merge_request.update(assignee: nil) - - expect_any_instance_of(User).to receive(:update_cache_counts) - - merge_request.update(assignee: assignee) - end - end - end - end - describe '#card_attributes' do it 'includes the author name' do allow(subject).to receive(:author).and_return(double(name: 'Robert')) @@ -760,13 +718,8 @@ describe MergeRequest, models: true do describe '#head_pipeline' do describe 'when the source project exists' do it 'returns the latest pipeline' do - pipeline = double(:ci_pipeline, ref: 'master') - - allow(subject).to receive(:diff_head_sha).and_return('123abc') - - expect(subject.source_project).to receive(:pipeline_for). - with('master', '123abc'). - and_return(pipeline) + pipeline = create(:ci_empty_pipeline, project: subject.source_project, ref: 'master', status: 'running', sha: "123abc") + subject.update(head_pipeline: pipeline) expect(subject.head_pipeline).to eq(pipeline) end @@ -1315,71 +1268,6 @@ describe MergeRequest, models: true do end end - describe '#conflicts_can_be_resolved_in_ui?' do - def create_merge_request(source_branch) - create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start') do |mr| - mr.mark_as_unmergeable - end - end - - it 'returns a falsey value when the MR can be merged without conflicts' do - merge_request = create_merge_request('master') - merge_request.mark_as_mergeable - - expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey - end - - it 'returns a falsey value when the MR is marked as having conflicts, but has none' do - merge_request = create_merge_request('master') - - expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey - end - - it 'returns a falsey value when the MR has a missing ref after a force push' do - merge_request = create_merge_request('conflict-resolvable') - allow(merge_request.conflicts).to receive(:merge_index).and_raise(Rugged::OdbError) - - expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey - end - - it 'returns a falsey value when the MR does not support new diff notes' do - merge_request = create_merge_request('conflict-resolvable') - merge_request.merge_request_diff.update_attributes(start_commit_sha: nil) - - expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey - end - - it 'returns a falsey value when the conflicts contain a large file' do - merge_request = create_merge_request('conflict-too-large') - - expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey - end - - it 'returns a falsey value when the conflicts contain a binary file' do - merge_request = create_merge_request('conflict-binary-file') - - expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey - end - - it 'returns a falsey value when the conflicts contain a file edited in one branch and deleted in another' do - merge_request = create_merge_request('conflict-missing-side') - - expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey - end - - it 'returns a truthy value when the conflicts are resolvable in the UI' do - merge_request = create_merge_request('conflict-resolvable') - - expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_truthy - end - - it 'returns a truthy value when the conflicts have to be resolved in an editor' do - merge_request = create_merge_request('conflict-contains-conflict-markers') - - expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_truthy - end - end - describe "#source_project_missing?" do let(:project) { create(:empty_project) } let(:fork_project) { create(:empty_project, forked_from_project: project) } @@ -1504,11 +1392,15 @@ describe MergeRequest, models: true do describe '#mergeable_with_slash_command?' do def create_pipeline(status) - create(:ci_pipeline_with_one_job, + pipeline = create(:ci_pipeline_with_one_job, project: project, ref: merge_request.source_branch, sha: merge_request.diff_head_sha, status: status) + + merge_request.update(head_pipeline: pipeline) + + pipeline end let(:project) { create(:project, :public, :repository, only_allow_merge_if_pipeline_succeeds: true) } diff --git a/spec/models/project_authorization_spec.rb b/spec/models/project_authorization_spec.rb index 33ef67f97a7..cd0a4a94809 100644 --- a/spec/models/project_authorization_spec.rb +++ b/spec/models/project_authorization_spec.rb @@ -16,7 +16,7 @@ describe ProjectAuthorization do it 'inserts rows in batches' do described_class.insert_authorizations([ [user.id, project1.id, Gitlab::Access::MASTER], - [user.id, project2.id, Gitlab::Access::MASTER], + [user.id, project2.id, Gitlab::Access::MASTER] ], 1) expect(user.project_authorizations.count).to eq(2) diff --git a/spec/models/project_services/asana_service_spec.rb b/spec/models/project_services/asana_service_spec.rb index 48aef3a93f2..95c35162d96 100644 --- a/spec/models/project_services/asana_service_spec.rb +++ b/spec/models/project_services/asana_service_spec.rb @@ -28,7 +28,7 @@ describe AsanaService, models: true do commits: messages.map do |m| { message: m, - url: 'https://gitlab.com/', + url: 'https://gitlab.com/' } end } diff --git a/spec/models/project_services/chat_message/issue_message_spec.rb b/spec/models/project_services/chat_message/issue_message_spec.rb index 34e2d94b1ed..c159ab00ab1 100644 --- a/spec/models/project_services/chat_message/issue_message_spec.rb +++ b/spec/models/project_services/chat_message/issue_message_spec.rb @@ -48,7 +48,7 @@ describe ChatMessage::IssueMessage, models: true do title: "#100 Issue title", title_link: "http://url.com", text: "issue description", - color: color, + color: color } ]) end diff --git a/spec/models/project_services/chat_message/merge_message_spec.rb b/spec/models/project_services/chat_message/merge_message_spec.rb index fa0a1f4a5b7..61f17031172 100644 --- a/spec/models/project_services/chat_message/merge_message_spec.rb +++ b/spec/models/project_services/chat_message/merge_message_spec.rb @@ -22,7 +22,7 @@ describe ChatMessage::MergeMessage, models: true do state: 'opened', description: 'merge request description', source_branch: 'source_branch', - target_branch: 'target_branch', + target_branch: 'target_branch' } } end diff --git a/spec/models/project_services/chat_message/note_message_spec.rb b/spec/models/project_services/chat_message/note_message_spec.rb index 7cd9c61ee2b..7996536218a 100644 --- a/spec/models/project_services/chat_message/note_message_spec.rb +++ b/spec/models/project_services/chat_message/note_message_spec.rb @@ -15,7 +15,7 @@ describe ChatMessage::NoteMessage, models: true do project_url: 'http://somewhere.com', repository: { name: 'project_name', - url: 'http://somewhere.com', + url: 'http://somewhere.com' }, object_attributes: { id: 10, diff --git a/spec/models/project_services/chat_message/pipeline_message_spec.rb b/spec/models/project_services/chat_message/pipeline_message_spec.rb index e005be42b0d..7d2599dc703 100644 --- a/spec/models/project_services/chat_message/pipeline_message_spec.rb +++ b/spec/models/project_services/chat_message/pipeline_message_spec.rb @@ -62,7 +62,7 @@ describe ChatMessage::PipelineMessage do def build_message(status_text = status, name = user[:name]) "<http://example.gitlab.com|project_name>:" \ " Pipeline <http://example.gitlab.com/pipelines/123|#123>" \ - " of <http://example.gitlab.com/commits/develop|develop> branch" \ + " of branch `<http://example.gitlab.com/commits/develop|develop>`" \ " by #{name} #{status_text} in 02:00:10" end end @@ -81,7 +81,7 @@ describe ChatMessage::PipelineMessage do expect(subject.pretext).to be_empty expect(subject.attachments).to eq(message) expect(subject.activity).to eq({ - title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of [develop](http://example.gitlab.com/commits/develop) branch by hacker passed', + title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of branch `[develop](http://example.gitlab.com/commits/develop)` by hacker passed', subtitle: 'in [project_name](http://example.gitlab.com)', text: 'in 02:00:10', image: '' @@ -98,7 +98,7 @@ describe ChatMessage::PipelineMessage do expect(subject.pretext).to be_empty expect(subject.attachments).to eq(message) expect(subject.activity).to eq({ - title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of [develop](http://example.gitlab.com/commits/develop) branch by hacker failed', + title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of branch `[develop](http://example.gitlab.com/commits/develop)` by hacker failed', subtitle: 'in [project_name](http://example.gitlab.com)', text: 'in 02:00:10', image: '' @@ -113,7 +113,7 @@ describe ChatMessage::PipelineMessage do expect(subject.pretext).to be_empty expect(subject.attachments).to eq(message) expect(subject.activity).to eq({ - title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of [develop](http://example.gitlab.com/commits/develop) branch by API failed', + title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of branch `[develop](http://example.gitlab.com/commits/develop)` by API failed', subtitle: 'in [project_name](http://example.gitlab.com)', text: 'in 02:00:10', image: '' @@ -125,8 +125,8 @@ describe ChatMessage::PipelineMessage do def build_markdown_message(status_text = status, name = user[:name]) "[project_name](http://example.gitlab.com):" \ " Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ - " of [develop](http://example.gitlab.com/commits/develop)" \ - " branch by #{name} #{status_text} in 02:00:10" + " of branch `[develop](http://example.gitlab.com/commits/develop)`" \ + " by #{name} #{status_text} in 02:00:10" end end end diff --git a/spec/models/project_services/chat_message/push_message_spec.rb b/spec/models/project_services/chat_message/push_message_spec.rb index 63eb078c44e..e38117b75f6 100644 --- a/spec/models/project_services/chat_message/push_message_spec.rb +++ b/spec/models/project_services/chat_message/push_message_spec.rb @@ -21,19 +21,19 @@ describe ChatMessage::PushMessage, models: true do before do args[:commits] = [ { message: 'message1', url: 'http://url1.com', id: 'abcdefghijkl', author: { name: 'author1' } }, - { message: 'message2', url: 'http://url2.com', id: '123456789012', author: { name: 'author2' } }, + { message: 'message2', url: 'http://url2.com', id: '123456789012', author: { name: 'author2' } } ] end context 'without markdown' do it 'returns a message regarding pushes' do expect(subject.pretext).to eq( - 'test.user pushed to branch <http://url.com/commits/master|master> of '\ + 'test.user pushed to branch `<http://url.com/commits/master|master>` of '\ '<http://url.com|project_name> (<http://url.com/compare/before...after|Compare changes>)') expect(subject.attachments).to eq([{ text: "<http://url1.com|abcdefgh>: message1 - author1\n\n"\ "<http://url2.com|12345678>: message2 - author2", - color: color, + color: color }]) end end @@ -45,7 +45,7 @@ describe ChatMessage::PushMessage, models: true do it 'returns a message regarding pushes' do expect(subject.pretext).to eq( - 'test.user pushed to branch [master](http://url.com/commits/master) of [project_name](http://url.com) ([Compare changes](http://url.com/compare/before...after))') + 'test.user pushed to branch `[master](http://url.com/commits/master)` of [project_name](http://url.com) ([Compare changes](http://url.com/compare/before...after))') expect(subject.attachments).to eq( "[abcdefgh](http://url1.com): message1 - author1\n\n[12345678](http://url2.com): message2 - author2") expect(subject.activity).to eq({ @@ -74,7 +74,7 @@ describe ChatMessage::PushMessage, models: true do context 'without markdown' do it 'returns a message regarding pushes' do expect(subject.pretext).to eq('test.user pushed new tag ' \ - '<http://url.com/commits/new_tag|new_tag> to ' \ + '`<http://url.com/commits/new_tag|new_tag>` to ' \ '<http://url.com|project_name>') expect(subject.attachments).to be_empty end @@ -87,7 +87,7 @@ describe ChatMessage::PushMessage, models: true do it 'returns a message regarding pushes' do expect(subject.pretext).to eq( - 'test.user pushed new tag [new_tag](http://url.com/commits/new_tag) to [project_name](http://url.com)') + 'test.user pushed new tag `[new_tag](http://url.com/commits/new_tag)` to [project_name](http://url.com)') expect(subject.attachments).to be_empty expect(subject.activity).to eq({ title: 'test.user created tag', @@ -107,7 +107,7 @@ describe ChatMessage::PushMessage, models: true do context 'without markdown' do it 'returns a message regarding a new branch' do expect(subject.pretext).to eq( - 'test.user pushed new branch <http://url.com/commits/master|master> to '\ + 'test.user pushed new branch `<http://url.com/commits/master|master>` to '\ '<http://url.com|project_name>') expect(subject.attachments).to be_empty end @@ -120,7 +120,7 @@ describe ChatMessage::PushMessage, models: true do it 'returns a message regarding a new branch' do expect(subject.pretext).to eq( - 'test.user pushed new branch [master](http://url.com/commits/master) to [project_name](http://url.com)') + 'test.user pushed new branch `[master](http://url.com/commits/master)` to [project_name](http://url.com)') expect(subject.attachments).to be_empty expect(subject.activity).to eq({ title: 'test.user created branch', @@ -140,7 +140,7 @@ describe ChatMessage::PushMessage, models: true do context 'without markdown' do it 'returns a message regarding a removed branch' do expect(subject.pretext).to eq( - 'test.user removed branch master from <http://url.com|project_name>') + 'test.user removed branch `master` from <http://url.com|project_name>') expect(subject.attachments).to be_empty end end @@ -152,7 +152,7 @@ describe ChatMessage::PushMessage, models: true do it 'returns a message regarding a removed branch' do expect(subject.pretext).to eq( - 'test.user removed branch master from [project_name](http://url.com)') + 'test.user removed branch `master` from [project_name](http://url.com)') expect(subject.attachments).to be_empty expect(subject.activity).to eq({ title: 'test.user removed branch', diff --git a/spec/models/project_services/chat_message/wiki_page_message_spec.rb b/spec/models/project_services/chat_message/wiki_page_message_spec.rb index 0df7db2abc2..4ca1b8aa7b7 100644 --- a/spec/models/project_services/chat_message/wiki_page_message_spec.rb +++ b/spec/models/project_services/chat_message/wiki_page_message_spec.rb @@ -53,7 +53,7 @@ describe ChatMessage::WikiPageMessage, models: true do expect(subject.attachments).to eq([ { text: "Wiki page description", - color: color, + color: color } ]) end @@ -66,7 +66,7 @@ describe ChatMessage::WikiPageMessage, models: true do expect(subject.attachments).to eq([ { text: "Wiki page description", - color: color, + color: color } ]) end diff --git a/spec/models/project_services/kubernetes_service_spec.rb b/spec/models/project_services/kubernetes_service_spec.rb index e69eb0098dd..c1c2f2a7219 100644 --- a/spec/models/project_services/kubernetes_service_spec.rb +++ b/spec/models/project_services/kubernetes_service_spec.rb @@ -54,7 +54,7 @@ describe KubernetesService, models: true, caching: true do 'a' * 63 => true, 'a' * 64 => false, 'a.b' => false, - 'a*b' => false, + 'a*b' => false }.each do |namespace, validity| it "validates #{namespace} as #{validity ? 'valid' : 'invalid'}" do subject.namespace = namespace @@ -168,7 +168,7 @@ describe KubernetesService, models: true, caching: true do { key: 'KUBE_TOKEN', value: 'token', public: false }, { key: 'KUBE_NAMESPACE', value: 'my-project', public: true }, { key: 'KUBE_CA_PEM', value: 'CA PEM DATA', public: true }, - { key: 'KUBE_CA_PEM_FILE', value: 'CA PEM DATA', public: true, file: true }, + { key: 'KUBE_CA_PEM_FILE', value: 'CA PEM DATA', public: true, file: true } ) end end @@ -179,7 +179,7 @@ describe KubernetesService, models: true, caching: true do { key: 'KUBE_URL', value: 'https://kube.domain.com', public: true }, { key: 'KUBE_TOKEN', value: 'token', public: false }, { key: 'KUBE_CA_PEM', value: 'CA PEM DATA', public: true }, - { key: 'KUBE_CA_PEM_FILE', value: 'CA PEM DATA', public: true, file: true }, + { key: 'KUBE_CA_PEM_FILE', value: 'CA PEM DATA', public: true, file: true } ) end diff --git a/spec/models/project_services/pivotaltracker_service_spec.rb b/spec/models/project_services/pivotaltracker_service_spec.rb index 45b2f1068bf..a76e909d04d 100644 --- a/spec/models/project_services/pivotaltracker_service_spec.rb +++ b/spec/models/project_services/pivotaltracker_service_spec.rb @@ -40,7 +40,7 @@ describe PivotaltrackerService, models: true do name: 'Some User' }, url: 'https://example.com/commit', - message: 'commit message', + message: 'commit message' } ] } diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb index f3126bc1e57..1f9d3c07b51 100644 --- a/spec/models/project_services/prometheus_service_spec.rb +++ b/spec/models/project_services/prometheus_service_spec.rb @@ -6,6 +6,7 @@ describe PrometheusService, models: true, caching: true do let(:project) { create(:prometheus_project) } let(:service) { project.prometheus_service } + let(:environment_query) { Gitlab::Prometheus::Queries::EnvironmentQuery } describe "Associations" do it { is_expected.to belong_to :project } @@ -45,17 +46,18 @@ describe PrometheusService, models: true, caching: true do end end - describe '#metrics' do + describe '#environment_metrics' do let(:environment) { build_stubbed(:environment, slug: 'env-slug') } - subject { service.metrics(environment) } around do |example| Timecop.freeze { example.run } end context 'with valid data' do + subject { service.environment_metrics(environment) } + before do - stub_reactive_cache(service, prometheus_data, 'env-slug') + stub_reactive_cache(service, prometheus_data, environment_query, environment.id) end it 'returns reactive data' do @@ -64,15 +66,36 @@ describe PrometheusService, models: true, caching: true do end end + describe '#deployment_metrics' do + let(:deployment) { build_stubbed(:deployment)} + let(:deployment_query) { Gitlab::Prometheus::Queries::DeploymentQuery } + + around do |example| + Timecop.freeze { example.run } + end + + context 'with valid data' do + subject { service.deployment_metrics(deployment) } + + before do + stub_reactive_cache(service, prometheus_data, deployment_query, deployment.id) + end + + it 'returns reactive data' do + is_expected.to eq(prometheus_data.merge(deployment_time: deployment.created_at.to_i)) + end + end + end + describe '#calculate_reactive_cache' do - let(:environment) { build_stubbed(:environment, slug: 'env-slug') } + let(:environment) { create(:environment, slug: 'env-slug') } around do |example| Timecop.freeze { example.run } end subject do - service.calculate_reactive_cache(environment.slug) + service.calculate_reactive_cache(environment_query.to_s, environment.id) end context 'when service is inactive' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 429b3dd83af..f2b4e9070b4 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -813,8 +813,16 @@ describe Project, models: true do context 'when avatar file is uploaded' do let(:project) { create(:empty_project, :with_avatar) } let(:avatar_path) { "/uploads/project/avatar/#{project.id}/dk.png" } + let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" } - it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } + it 'shows correct url' do + expect(project.avatar_url).to eq(avatar_path) + expect(project.avatar_url(only_path: false)).to eq([gitlab_host, avatar_path].join) + + allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host) + + expect(project.avatar_url).to eq([gitlab_host, avatar_path].join) + end end context 'When avatar file in git' do @@ -965,7 +973,7 @@ describe Project, models: true do before do storages = { 'default' => { 'path' => 'tmp/tests/repositories' }, - 'picked' => { 'path' => 'tmp/tests/repositories' }, + 'picked' => { 'path' => 'tmp/tests/repositories' } } allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) end diff --git a/spec/models/project_statistics_spec.rb b/spec/models/project_statistics_spec.rb index ff29f6f66ba..c5ffbda9821 100644 --- a/spec/models/project_statistics_spec.rb +++ b/spec/models/project_statistics_spec.rb @@ -35,7 +35,7 @@ describe ProjectStatistics, models: true do commit_count: 8.exabytes - 1, repository_size: 2.exabytes, lfs_objects_size: 2.exabytes, - build_artifacts_size: 4.exabytes - 1, + build_artifacts_size: 4.exabytes - 1 ) statistics.reload @@ -149,7 +149,7 @@ describe ProjectStatistics, models: true do it "sums all storage counters" do statistics.update!( repository_size: 2, - lfs_objects_size: 3, + lfs_objects_size: 3 ) statistics.reload diff --git a/spec/models/protected_branch/merge_access_level_spec.rb b/spec/models/protected_branch/merge_access_level_spec.rb new file mode 100644 index 00000000000..1e7242e9fa8 --- /dev/null +++ b/spec/models/protected_branch/merge_access_level_spec.rb @@ -0,0 +1,5 @@ +require 'spec_helper' + +describe ProtectedBranch::MergeAccessLevel, :models do + it { is_expected.to validate_inclusion_of(:access_level).in_array([Gitlab::Access::MASTER, Gitlab::Access::DEVELOPER, Gitlab::Access::NO_ACCESS]) } +end diff --git a/spec/models/protected_branch/push_access_level_spec.rb b/spec/models/protected_branch/push_access_level_spec.rb new file mode 100644 index 00000000000..de68351198c --- /dev/null +++ b/spec/models/protected_branch/push_access_level_spec.rb @@ -0,0 +1,5 @@ +require 'spec_helper' + +describe ProtectedBranch::PushAccessLevel, :models do + it { is_expected.to validate_inclusion_of(:access_level).in_array([Gitlab::Access::MASTER, Gitlab::Access::DEVELOPER, Gitlab::Access::NO_ACCESS]) } +end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index dd6514b3b50..61b748429d7 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Repository, models: true do include RepoHelpers - TestBlob = Struct.new(:name) + TestBlob = Struct.new(:path) let(:project) { create(:project, :repository) } let(:repository) { project.repository } @@ -565,31 +565,31 @@ describe Repository, models: true do it 'accepts changelog' do expect(repository.tree).to receive(:blobs).and_return([TestBlob.new('changelog')]) - expect(repository.changelog.name).to eq('changelog') + expect(repository.changelog.path).to eq('changelog') end it 'accepts news instead of changelog' do expect(repository.tree).to receive(:blobs).and_return([TestBlob.new('news')]) - expect(repository.changelog.name).to eq('news') + expect(repository.changelog.path).to eq('news') end it 'accepts history instead of changelog' do expect(repository.tree).to receive(:blobs).and_return([TestBlob.new('history')]) - expect(repository.changelog.name).to eq('history') + expect(repository.changelog.path).to eq('history') end it 'accepts changes instead of changelog' do expect(repository.tree).to receive(:blobs).and_return([TestBlob.new('changes')]) - expect(repository.changelog.name).to eq('changes') + expect(repository.changelog.path).to eq('changes') end it 'is case-insensitive' do expect(repository.tree).to receive(:blobs).and_return([TestBlob.new('CHANGELOG')]) - expect(repository.changelog.name).to eq('CHANGELOG') + expect(repository.changelog.path).to eq('CHANGELOG') end end @@ -624,7 +624,7 @@ describe Repository, models: true do repository.create_file(user, 'LICENSE', 'Copyright!', message: 'Add LICENSE', branch_name: 'master') - expect(repository.license_blob.name).to eq('LICENSE') + expect(repository.license_blob.path).to eq('LICENSE') end %w[LICENSE LICENCE LiCensE LICENSE.md LICENSE.foo COPYING COPYING.md].each do |filename| @@ -654,7 +654,7 @@ describe Repository, models: true do expect(repository.license_key).to be_nil end - it 'detects license file with no recognizable open-source license content' do + it 'returns nil when the content is not recognizable' do repository.create_file(user, 'LICENSE', 'Copyright!', message: 'Add LICENSE', branch_name: 'master') @@ -670,12 +670,45 @@ describe Repository, models: true do end end + describe '#license' do + before do + repository.delete_file(user, 'LICENSE', + message: 'Remove LICENSE', branch_name: 'master') + end + + it 'returns nil when no license is detected' do + expect(repository.license).to be_nil + end + + it 'returns nil when the repository does not exist' do + expect(repository).to receive(:exists?).and_return(false) + + expect(repository.license).to be_nil + end + + it 'returns nil when the content is not recognizable' do + repository.create_file(user, 'LICENSE', 'Copyright!', + message: 'Add LICENSE', branch_name: 'master') + + expect(repository.license).to be_nil + end + + it 'returns the license' do + license = Licensee::License.new('mit') + repository.create_file(user, 'LICENSE', + license.content, + message: 'Add LICENSE', branch_name: 'master') + + expect(repository.license).to eq(license) + end + end + describe "#gitlab_ci_yml", caching: true do it 'returns valid file' do files = [TestBlob.new('file'), TestBlob.new('.gitlab-ci.yml'), TestBlob.new('copying')] expect(repository.tree).to receive(:blobs).and_return(files) - expect(repository.gitlab_ci_yml.name).to eq('.gitlab-ci.yml') + expect(repository.gitlab_ci_yml.path).to eq('.gitlab-ci.yml') end it 'returns nil if not exists' do @@ -1626,15 +1659,25 @@ describe Repository, models: true do describe '#readme', caching: true do context 'with a non-existing repository' do it 'returns nil' do - expect(repository).to receive(:tree).with(:head).and_return(nil) + allow(repository).to receive(:tree).with(:head).and_return(nil) expect(repository.readme).to be_nil end end context 'with an existing repository' do - it 'returns the README' do - expect(repository.readme).to be_an_instance_of(Gitlab::Git::Blob) + context 'when no README exists' do + it 'returns nil' do + allow_any_instance_of(Tree).to receive(:readme).and_return(nil) + + expect(repository.readme).to be_nil + end + end + + context 'when a README exists' do + it 'returns the README' do + expect(repository.readme).to be_an_instance_of(ReadmeBlob) + end end end end @@ -1825,11 +1868,12 @@ describe Repository, models: true do describe '#refresh_method_caches' do it 'refreshes the caches of the given types' do expect(repository).to receive(:expire_method_caches). - with(%i(rendered_readme license_blob license_key)) + with(%i(rendered_readme license_blob license_key license)) expect(repository).to receive(:rendered_readme) expect(repository).to receive(:license_blob) expect(repository).to receive(:license_key) + expect(repository).to receive(:license) repository.refresh_method_caches(%i(readme license)) end diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb index 75b1fc7e216..1e5c96fe593 100644 --- a/spec/models/snippet_spec.rb +++ b/spec/models/snippet_spec.rb @@ -131,46 +131,6 @@ describe Snippet, models: true do end end - describe '.accessible_to' do - let(:author) { create(:author) } - let(:project) { create(:empty_project) } - - let!(:public_snippet) { create(:snippet, :public) } - let!(:internal_snippet) { create(:snippet, :internal) } - let!(:private_snippet) { create(:snippet, :private, author: author) } - - let!(:project_public_snippet) { create(:snippet, :public, project: project) } - let!(:project_internal_snippet) { create(:snippet, :internal, project: project) } - let!(:project_private_snippet) { create(:snippet, :private, project: project) } - - it 'returns only public snippets when user is blank' do - expect(described_class.accessible_to(nil)).to match_array [public_snippet, project_public_snippet] - end - - it 'returns only public, and internal snippets for regular users' do - user = create(:user) - - expect(described_class.accessible_to(user)).to match_array [public_snippet, internal_snippet, project_public_snippet, project_internal_snippet] - end - - it 'returns public, internal snippets and project private snippets for project members' do - member = create(:user) - project.team << [member, :developer] - - expect(described_class.accessible_to(member)).to match_array [public_snippet, internal_snippet, project_public_snippet, project_internal_snippet, project_private_snippet] - end - - it 'returns private snippets where the user is the author' do - expect(described_class.accessible_to(author)).to match_array [public_snippet, internal_snippet, private_snippet, project_public_snippet, project_internal_snippet] - end - - it 'returns all snippets when for admins' do - admin = create(:admin) - - expect(described_class.accessible_to(admin)).to match_array [public_snippet, internal_snippet, private_snippet, project_public_snippet, project_internal_snippet, project_private_snippet] - end - end - describe '#participants' do let(:project) { create(:empty_project, :public) } let(:snippet) { create(:snippet, content: 'foo', project: project) } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 63e71f5ff2f..f2c059010f4 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -344,6 +344,35 @@ describe User, models: true do end end + describe '#update_tracked_fields!', :redis do + let(:request) { OpenStruct.new(remote_ip: "127.0.0.1") } + let(:user) { create(:user) } + + it 'writes trackable attributes' do + expect do + user.update_tracked_fields!(request) + end.to change { user.reload.current_sign_in_at } + end + + it 'does not write trackable attributes when called a second time within the hour' do + user.update_tracked_fields!(request) + + expect do + user.update_tracked_fields!(request) + end.not_to change { user.reload.current_sign_in_at } + end + + it 'writes trackable attributes for a different user' do + user2 = create(:user) + + user.update_tracked_fields!(request) + + expect do + user2.update_tracked_fields!(request) + end.to change { user2.reload.current_sign_in_at } + end + end + shared_context 'user keys' do let(:user) { create(:user) } let!(:key) { create(:key, user: user) } @@ -647,7 +676,7 @@ describe User, models: true do protocol_and_expectation = { 'http' => false, 'ssh' => true, - '' => true, + '' => true } protocol_and_expectation.each do |protocol, expected| @@ -935,12 +964,19 @@ describe User, models: true do describe '#avatar_url' do let(:user) { create(:user, :with_avatar) } - subject { user.avatar_url } context 'when avatar file is uploaded' do + let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" } let(:avatar_path) { "/uploads/user/avatar/#{user.id}/dk.png" } - it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } + it 'shows correct avatar url' do + expect(user.avatar_url).to eq(avatar_path) + expect(user.avatar_url(only_path: false)).to eq([gitlab_host, avatar_path].join) + + allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host) + + expect(user.avatar_url).to eq([gitlab_host, avatar_path].join) + end end end diff --git a/spec/policies/environment_policy_spec.rb b/spec/policies/environment_policy_spec.rb index 0e15beaa5e8..650432520bb 100644 --- a/spec/policies/environment_policy_spec.rb +++ b/spec/policies/environment_policy_spec.rb @@ -33,7 +33,7 @@ describe EnvironmentPolicy do let(:project) { create(:project, :public) } before do - project.add_master(user) + project.add_developer(user) end context 'when team member has ability to stop environment' do diff --git a/spec/policies/project_snippet_policy_spec.rb b/spec/policies/project_snippet_policy_spec.rb index d0758af57dd..e1771b636b8 100644 --- a/spec/policies/project_snippet_policy_spec.rb +++ b/spec/policies/project_snippet_policy_spec.rb @@ -1,7 +1,9 @@ require 'spec_helper' describe ProjectSnippetPolicy, models: true do - let(:current_user) { create(:user) } + let(:regular_user) { create(:user) } + let(:external_user) { create(:user, :external) } + let(:project) { create(:empty_project) } let(:author_permissions) do [ @@ -10,13 +12,15 @@ describe ProjectSnippetPolicy, models: true do ] end - subject { described_class.abilities(current_user, project_snippet).to_set } + def abilities(user, snippet_visibility) + snippet = create(:project_snippet, snippet_visibility, project: project) - context 'public snippet' do - let(:project_snippet) { create(:project_snippet, :public) } + described_class.abilities(user, snippet).to_set + end + context 'public snippet' do context 'no user' do - let(:current_user) { nil } + subject { abilities(nil, :public) } it do is_expected.to include(:read_project_snippet) @@ -25,6 +29,17 @@ describe ProjectSnippetPolicy, models: true do end context 'regular user' do + subject { abilities(regular_user, :public) } + + it do + is_expected.to include(:read_project_snippet) + is_expected.not_to include(*author_permissions) + end + end + + context 'external user' do + subject { abilities(external_user, :public) } + it do is_expected.to include(:read_project_snippet) is_expected.not_to include(*author_permissions) @@ -33,10 +48,8 @@ describe ProjectSnippetPolicy, models: true do end context 'internal snippet' do - let(:project_snippet) { create(:project_snippet, :internal) } - context 'no user' do - let(:current_user) { nil } + subject { abilities(nil, :internal) } it do is_expected.not_to include(:read_project_snippet) @@ -45,6 +58,28 @@ describe ProjectSnippetPolicy, models: true do end context 'regular user' do + subject { abilities(regular_user, :internal) } + + it do + is_expected.to include(:read_project_snippet) + is_expected.not_to include(*author_permissions) + end + end + + context 'external user' do + subject { abilities(external_user, :internal) } + + it do + is_expected.not_to include(:read_project_snippet) + is_expected.not_to include(*author_permissions) + end + end + + context 'project team member external user' do + subject { abilities(external_user, :internal) } + + before { project.team << [external_user, :developer] } + it do is_expected.to include(:read_project_snippet) is_expected.not_to include(*author_permissions) @@ -53,10 +88,8 @@ describe ProjectSnippetPolicy, models: true do end context 'private snippet' do - let(:project_snippet) { create(:project_snippet, :private) } - context 'no user' do - let(:current_user) { nil } + subject { abilities(nil, :private) } it do is_expected.not_to include(:read_project_snippet) @@ -65,6 +98,8 @@ describe ProjectSnippetPolicy, models: true do end context 'regular user' do + subject { abilities(regular_user, :private) } + it do is_expected.not_to include(:read_project_snippet) is_expected.not_to include(*author_permissions) @@ -72,7 +107,9 @@ describe ProjectSnippetPolicy, models: true do end context 'snippet author' do - let(:project_snippet) { create(:project_snippet, :private, author: current_user) } + let(:snippet) { create(:project_snippet, :private, author: regular_user) } + + subject { described_class.abilities(regular_user, snippet).to_set } it do is_expected.to include(:read_project_snippet) @@ -80,8 +117,21 @@ describe ProjectSnippetPolicy, models: true do end end - context 'project team member' do - before { project_snippet.project.team << [current_user, :developer] } + context 'project team member normal user' do + subject { abilities(regular_user, :private) } + + before { project.team << [regular_user, :developer] } + + it do + is_expected.to include(:read_project_snippet) + is_expected.not_to include(*author_permissions) + end + end + + context 'project team member external user' do + subject { abilities(external_user, :private) } + + before { project.team << [external_user, :developer] } it do is_expected.to include(:read_project_snippet) @@ -90,7 +140,7 @@ describe ProjectSnippetPolicy, models: true do end context 'admin user' do - let(:current_user) { create(:admin) } + subject { abilities(create(:admin), :private) } it do is_expected.to include(:read_project_snippet) diff --git a/spec/presenters/merge_request_presenter_spec.rb b/spec/presenters/merge_request_presenter_spec.rb new file mode 100644 index 00000000000..44720fc4448 --- /dev/null +++ b/spec/presenters/merge_request_presenter_spec.rb @@ -0,0 +1,356 @@ +require 'spec_helper' + +describe MergeRequestPresenter do + let(:resource) { create :merge_request, source_project: project } + let(:project) { create :empty_project } + let(:user) { create(:user) } + + describe '#ci_status' do + subject { described_class.new(resource).ci_status } + + context 'when no head pipeline' do + it 'return status using CiService' do + ci_service = double(MockCiService) + ci_status = double + + allow(resource.source_project) + .to receive(:ci_service) + .and_return(ci_service) + + allow(resource).to receive(:head_pipeline).and_return(nil) + + expect(ci_service).to receive(:commit_status) + .with(resource.diff_head_sha, resource.source_branch) + .and_return(ci_status) + + is_expected.to eq(ci_status) + end + end + + context 'when head pipeline present' do + let(:pipeline) { build_stubbed(:ci_pipeline) } + + before do + allow(resource).to receive(:head_pipeline).and_return(pipeline) + end + + context 'success with warnings' do + before do + allow(pipeline).to receive(:success?) { true } + allow(pipeline).to receive(:has_warnings?) { true } + end + + it 'returns "success_with_warnings"' do + is_expected.to eq('success_with_warnings') + end + end + + context 'pipeline HAS status AND its not success with warnings' do + before do + allow(pipeline).to receive(:success?) { false } + allow(pipeline).to receive(:has_warnings?) { false } + end + + it 'returns pipeline status' do + is_expected.to eq('pending') + end + end + + context 'pipeline has NO status AND its not success with warnings' do + before do + allow(pipeline).to receive(:status) { nil } + allow(pipeline).to receive(:success?) { false } + allow(pipeline).to receive(:has_warnings?) { false } + end + + it 'returns "preparing"' do + is_expected.to eq('preparing') + end + end + end + end + + describe '#conflict_resolution_path' do + let(:project) { create :empty_project } + let(:user) { create :user } + let(:presenter) { described_class.new(resource, current_user: user) } + let(:path) { presenter.conflict_resolution_path } + + context 'when MR cannot be resolved in UI' do + it 'does not return conflict resolution path' do + allow(presenter).to receive_message_chain(:conflicts, :can_be_resolved_in_ui?) { false } + + expect(path).to be_nil + end + end + + context 'when conflicts cannot be resolved by user' do + it 'does not return conflict resolution path' do + allow(presenter).to receive_message_chain(:conflicts, :can_be_resolved_in_ui?) { true } + allow(presenter).to receive_message_chain(:conflicts, :can_be_resolved_by?).with(user) { false } + + expect(path).to be_nil + end + end + + context 'when able to access conflict resolution UI' do + it 'does return conflict resolution path' do + allow(presenter).to receive_message_chain(:conflicts, :can_be_resolved_in_ui?) { true } + allow(presenter).to receive_message_chain(:conflicts, :can_be_resolved_by?).with(user) { true } + + expect(path) + .to eq("/#{project.full_path}/merge_requests/#{resource.iid}/conflicts") + end + end + end + + context 'issues links' do + let(:project) { create(:project, :private, creator: user, namespace: user.namespace) } + let(:issue_a) { create(:issue, project: project) } + let(:issue_b) { create(:issue, project: project) } + + let(:resource) do + create(:merge_request, + source_project: project, target_project: project, + description: "Fixes #{issue_a.to_reference} Related #{issue_b.to_reference}") + end + + before do + project.team << [user, :developer] + + allow(resource.project).to receive(:default_branch) + .and_return(resource.target_branch) + end + + describe '#closing_issues_links' do + subject { described_class.new(resource, current_user: user).closing_issues_links } + + it 'presents closing issues links' do + is_expected.to match("#{project.full_path}/issues/#{issue_a.iid}") + end + + it 'does not present related issues links' do + is_expected.not_to match("#{project.full_path}/issues/#{issue_b.iid}") + end + end + + describe '#mentioned_issues_links' do + subject do + described_class.new(resource, current_user: user) + .mentioned_issues_links + end + + it 'presents related issues links' do + is_expected.to match("#{project.full_path}/issues/#{issue_b.iid}") + end + + it 'does not present closing issues links' do + is_expected.not_to match("#{project.full_path}/issues/#{issue_a.iid}") + end + end + + describe '#assign_to_closing_issues_link' do + subject do + described_class.new(resource, current_user: user) + .assign_to_closing_issues_link + end + + before do + assign_issues_service = double(MergeRequests::AssignIssuesService, assignable_issues: assignable_issues) + allow(MergeRequests::AssignIssuesService).to receive(:new) + .and_return(assign_issues_service) + end + + context 'single closing issue' do + let(:issue) { create(:issue) } + let(:assignable_issues) { [issue] } + + it 'returns correct link with correct text' do + is_expected + .to match("#{project.full_path}/merge_requests/#{resource.iid}/assign_related_issues") + + is_expected + .to match("Assign yourself to this issue") + end + end + + context 'multiple closing issues' do + let(:issues) { create_list(:issue, 2) } + let(:assignable_issues) { issues } + + it 'returns correct link with correct text' do + is_expected + .to match("#{project.full_path}/merge_requests/#{resource.iid}/assign_related_issues") + + is_expected + .to match("Assign yourself to these issues") + end + end + + context 'no closing issue' do + let(:assignable_issues) { [] } + + it 'returns correct link with correct text' do + is_expected.to be_nil + end + end + end + end + + describe '#cancel_merge_when_pipeline_succeeds_path' do + subject do + described_class.new(resource, current_user: user) + .cancel_merge_when_pipeline_succeeds_path + end + + context 'when can cancel mwps' do + it 'returns path' do + allow(resource).to receive(:can_cancel_merge_when_pipeline_succeeds?) + .with(user) + .and_return(true) + + is_expected.to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}/cancel_merge_when_pipeline_succeeds") + end + end + + context 'when cannot cancel mwps' do + it 'returns nil' do + allow(resource).to receive(:can_cancel_merge_when_pipeline_succeeds?) + .with(user) + .and_return(false) + + is_expected.to be_nil + end + end + end + + describe '#merge_path' do + subject do + described_class.new(resource, current_user: user).merge_path + end + + context 'when can be merged by user' do + it 'returns path' do + allow(resource).to receive(:can_be_merged_by?) + .with(user) + .and_return(true) + + is_expected + .to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}/merge") + end + end + + context 'when cannot be merged by user' do + it 'returns nil' do + allow(resource).to receive(:can_be_merged_by?) + .with(user) + .and_return(false) + + is_expected.to be_nil + end + end + end + + describe '#create_issue_to_resolve_discussions_path' do + subject do + described_class.new(resource, current_user: user) + .create_issue_to_resolve_discussions_path + end + + context 'when can create issue and issues enabled' do + it 'returns path' do + allow(project).to receive(:issues_enabled?) { true } + project.team << [user, :master] + + is_expected + .to eq("/#{resource.project.full_path}/issues/new?merge_request_to_resolve_discussions_of=#{resource.iid}") + end + end + + context 'when cannot create issue' do + it 'returns nil' do + allow(project).to receive(:issues_enabled?) { true } + + is_expected.to be_nil + end + end + + context 'when issues disabled' do + it 'returns nil' do + allow(project).to receive(:issues_enabled?) { false } + project.team << [user, :master] + + is_expected.to be_nil + end + end + end + + describe '#remove_wip_path' do + subject do + described_class.new(resource, current_user: user).remove_wip_path + end + + context 'when merge request enabled and has permission' do + it 'has remove_wip_path' do + allow(project).to receive(:merge_requests_enabled?) { true } + project.team << [user, :master] + + is_expected + .to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}/remove_wip") + end + end + + context 'when has no permission' do + it 'returns nil' do + is_expected.to be_nil + end + end + end + + describe '#target_branch_commits_path' do + subject do + described_class.new(resource, current_user: user) + .target_branch_commits_path + end + + context 'when target branch exists' do + it 'returns path' do + allow(resource).to receive(:target_branch_exists?) { true } + + is_expected + .to eq("/#{resource.target_project.full_path}/commits/#{resource.target_branch}") + end + end + + context 'when target branch does not exists' do + it 'returns nil' do + allow(resource).to receive(:target_branch_exists?) { false } + + is_expected.to be_nil + end + end + end + + describe '#source_branch_path' do + subject do + described_class.new(resource, current_user: user).source_branch_path + end + + context 'when source branch exists' do + it 'returns path' do + allow(resource).to receive(:source_branch_exists?) { true } + + is_expected + .to eq("/#{resource.source_project.full_path}/branches/#{resource.source_branch}") + end + end + + context 'when source branch does not exists' do + it 'returns nil' do + allow(resource).to receive(:source_branch_exists?) { false } + + is_expected.to be_nil + end + end + end +end diff --git a/spec/requests/api/commit_statuses_spec.rb b/spec/requests/api/commit_statuses_spec.rb index 1233cdc64c4..1c163cee152 100644 --- a/spec/requests/api/commit_statuses_spec.rb +++ b/spec/requests/api/commit_statuses_spec.rb @@ -26,8 +26,8 @@ describe API::CommitStatuses do create(:commit_status, { pipeline: commit, ref: commit.ref }.merge(opts)) end - let!(:status1) { create_status(master, status: 'running') } - let!(:status2) { create_status(master, name: 'coverage', status: 'pending') } + let!(:status1) { create_status(master, status: 'running', retried: true) } + let!(:status2) { create_status(master, name: 'coverage', status: 'pending', retried: true) } let!(:status3) { create_status(develop, status: 'running', allow_failure: true) } let!(:status4) { create_status(master, name: 'coverage', status: 'success') } let!(:status5) { create_status(develop, name: 'coverage', status: 'success') } diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb index fa28047d49c..deb2cac6869 100644 --- a/spec/requests/api/files_spec.rb +++ b/spec/requests/api/files_spec.rb @@ -329,7 +329,7 @@ describe API::Files do end let(:get_params) do { - ref: 'master', + ref: 'master' } end diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index 3e27a3bee77..90b36374ded 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -73,7 +73,7 @@ describe API::Groups do storage_size: 702, repository_size: 123, lfs_objects_size: 234, - build_artifacts_size: 345, + build_artifacts_size: 345 }.stringify_keys exposed_attributes = attributes.dup exposed_attributes['job_artifacts_size'] = exposed_attributes.delete('build_artifacts_size') @@ -178,7 +178,7 @@ describe API::Groups do expect(json_response['path']).to eq(group1.path) expect(json_response['description']).to eq(group1.description) expect(json_response['visibility']).to eq(Gitlab::VisibilityLevel.string_level(group1.visibility_level)) - expect(json_response['avatar_url']).to eq(group1.avatar_url) + expect(json_response['avatar_url']).to eq(group1.avatar_url(only_path: false)) expect(json_response['web_url']).to eq(group1.web_url) expect(json_response['request_access_enabled']).to eq(group1.request_access_enabled) expect(json_response['full_name']).to eq(group1.full_name) diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index da2b56c040b..79cac721202 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -1124,7 +1124,7 @@ describe API::Issues do end context 'CE restrictions' do - it 'updates an issue with several assignee but only one has been applied' do + it 'updates an issue with several assignees but only one has been applied' do put api("/projects/#{project.id}/issues/#{issue.iid}", user), assignee_ids: [user2.id, guest.id] diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index ab70ce5cd2f..d5c3b5b34ad 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -661,7 +661,7 @@ describe API::Projects do 'name' => user.namespace.name, 'path' => user.namespace.path, 'kind' => user.namespace.kind, - 'full_path' => user.namespace.full_path, + 'full_path' => user.namespace.full_path }) end diff --git a/spec/requests/api/system_hooks_spec.rb b/spec/requests/api/system_hooks_spec.rb index c7b84173570..2eb191d6049 100644 --- a/spec/requests/api/system_hooks_spec.rb +++ b/spec/requests/api/system_hooks_spec.rb @@ -32,8 +32,9 @@ describe API::SystemHooks do expect(response).to include_pagination_headers expect(json_response).to be_an Array expect(json_response.first['url']).to eq(hook.url) - expect(json_response.first['push_events']).to be true + expect(json_response.first['push_events']).to be false expect(json_response.first['tag_push_events']).to be false + expect(json_response.first['repository_update_events']).to be true end end end diff --git a/spec/requests/api/v3/files_spec.rb b/spec/requests/api/v3/files_spec.rb index 5bcbb441979..378ca1720ff 100644 --- a/spec/requests/api/v3/files_spec.rb +++ b/spec/requests/api/v3/files_spec.rb @@ -53,7 +53,7 @@ describe API::V3::Files do let(:params) do { file_path: 'app/models/application.rb', - ref: 'master', + ref: 'master' } end @@ -263,7 +263,7 @@ describe API::V3::Files do let(:get_params) do { file_path: file_path, - ref: 'master', + ref: 'master' } end diff --git a/spec/requests/api/v3/groups_spec.rb b/spec/requests/api/v3/groups_spec.rb index 2862580cc70..bc261b5e07c 100644 --- a/spec/requests/api/v3/groups_spec.rb +++ b/spec/requests/api/v3/groups_spec.rb @@ -69,7 +69,7 @@ describe API::V3::Groups do storage_size: 702, repository_size: 123, lfs_objects_size: 234, - build_artifacts_size: 345, + build_artifacts_size: 345 }.stringify_keys project1.statistics.update!(attributes) @@ -176,7 +176,7 @@ describe API::V3::Groups do expect(json_response['path']).to eq(group1.path) expect(json_response['description']).to eq(group1.description) expect(json_response['visibility_level']).to eq(group1.visibility_level) - expect(json_response['avatar_url']).to eq(group1.avatar_url) + expect(json_response['avatar_url']).to eq(group1.avatar_url(only_path: false)) expect(json_response['web_url']).to eq(group1.web_url) expect(json_response['request_access_enabled']).to eq(group1.request_access_enabled) expect(json_response['full_name']).to eq(group1.full_name) diff --git a/spec/requests/api/v3/projects_spec.rb b/spec/requests/api/v3/projects_spec.rb index e15b90d7a9e..dc7c3d125b1 100644 --- a/spec/requests/api/v3/projects_spec.rb +++ b/spec/requests/api/v3/projects_spec.rb @@ -227,7 +227,7 @@ describe API::V3::Projects do storage_size: 702, repository_size: 123, lfs_objects_size: 234, - build_artifacts_size: 345, + build_artifacts_size: 345 } project4.statistics.update!(attributes) @@ -706,7 +706,7 @@ describe API::V3::Projects do 'name' => user.namespace.name, 'path' => user.namespace.path, 'kind' => user.namespace.kind, - 'full_path' => user.namespace.full_path, + 'full_path' => user.namespace.full_path }) end diff --git a/spec/requests/api/v3/system_hooks_spec.rb b/spec/requests/api/v3/system_hooks_spec.rb index 72c7d14b8ba..ae427541abb 100644 --- a/spec/requests/api/v3/system_hooks_spec.rb +++ b/spec/requests/api/v3/system_hooks_spec.rb @@ -31,8 +31,9 @@ describe API::V3::SystemHooks do expect(response).to have_http_status(200) expect(json_response).to be_an Array expect(json_response.first['url']).to eq(hook.url) - expect(json_response.first['push_events']).to be true + expect(json_response.first['push_events']).to be false expect(json_response.first['tag_push_events']).to be false + expect(json_response.first['repository_update_events']).to be true end end end diff --git a/spec/requests/ci/api/builds_spec.rb b/spec/requests/ci/api/builds_spec.rb index 108f73bb965..286de277ae7 100644 --- a/spec/requests/ci/api/builds_spec.rb +++ b/spec/requests/ci/api/builds_spec.rb @@ -185,7 +185,7 @@ describe Ci::API::Builds do { "key" => "CI_PIPELINE_TRIGGERED", "value" => "true", "public" => true }, { "key" => "DB_NAME", "value" => "postgres", "public" => true }, { "key" => "SECRET_KEY", "value" => "secret_value", "public" => false }, - { "key" => "TRIGGER_KEY_1", "value" => "TRIGGER_VALUE_1", "public" => false }, + { "key" => "TRIGGER_KEY_1", "value" => "TRIGGER_VALUE_1", "public" => false } ) end end diff --git a/spec/requests/lfs_http_spec.rb b/spec/requests/lfs_http_spec.rb index 5d495bc9e7d..0c9b4121adf 100644 --- a/spec/requests/lfs_http_spec.rb +++ b/spec/requests/lfs_http_spec.rb @@ -425,7 +425,7 @@ describe 'Git LFS API and storage' do 'size' => sample_size, 'error' => { 'code' => 404, - 'message' => "Object does not exist on the server or you don't have permissions to access it", + 'message' => "Object does not exist on the server or you don't have permissions to access it" } } ] @@ -456,7 +456,7 @@ describe 'Git LFS API and storage' do 'size' => 1575078, 'error' => { 'code' => 404, - 'message' => "Object does not exist on the server or you don't have permissions to access it", + 'message' => "Object does not exist on the server or you don't have permissions to access it" } } ] @@ -493,7 +493,7 @@ describe 'Git LFS API and storage' do 'size' => 1575078, 'error' => { 'code' => 404, - 'message' => "Object does not exist on the server or you don't have permissions to access it", + 'message' => "Object does not exist on the server or you don't have permissions to access it" } }, { diff --git a/spec/requests/openid_connect_spec.rb b/spec/requests/openid_connect_spec.rb index a4f85c22943..05176c3beaa 100644 --- a/spec/requests/openid_connect_spec.rb +++ b/spec/requests/openid_connect_spec.rb @@ -61,7 +61,7 @@ describe 'OpenID Connect requests' do email: private_email.email, public_email: public_email.email, website_url: 'https://example.com', - avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png"), + avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png") ) end @@ -79,7 +79,7 @@ describe 'OpenID Connect requests' do 'email_verified' => true, 'website' => 'https://example.com', 'profile' => 'http://localhost/alice', - 'picture' => "http://localhost/uploads/user/avatar/#{user.id}/dk.png", + 'picture' => "http://localhost/uploads/user/avatar/#{user.id}/dk.png" }) end end @@ -98,7 +98,7 @@ describe 'OpenID Connect requests' do expect(@payload['sub']).to eq hashed_subject end - it 'includes the time of the last authentication' do + it 'includes the time of the last authentication', :redis do expect(@payload['auth_time']).to eq user.current_sign_in_at.to_i end diff --git a/spec/requests/projects/cycle_analytics_events_spec.rb b/spec/requests/projects/cycle_analytics_events_spec.rb index 33940f70b1c..d92daa345b3 100644 --- a/spec/requests/projects/cycle_analytics_events_spec.rb +++ b/spec/requests/projects/cycle_analytics_events_spec.rb @@ -9,8 +9,6 @@ describe 'cycle analytics events', api: true do before do project.team << [user, :developer] - allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue]) - 3.times do |count| Timecop.freeze(Time.now + count.days) do create_cycle @@ -121,9 +119,10 @@ describe 'cycle analytics events', api: true do def create_cycle milestone = create(:milestone, project: project) issue.update(milestone: milestone) - mr = create_merge_request_closing_issue(issue) + mr = create_merge_request_closing_issue(issue, commit_message: "References #{issue.to_reference}") pipeline = create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha) + mr.update(head_pipeline_id: pipeline.id) pipeline.run create(:ci_build, pipeline: pipeline, status: :success, author: user) diff --git a/spec/serializers/analytics_issue_entity_spec.rb b/spec/serializers/analytics_issue_entity_spec.rb index 68086216ba9..75d606d5eb3 100644 --- a/spec/serializers/analytics_issue_entity_spec.rb +++ b/spec/serializers/analytics_issue_entity_spec.rb @@ -9,7 +9,7 @@ describe AnalyticsIssueEntity do iid: "1", id: "1", created_at: "2016-11-12 15:04:02.948604", - author: user, + author: user } end diff --git a/spec/serializers/analytics_issue_serializer_spec.rb b/spec/serializers/analytics_issue_serializer_spec.rb index ba24cf8e481..7c14c198a74 100644 --- a/spec/serializers/analytics_issue_serializer_spec.rb +++ b/spec/serializers/analytics_issue_serializer_spec.rb @@ -16,7 +16,7 @@ describe AnalyticsIssueSerializer do iid: "1", id: "1", created_at: "2016-11-12 15:04:02.948604", - author: user, + author: user } end diff --git a/spec/serializers/build_entity_spec.rb b/spec/serializers/build_entity_spec.rb index 897a28b7305..b5eb84ae43b 100644 --- a/spec/serializers/build_entity_spec.rb +++ b/spec/serializers/build_entity_spec.rb @@ -6,7 +6,7 @@ describe BuildEntity do let(:request) { double('request') } before do - allow(request).to receive(:user).and_return(user) + allow(request).to receive(:current_user).and_return(user) end let(:entity) do diff --git a/spec/serializers/build_serializer_spec.rb b/spec/serializers/build_serializer_spec.rb index 7f1abecfafe..01e2cfed6f8 100644 --- a/spec/serializers/build_serializer_spec.rb +++ b/spec/serializers/build_serializer_spec.rb @@ -4,7 +4,7 @@ describe BuildSerializer do let(:user) { create(:user) } let(:serializer) do - described_class.new(user: user) + described_class.new(current_user: user) end subject { serializer.represent(resource) } diff --git a/spec/serializers/deployment_entity_spec.rb b/spec/serializers/deployment_entity_spec.rb index 69355bcde42..522c92ce295 100644 --- a/spec/serializers/deployment_entity_spec.rb +++ b/spec/serializers/deployment_entity_spec.rb @@ -8,7 +8,7 @@ describe DeploymentEntity do subject { entity.as_json } before do - allow(request).to receive(:user).and_return(user) + allow(request).to receive(:current_user).and_return(user) end it 'exposes internal deployment id' do diff --git a/spec/serializers/environment_serializer_spec.rb b/spec/serializers/environment_serializer_spec.rb index 1909e6385b5..d2ad6c44702 100644 --- a/spec/serializers/environment_serializer_spec.rb +++ b/spec/serializers/environment_serializer_spec.rb @@ -6,7 +6,7 @@ describe EnvironmentSerializer do let(:json) do described_class - .new(user: user, project: project) + .new(current_user: user, project: project) .represent(resource) end diff --git a/spec/serializers/event_entity_spec.rb b/spec/serializers/event_entity_spec.rb new file mode 100644 index 00000000000..bb54597c967 --- /dev/null +++ b/spec/serializers/event_entity_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe EventEntity do + subject { described_class.represent(create(:event)).as_json } + + it 'exposes author' do + expect(subject).to include(:author) + end + + it 'exposes core elements of event' do + expect(subject).to include(:updated_at) + end +end diff --git a/spec/serializers/merge_request_basic_serializer_spec.rb b/spec/serializers/merge_request_basic_serializer_spec.rb new file mode 100644 index 00000000000..4daf5a59d0c --- /dev/null +++ b/spec/serializers/merge_request_basic_serializer_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' + +describe MergeRequestBasicSerializer do + let(:resource) { create(:merge_request) } + let(:user) { create(:user) } + + subject { described_class.new.represent(resource) } + + it 'has important MergeRequest attributes' do + expect(subject).to include(:merge_status) + end +end diff --git a/spec/serializers/merge_request_entity_spec.rb b/spec/serializers/merge_request_entity_spec.rb new file mode 100644 index 00000000000..bb6e83ae4bd --- /dev/null +++ b/spec/serializers/merge_request_entity_spec.rb @@ -0,0 +1,128 @@ +require 'spec_helper' + +describe MergeRequestEntity do + let(:project) { create :empty_project } + let(:resource) { create(:merge_request, source_project: project, target_project: project) } + let(:user) { create(:user) } + + let(:request) { double('request', current_user: user) } + + subject do + described_class.new(resource, request: request).as_json + end + + it 'includes author' do + req = double('request') + + author_payload = UserEntity + .represent(resource.author, request: req) + .as_json + + expect(subject[:author]).to eq(author_payload) + end + + it 'includes pipeline' do + req = double('request', current_user: user) + pipeline = build_stubbed(:ci_pipeline) + allow(resource).to receive(:head_pipeline).and_return(pipeline) + + pipeline_payload = PipelineEntity + .represent(pipeline, request: req) + .as_json + + expect(subject[:pipeline]).to eq(pipeline_payload) + end + + it 'includes issues_links' do + issues_links = subject[:issues_links] + + expect(issues_links).to include(:closing, :mentioned_but_not_closing, + :assign_to_closing) + end + + it 'has important MergeRequest attributes' do + expect(subject).to include(:diff_head_sha, :merge_commit_message, + :has_conflicts, :has_ci, :merge_path, + :conflict_resolution_path, + :cancel_merge_when_pipeline_succeeds_path, + :create_issue_to_resolve_discussions_path, + :source_branch_path, :target_branch_commits_path, + :commits_count) + end + + it 'has email_patches_path' do + expect(subject[:email_patches_path]) + .to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}.patch") + end + + it 'has plain_diff_path' do + expect(subject[:plain_diff_path]) + .to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}.diff") + end + + it 'has merge_commit_message_with_description' do + expect(subject[:merge_commit_message_with_description]) + .to eq(resource.merge_commit_message(include_description: true)) + end + + describe 'diff_head_sha' do + before do + allow(resource).to receive(:diff_head_sha) { 'sha' } + end + + context 'when no diff head commit' do + it 'returns nil' do + allow(resource).to receive(:diff_head_commit) { nil } + + expect(subject[:diff_head_sha]).to be_nil + end + end + + context 'when diff head commit present' do + it 'returns diff head commit short id' do + allow(resource).to receive(:diff_head_commit) { double } + + expect(subject[:diff_head_sha]).to eq('sha') + end + end + end + + it 'includes merge_event' do + create(:event, :merged, author: user, project: resource.project, target: resource) + + expect(subject[:merge_event]).to include(:author, :updated_at) + end + + it 'includes closed_event' do + create(:event, :closed, author: user, project: resource.project, target: resource) + + expect(subject[:closed_event]).to include(:author, :updated_at) + end + + describe 'diverged_commits_count' do + context 'when MR open and its diverging' do + it 'returns diverged commits count' do + allow(resource).to receive_messages(open?: true, diverged_from_target_branch?: true, + diverged_commits_count: 10) + + expect(subject[:diverged_commits_count]).to eq(10) + end + end + + context 'when MR is not open' do + it 'returns 0' do + allow(resource).to receive_messages(open?: false) + + expect(subject[:diverged_commits_count]).to be_zero + end + end + + context 'when MR is not diverging' do + it 'returns 0' do + allow(resource).to receive_messages(open?: true, diverged_from_target_branch?: false) + + expect(subject[:diverged_commits_count]).to be_zero + end + end + end +end diff --git a/spec/serializers/merge_request_serializer_spec.rb b/spec/serializers/merge_request_serializer_spec.rb new file mode 100644 index 00000000000..73fbecc153d --- /dev/null +++ b/spec/serializers/merge_request_serializer_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe MergeRequestSerializer do + let(:user) { build_stubbed(:user) } + let(:merge_request) { build_stubbed(:merge_request) } + + let(:serializer) do + described_class.new(current_user: user) + end + + describe '#represent' do + let(:opts) { { basic: basic } } + subject { serializer.represent(merge_request, basic: basic) } + + context 'when basic param is truthy' do + let(:basic) { true } + + it 'calls super class #represent with correct params' do + expect_any_instance_of(BaseSerializer).to receive(:represent) + .with(merge_request, opts, MergeRequestBasicEntity) + + subject + end + end + + context 'when basic param is falsy' do + let(:basic) { false } + + it 'calls super class #represent with correct params' do + expect_any_instance_of(BaseSerializer).to receive(:represent) + .with(merge_request, opts, MergeRequestEntity) + + subject + end + end + end +end diff --git a/spec/serializers/pipeline_entity_spec.rb b/spec/serializers/pipeline_entity_spec.rb index 93d5a21419d..d2482ac434b 100644 --- a/spec/serializers/pipeline_entity_spec.rb +++ b/spec/serializers/pipeline_entity_spec.rb @@ -5,7 +5,7 @@ describe PipelineEntity do let(:request) { double('request') } before do - allow(request).to receive(:user).and_return(user) + allow(request).to receive(:current_user).and_return(user) end let(:entity) do @@ -19,7 +19,7 @@ describe PipelineEntity do let(:pipeline) { create(:ci_empty_pipeline) } it 'contains required fields' do - expect(subject).to include :id, :user, :path + expect(subject).to include :id, :user, :path, :coverage expect(subject).to include :ref, :commit expect(subject).to include :updated_at, :created_at end diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb index ecde45a6d44..f2426db6d81 100644 --- a/spec/serializers/pipeline_serializer_spec.rb +++ b/spec/serializers/pipeline_serializer_spec.rb @@ -4,7 +4,7 @@ describe PipelineSerializer do let(:user) { create(:user) } let(:serializer) do - described_class.new(user: user) + described_class.new(current_user: user) end subject { serializer.represent(resource) } @@ -44,7 +44,7 @@ describe PipelineSerializer do end let(:serializer) do - described_class.new(user: user) + described_class.new(current_user: user) .with_pagination(request, response) end @@ -113,7 +113,7 @@ describe PipelineSerializer do it "verifies number of queries" do recorded = ActiveRecord::QueryRecorder.new { subject } - expect(recorded.count).to be_within(1).of(50) + expect(recorded.count).to be_within(1).of(58) expect(recorded.cached_count).to eq(0) end diff --git a/spec/serializers/stage_entity_spec.rb b/spec/serializers/stage_entity_spec.rb index 0412b2d7741..64b3217b809 100644 --- a/spec/serializers/stage_entity_spec.rb +++ b/spec/serializers/stage_entity_spec.rb @@ -14,7 +14,7 @@ describe StageEntity do end before do - allow(request).to receive(:user).and_return(user) + allow(request).to receive(:current_user).and_return(user) create(:ci_build, :success, pipeline: pipeline) end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index fa5014cee07..b536103ed65 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -27,12 +27,50 @@ describe Ci::CreatePipelineService, services: true do ) end - it { expect(pipeline).to be_kind_of(Ci::Pipeline) } - it { expect(pipeline).to be_valid } - it { expect(pipeline).to eq(project.pipelines.last) } - it { expect(pipeline).to have_attributes(user: user) } - it { expect(pipeline).to have_attributes(status: 'pending') } - it { expect(pipeline.builds.first).to be_kind_of(Ci::Build) } + it 'creates a pipeline' do + expect(pipeline).to be_kind_of(Ci::Pipeline) + expect(pipeline).to be_valid + expect(pipeline).to eq(project.pipelines.last) + expect(pipeline).to have_attributes(user: user) + expect(pipeline).to have_attributes(status: 'pending') + expect(pipeline.builds.first).to be_kind_of(Ci::Build) + end + + context '#update_merge_requests_head_pipeline' do + it 'updates head pipeline of each merge request' do + merge_request_1 = create(:merge_request, source_branch: 'master', target_branch: "branch_1", source_project: project) + merge_request_2 = create(:merge_request, source_branch: 'master', target_branch: "branch_2", source_project: project) + + head_pipeline = pipeline + + expect(merge_request_1.reload.head_pipeline).to eq(head_pipeline) + expect(merge_request_2.reload.head_pipeline).to eq(head_pipeline) + end + + context 'when there is no pipeline for source branch' do + it "does not update merge request head pipeline" do + merge_request = create(:merge_request, source_branch: 'other_branch', target_branch: "branch_1", source_project: project) + + head_pipeline = pipeline + + expect(merge_request.reload.head_pipeline).not_to eq(head_pipeline) + end + end + + context 'when merge request target project is different from source project' do + let!(:target_project) { create(:empty_project) } + let!(:forked_project_link) { create(:forked_project_link, forked_to_project: project, forked_from_project: target_project) } + + it 'updates head pipeline for merge request' do + merge_request = + create(:merge_request, source_branch: 'master', target_branch: "branch_1", source_project: project, target_project: target_project) + + head_pipeline = pipeline + + expect(merge_request.reload.head_pipeline).to eq(head_pipeline) + end + end + end context 'auto-cancel enabled' do before do diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb index cf773866a6f..fc5de5d069a 100644 --- a/spec/services/ci/process_pipeline_service_spec.rb +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -268,6 +268,24 @@ describe Ci::ProcessPipelineService, '#execute', :services do end end + context 'when there are only manual actions in stages' do + before do + create_build('image', stage_idx: 0, when: 'manual', allow_failure: true) + create_build('build', stage_idx: 1, when: 'manual', allow_failure: true) + create_build('deploy', stage_idx: 2, when: 'manual') + create_build('check', stage_idx: 3) + + process_pipeline + end + + it 'processes all jobs until blocking actions encountered' do + expect(all_builds_statuses).to eq(%w[manual manual manual created]) + expect(all_builds_names).to eq(%w[image build deploy check]) + + expect(pipeline.reload).to be_blocked + end + end + context 'when blocking manual actions are defined' do before do create_build('code:test', stage_idx: 0) @@ -425,6 +443,21 @@ describe Ci::ProcessPipelineService, '#execute', :services do end end + context 'updates a list of retried builds' do + subject { described_class.retried.order(:id) } + + let!(:build_retried) { create_build('build') } + let!(:build) { create_build('build') } + let!(:test) { create_build('test') } + + it 'returns unique statuses' do + process_pipeline + + expect(all_builds.latest).to contain_exactly(build, test) + expect(all_builds.retried).to contain_exactly(build_retried) + end + end + def process_pipeline described_class.new(pipeline.project, user).execute(pipeline) end @@ -441,6 +474,10 @@ describe Ci::ProcessPipelineService, '#execute', :services do builds.pluck(:name) end + def all_builds_names + all_builds.pluck(:name) + end + def builds_statuses builds.pluck(:status) end diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index b2d37657770..7254e6b357a 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -22,7 +22,7 @@ describe Ci::RetryBuildService, :services do %i[type lock_version target_url base_tags commit_id deployments erased_by_id last_deployment project_id runner_id tag_taggings taggings tags trigger_request_id - user_id auto_canceled_by_id].freeze + user_id auto_canceled_by_id retried].freeze shared_examples 'build duplication' do let(:build) do @@ -115,7 +115,7 @@ describe Ci::RetryBuildService, :services do end describe '#reprocess' do - let(:new_build) { service.reprocess(build) } + let(:new_build) { service.reprocess!(build) } context 'when user has ability to execute build' do before do @@ -131,11 +131,16 @@ describe Ci::RetryBuildService, :services do it 'does not enqueue the new build' do expect(new_build).to be_created end + + it 'does mark old build as retried' do + expect(new_build).to be_latest + expect(build.reload).to be_retried + end end context 'when user does not have ability to execute build' do it 'raises an error' do - expect { service.reprocess(build) } + expect { service.reprocess!(build) } .to raise_error Gitlab::Access::AccessDeniedError end end diff --git a/spec/services/ci/retry_pipeline_service_spec.rb b/spec/services/ci/retry_pipeline_service_spec.rb index 40e151545c9..d941d56c0d8 100644 --- a/spec/services/ci/retry_pipeline_service_spec.rb +++ b/spec/services/ci/retry_pipeline_service_spec.rb @@ -13,7 +13,7 @@ describe Ci::RetryPipelineService, '#execute', :services do context 'when there are already retried jobs present' do before do - create_build('rspec', :canceled, 0) + create_build('rspec', :canceled, 0, retried: true) create_build('rspec', :failed, 0) end diff --git a/spec/services/cohorts_service_spec.rb b/spec/services/cohorts_service_spec.rb index 1e99442fdcb..77595d7ba2d 100644 --- a/spec/services/cohorts_service_spec.rb +++ b/spec/services/cohorts_service_spec.rb @@ -89,7 +89,7 @@ describe CohortsService do activity_months: [{ total: 2, percentage: 100 }], total: 2, inactive: 1 - }, + } ] expect(described_class.new.execute).to eq(months_included: 12, diff --git a/spec/services/create_deployment_service_spec.rb b/spec/services/create_deployment_service_spec.rb index a883705bd45..f35d7a33548 100644 --- a/spec/services/create_deployment_service_spec.rb +++ b/spec/services/create_deployment_service_spec.rb @@ -255,7 +255,7 @@ describe CreateDeploymentService, services: true do environment: 'production', ref: 'master', tag: false, - sha: '97de212e80737a608d939f648d959671fb0a0142b', + sha: '97de212e80737a608d939f648d959671fb0a0142b' } end diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index 0477cac6677..ab06f45dbb9 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -584,7 +584,7 @@ describe GitPushService, services: true do commit = double(:commit) diff = double(:diff, new_path: 'README.md') - expect(commit).to receive(:raw_diffs).with(deltas_only: true). + expect(commit).to receive(:raw_deltas). and_return([diff]) service.push_commits = [commit] @@ -622,12 +622,21 @@ describe GitPushService, services: true do it 'only schedules a limited number of commits' do allow(service).to receive(:push_commits). - and_return(Array.new(1000, double(:commit, to_hash: {}))) + and_return(Array.new(1000, double(:commit, to_hash: {}, matches_cross_reference_regex?: true))) expect(ProcessCommitWorker).to receive(:perform_async).exactly(100).times service.process_commit_messages end + + it "skips commits which don't include cross-references" do + allow(service).to receive(:push_commits). + and_return([double(:commit, to_hash: {}, matches_cross_reference_regex?: false)]) + + expect(ProcessCommitWorker).not_to receive(:perform_async) + + service.process_commit_messages + end end def execute_service(project, user, oldrev, newrev, ref) diff --git a/spec/services/issuable/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb index 5b1639ca0d6..6437d00e451 100644 --- a/spec/services/issuable/bulk_update_service_spec.rb +++ b/spec/services/issuable/bulk_update_service_spec.rb @@ -62,7 +62,7 @@ describe Issuable::BulkUpdateService, services: true do expect(result[:count]).to eq(1) end - it 'updates the assignee to the use ID passed' do + it 'updates the assignee to the user ID passed' do assignee = create(:user) project.team << [assignee, :developer] @@ -100,7 +100,7 @@ describe Issuable::BulkUpdateService, services: true do expect(result[:count]).to eq(1) end - it 'updates the assignee to the use ID passed' do + it 'updates the assignee to the user ID passed' do assignee = create(:user) project.team << [assignee, :developer] expect { bulk_update(issue, assignee_ids: [assignee.id]) } @@ -163,7 +163,7 @@ describe Issuable::BulkUpdateService, services: true do { label_ids: labels.map(&:id), add_label_ids: add_labels.map(&:id), - remove_label_ids: remove_labels.map(&:id), + remove_label_ids: remove_labels.map(&:id) } end diff --git a/spec/services/issues/build_service_spec.rb b/spec/services/issues/build_service_spec.rb index 55d635235b0..bed25fe7ccf 100644 --- a/spec/services/issues/build_service_spec.rb +++ b/spec/services/issues/build_service_spec.rb @@ -136,7 +136,7 @@ describe Issues::BuildService, services: true do user, title: 'Issue #1', description: 'Issue description', - milestone_id: milestone.id, + milestone_id: milestone.id ).execute expect(issue.title).to eq('Issue #1') diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb index 51840531711..0a1f41719f7 100644 --- a/spec/services/issues/close_service_spec.rb +++ b/spec/services/issues/close_service_spec.rb @@ -51,8 +51,10 @@ describe Issues::CloseService, services: true do end end - it { expect(issue).to be_valid } - it { expect(issue).to be_closed } + it 'closes the issue' do + expect(issue).to be_valid + expect(issue).to be_closed + end it 'sends email to user2 about assign of new issue' do email = ActionMailer::Base.deliveries.last @@ -96,9 +98,11 @@ describe Issues::CloseService, services: true do described_class.new(project, user).close_issue(issue) end - it { expect(issue).to be_valid } - it { expect(issue).to be_opened } - it { expect(todo.reload).to be_pending } + it 'closes the issue' do + expect(issue).to be_valid + expect(issue).to be_opened + expect(todo.reload).to be_pending + end end end end diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 01edc46496d..dab1a3469f7 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -118,6 +118,22 @@ describe Issues::CreateService, services: true do end end + context 'when assignee is set' do + let(:opts) do + { title: 'Title', + description: 'Description', + assignees: [assignee] } + end + + it 'invalidates open issues counter for assignees when issue is assigned' do + project.team << [assignee, :master] + + described_class.new(project, user, opts).execute + + expect(assignee.assigned_open_issues_count).to eq 1 + end + end + it 'executes issue hooks when issue is not confidential' do opts = { title: 'Title', description: 'Description', confidential: false } diff --git a/spec/services/issues/resolve_discussions_spec.rb b/spec/services/issues/resolve_discussions_spec.rb index c3b4c2176ee..86f218dec12 100644 --- a/spec/services/issues/resolve_discussions_spec.rb +++ b/spec/services/issues/resolve_discussions_spec.rb @@ -77,7 +77,7 @@ describe Issues::ResolveDiscussions, services: true do _second_discussion = Discussion.new([create(:diff_note_on_merge_request, :resolved, noteable: merge_request, project: merge_request.target_project, - line_number: 15, + line_number: 15 )]) service = DummyService.new( project, diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index 1954d8739f6..5184c1d5f19 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -59,6 +59,13 @@ describe Issues::UpdateService, services: true do expect(issue.due_date).to eq Date.tomorrow end + it 'updates open issue counter for assignees when issue is reassigned' do + update_issue(assignee_ids: [user2.id]) + + expect(user3.assigned_open_issues_count).to eq 0 + expect(user2.assigned_open_issues_count).to eq 1 + end + it 'sorts issues as specified by parameters' do issue1 = create(:issue, project: project, assignees: [user3]) issue2 = create(:issue, project: project, assignees: [user3]) diff --git a/spec/services/merge_requests/conflicts/list_service_spec.rb b/spec/services/merge_requests/conflicts/list_service_spec.rb new file mode 100644 index 00000000000..e8a305d6130 --- /dev/null +++ b/spec/services/merge_requests/conflicts/list_service_spec.rb @@ -0,0 +1,73 @@ +require 'spec_helper' + +describe MergeRequests::Conflicts::ListService do + describe '#can_be_resolved_in_ui?' do + def create_merge_request(source_branch) + create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start') do |mr| + mr.mark_as_unmergeable + end + end + + def conflicts_service(merge_request) + described_class.new(merge_request) + end + + it 'returns a falsey value when the MR can be merged without conflicts' do + merge_request = create_merge_request('master') + merge_request.mark_as_mergeable + + expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the MR is marked as having conflicts, but has none' do + merge_request = create_merge_request('master') + + expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the MR has a missing ref after a force push' do + merge_request = create_merge_request('conflict-resolvable') + service = conflicts_service(merge_request) + allow(service.conflicts).to receive(:merge_index).and_raise(Rugged::OdbError) + + expect(service.can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the MR does not support new diff notes' do + merge_request = create_merge_request('conflict-resolvable') + merge_request.merge_request_diff.update_attributes(start_commit_sha: nil) + + expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the conflicts contain a large file' do + merge_request = create_merge_request('conflict-too-large') + + expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the conflicts contain a binary file' do + merge_request = create_merge_request('conflict-binary-file') + + expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the conflicts contain a file edited in one branch and deleted in another' do + merge_request = create_merge_request('conflict-missing-side') + + expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a truthy value when the conflicts are resolvable in the UI' do + merge_request = create_merge_request('conflict-resolvable') + + expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_truthy + end + + it 'returns a truthy value when the conflicts have to be resolved in an editor' do + merge_request = create_merge_request('conflict-contains-conflict-markers') + + expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_truthy + end + end +end diff --git a/spec/services/merge_requests/resolve_service_spec.rb b/spec/services/merge_requests/conflicts/resolve_service_spec.rb index 3afd6b92900..19e8d5cc5f1 100644 --- a/spec/services/merge_requests/resolve_service_spec.rb +++ b/spec/services/merge_requests/conflicts/resolve_service_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe MergeRequests::ResolveService do +describe MergeRequests::Conflicts::ResolveService do let(:user) { create(:user) } let(:project) { create(:project, :repository) } @@ -24,6 +24,8 @@ describe MergeRequests::ResolveService do end describe '#execute' do + let(:service) { described_class.new(merge_request) } + context 'with section params' do let(:params) do { @@ -50,7 +52,7 @@ describe MergeRequests::ResolveService do context 'when the source and target project are the same' do before do - described_class.new(project, user, params).execute(merge_request) + service.execute(user, params) end it 'creates a commit with the message' do @@ -74,15 +76,26 @@ describe MergeRequests::ResolveService do branch_name: 'conflict-start') end - before do - described_class.new(fork_project, user, params).execute(merge_request_from_fork) + def resolve_conflicts + described_class.new(merge_request_from_fork).execute(user, params) + end + + it 'gets conflicts from the source project' do + expect(fork_project.repository.rugged).to receive(:merge_commits).and_call_original + expect(project.repository.rugged).not_to receive(:merge_commits) + + resolve_conflicts end it 'creates a commit with the message' do + resolve_conflicts + expect(merge_request_from_fork.source_branch_head.message).to eq(params[:commit_message]) end it 'creates a commit with the correct parents' do + resolve_conflicts + expect(merge_request_from_fork.source_branch_head.parents.map(&:id)). to eq(['404fa3fc7c2c9b5dacff102f353bdf55b1be2813', target_head]) @@ -115,7 +128,7 @@ describe MergeRequests::ResolveService do end before do - described_class.new(project, user, params).execute(merge_request) + service.execute(user, params) end it 'creates a commit with the message' do @@ -154,15 +167,15 @@ describe MergeRequests::ResolveService do } end - let(:service) { described_class.new(project, user, invalid_params) } - it 'raises a MissingResolution error' do - expect { service.execute(merge_request) }. + expect { service.execute(user, invalid_params) }. to raise_error(Gitlab::Conflict::File::MissingResolution) end end context 'when the content of a file is unchanged' do + let(:list_service) { MergeRequests::Conflicts::ListService.new(merge_request) } + let(:invalid_params) do { files: [ @@ -173,17 +186,15 @@ describe MergeRequests::ResolveService do }, { old_path: 'files/ruby/regex.rb', new_path: 'files/ruby/regex.rb', - content: merge_request.conflicts.file_for_path('files/ruby/regex.rb', 'files/ruby/regex.rb').content + content: list_service.conflicts.file_for_path('files/ruby/regex.rb', 'files/ruby/regex.rb').content } ], commit_message: 'This is a commit message!' } end - let(:service) { described_class.new(project, user, invalid_params) } - it 'raises a MissingResolution error' do - expect { service.execute(merge_request) }. + expect { service.execute(user, invalid_params) }. to raise_error(Gitlab::Conflict::File::MissingResolution) end end @@ -202,11 +213,9 @@ describe MergeRequests::ResolveService do } end - let(:service) { described_class.new(project, user, invalid_params) } - it 'raises a MissingFiles error' do - expect { service.execute(merge_request) }. - to raise_error(MergeRequests::ResolveService::MissingFiles) + expect { service.execute(user, invalid_params) }. + to raise_error(described_class::MissingFiles) end end end diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb index ace82380cc9..b70e9d534a4 100644 --- a/spec/services/merge_requests/create_service_spec.rb +++ b/spec/services/merge_requests/create_service_spec.rb @@ -27,10 +27,12 @@ describe MergeRequests::CreateService, services: true do @merge_request = service.execute end - it { expect(@merge_request).to be_valid } - it { expect(@merge_request.title).to eq('Awesome merge_request') } - it { expect(@merge_request.assignee).to be_nil } - it { expect(@merge_request.merge_params['force_remove_source_branch']).to eq('1') } + it 'creates an MR' do + expect(@merge_request).to be_valid + expect(@merge_request.title).to eq('Awesome merge_request') + expect(@merge_request.assignee).to be_nil + expect(@merge_request.merge_params['force_remove_source_branch']).to eq('1') + end it 'executes hooks with default action' do expect(service).to have_received(:execute_hooks).with(@merge_request) @@ -144,6 +146,26 @@ describe MergeRequests::CreateService, services: true do expect(merge_request.assignee).to eq(assignee) end + context 'when assignee is set' do + let(:opts) do + { + title: 'Title', + description: 'Description', + assignee_id: assignee.id, + source_branch: 'feature', + target_branch: 'master' + } + end + + it 'invalidates open merge request counter for assignees when merge request is assigned' do + project.team << [assignee, :master] + + described_class.new(project, user, opts).execute + + expect(assignee.assigned_open_merge_requests_count).to eq 1 + end + end + context "when issuable feature is private" do before do project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE, diff --git a/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb b/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb index 769b3193275..3ef5135e6a3 100644 --- a/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb +++ b/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb @@ -82,6 +82,10 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do sha: merge_request_head, status: 'success') end + before do + mr_merge_if_green_enabled.update(head_pipeline: triggering_pipeline) + end + it "merges all merge requests with merge when the pipeline succeeds enabled" do expect(MergeWorker).to receive(:perform_async) service.trigger(triggering_pipeline) @@ -124,6 +128,8 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do sha: mr_conflict.diff_head_sha, status: 'success') end + before { mr_conflict.update(head_pipeline: conflict_pipeline) } + it 'does not merge the merge request' do expect(MergeWorker).not_to receive(:perform_async) diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb index 03215a4624a..1f109eab268 100644 --- a/spec/services/merge_requests/refresh_service_spec.rb +++ b/spec/services/merge_requests/refresh_service_spec.rb @@ -348,7 +348,7 @@ describe MergeRequests::RefreshService, services: true do title: 'fixup! Fix issue', work_in_progress?: true, to_reference: 'ccccccc' - ), + ) ]) refresh_service.execute(@oldrev, @newrev, 'refs/heads/wip') reload_mrs diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index 31487c0f794..860a7798857 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -59,14 +59,16 @@ describe MergeRequests::UpdateService, services: true do end end - it { expect(@merge_request).to be_valid } - it { expect(@merge_request.title).to eq('New title') } - it { expect(@merge_request.assignee).to eq(user2) } - it { expect(@merge_request).to be_closed } - it { expect(@merge_request.labels.count).to eq(1) } - it { expect(@merge_request.labels.first.title).to eq(label.name) } - it { expect(@merge_request.target_branch).to eq('target') } - it { expect(@merge_request.merge_params['force_remove_source_branch']).to eq('1') } + it 'mathces base expectations' do + expect(@merge_request).to be_valid + expect(@merge_request.title).to eq('New title') + expect(@merge_request.assignee).to eq(user2) + expect(@merge_request).to be_closed + expect(@merge_request.labels.count).to eq(1) + expect(@merge_request.labels.first.title).to eq(label.name) + expect(@merge_request.target_branch).to eq('target') + expect(@merge_request.merge_params['force_remove_source_branch']).to eq('1') + end it 'executes hooks with update action' do expect(service).to have_received(:execute_hooks). @@ -148,9 +150,11 @@ describe MergeRequests::UpdateService, services: true do end end - it { expect(@merge_request).to be_valid } - it { expect(@merge_request.state).to eq('merged') } - it { expect(@merge_request.merge_error).to be_nil } + it 'merges the MR' do + expect(@merge_request).to be_valid + expect(@merge_request.state).to eq('merged') + expect(@merge_request.merge_error).to be_nil + end end context 'with finished pipeline' do @@ -167,18 +171,22 @@ describe MergeRequests::UpdateService, services: true do end end - it { expect(@merge_request).to be_valid } - it { expect(@merge_request.state).to eq('merged') } + it 'merges the MR' do + expect(@merge_request).to be_valid + expect(@merge_request.state).to eq('merged') + end end context 'with active pipeline' do before do service_mock = double - create(:ci_pipeline_with_one_job, + pipeline = create(:ci_pipeline_with_one_job, project: project, ref: merge_request.source_branch, sha: merge_request.diff_head_sha) + merge_request.update(head_pipeline: pipeline) + expect(MergeRequests::MergeWhenPipelineSucceedsService).to receive(:new).with(project, user). and_return(service_mock) expect(service_mock).to receive(:execute).with(merge_request) @@ -200,8 +208,10 @@ describe MergeRequests::UpdateService, services: true do end end - it { expect(@merge_request.state).to eq('opened') } - it { expect(@merge_request.merge_error).not_to be_nil } + it 'does not merge the MR' do + expect(@merge_request.state).to eq('opened') + expect(@merge_request.merge_error).not_to be_nil + end end context 'MR can not be merged when note sha != MR sha' do @@ -297,6 +307,15 @@ describe MergeRequests::UpdateService, services: true do end end + context 'when the assignee changes' do + it 'updates open merge request counter for assignees when merge request is reassigned' do + update_merge_request(assignee_id: user2.id) + + expect(user3.assigned_open_merge_requests_count).to eq 0 + expect(user2.assigned_open_merge_requests_count).to eq 1 + end + end + context 'when the target branch change' do before do update_merge_request({ target_branch: 'target' }) diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 74f96b97909..de3bbc6b6a1 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -350,7 +350,7 @@ describe NotificationService, services: true do create(:note_on_personal_snippet, noteable: snippet, note: 'note', author: @u_participant), create(:note_on_personal_snippet, noteable: snippet, note: 'note', author: @u_mentioned), create(:note_on_personal_snippet, noteable: snippet, note: 'note', author: @u_disabled), - create(:note_on_personal_snippet, noteable: snippet, note: 'note', author: @u_note_author), + create(:note_on_personal_snippet, noteable: snippet, note: 'note', author: @u_note_author) ] end diff --git a/spec/services/projects/participants_service_spec.rb b/spec/services/projects/participants_service_spec.rb index 063b3bd76eb..0657b7e93fe 100644 --- a/spec/services/projects/participants_service_spec.rb +++ b/spec/services/projects/participants_service_spec.rb @@ -6,7 +6,6 @@ describe Projects::ParticipantsService, services: true do let(:project) { create(:empty_project, :public) } let(:group) { create(:group, avatar: fixture_file_upload(Rails.root + 'spec/fixtures/dk.png')) } let(:user) { create(:user) } - let(:base_url) { Settings.send(:build_base_gitlab_url) } let!(:group_member) { create(:group_member, group: group, user: user) } it 'should return an url for the avatar' do @@ -14,7 +13,7 @@ describe Projects::ParticipantsService, services: true do groups = participants.groups expect(groups.size).to eq 1 - expect(groups.first[:avatar_url]).to eq "#{base_url}/uploads/group/avatar/#{group.id}/dk.png" + expect(groups.first[:avatar_url]).to eq("/uploads/group/avatar/#{group.id}/dk.png") end it 'should return an url for the avatar with relative url' do @@ -25,7 +24,7 @@ describe Projects::ParticipantsService, services: true do groups = participants.groups expect(groups.size).to eq 1 - expect(groups.first[:avatar_url]).to eq "#{base_url}/gitlab/uploads/group/avatar/#{group.id}/dk.png" + expect(groups.first[:avatar_url]).to eq("/gitlab/uploads/group/avatar/#{group.id}/dk.png") end end end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 516566eddef..7a9cd7553b1 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -178,7 +178,7 @@ describe SystemNoteService, services: true do end it 'builds a correct phrase when assignee removed' do - expect(build_note([assignee1], [])).to eq 'removed all assignees' + expect(build_note([assignee1], [])).to eq 'removed assignee' end it 'builds a correct phrase when assignees changed' do diff --git a/spec/sidekiq/cron/job_gem_dependency_spec.rb b/spec/sidekiq/cron/job_gem_dependency_spec.rb new file mode 100644 index 00000000000..2e30cf025b0 --- /dev/null +++ b/spec/sidekiq/cron/job_gem_dependency_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe Sidekiq::Cron::Job do + describe 'cron jobs' do + context 'when rufus-scheduler depends on ZoTime or EoTime' do + before do + described_class + .create(name: 'TestCronWorker', + cron: Settings.cron_jobs[:pipeline_schedule_worker]['cron'], + class: Settings.cron_jobs[:pipeline_schedule_worker]['job_class']) + end + + it 'does not get "Rufus::Scheduler::ZoTime/EtOrbi::EoTime into an exact number"' do + expect { described_class.all.first.should_enque?(Time.now) }.not_to raise_error + end + end + end +end diff --git a/spec/support/cycle_analytics_helpers.rb b/spec/support/cycle_analytics_helpers.rb index 8ad042f5e3b..66545127a44 100644 --- a/spec/support/cycle_analytics_helpers.rb +++ b/spec/support/cycle_analytics_helpers.rb @@ -20,7 +20,7 @@ module CycleAnalyticsHelpers ref: 'refs/heads/master').execute end - def create_merge_request_closing_issue(issue, message: nil, source_branch: nil) + def create_merge_request_closing_issue(issue, message: nil, source_branch: nil, commit_message: 'commit message') if !source_branch || project.repository.commit(source_branch).blank? source_branch = generate(:branch) project.repository.add_branch(user, source_branch, 'master') @@ -30,7 +30,7 @@ module CycleAnalyticsHelpers user, generate(:branch), 'content', - message: 'commit message', + message: commit_message, branch_name: source_branch) project.repository.commit(sha) diff --git a/spec/support/filtered_search_helpers.rb b/spec/support/filtered_search_helpers.rb index 36be0bb6bf8..37cc308e613 100644 --- a/spec/support/filtered_search_helpers.rb +++ b/spec/support/filtered_search_helpers.rb @@ -73,11 +73,11 @@ module FilteredSearchHelpers end def remove_recent_searches - execute_script('window.localStorage.removeItem(\'issue-recent-searches\');') + execute_script('window.localStorage.clear();') end - def set_recent_searches(input) - execute_script("window.localStorage.setItem('issue-recent-searches', '#{input}');") + def set_recent_searches(key, input) + execute_script("window.localStorage.setItem('#{key}', '#{input}');") end def wait_for_filtered_search(text) diff --git a/spec/support/generate-seed-repo-rb b/spec/support/generate-seed-repo-rb new file mode 100755 index 00000000000..7335f74c0e9 --- /dev/null +++ b/spec/support/generate-seed-repo-rb @@ -0,0 +1,162 @@ +#!/usr/bin/env ruby +# +# # generate-seed-repo-rb +# +# This script generates the seed_repo.rb file used by lib/gitlab/git +# tests. The seed_repo.rb file needs to be updated anytime there is a +# Git push to https://gitlab.com/gitlab-org/gitlab-git-test. +# +# Usage: +# +# ./spec/support/generate-seed-repo-rb > spec/support/seed_repo.rb +# +# + +require 'erb' +require 'tempfile' + +SOURCE = 'https://gitlab.com/gitlab-org/gitlab-git-test.git'.freeze +SCRIPT_NAME = 'generate-seed-repo-rb'.freeze +REPO_NAME = 'gitlab-git-test.git'.freeze + +def main + Dir.mktmpdir do |dir| + unless system(*%W[git clone --bare #{SOURCE} #{REPO_NAME}], chdir: dir) + abort "git clone failed" + end + repo = File.join(dir, REPO_NAME) + erb = ERB.new(DATA.read) + erb.run(binding) + end +end + +def capture!(cmd, dir) + output = IO.popen(cmd, 'r', chdir: dir) { |io| io.read } + raise "command failed with #{$?}: #{cmd.join(' ')}" unless $?.success? + output.chomp +end + +main + +__END__ +# This file is generated by <%= SCRIPT_NAME %>. Do not edit this file manually. +# +# Seed repo: +<%= capture!(%w{git log --format=#\ %H\ %s}, repo) %> + +module SeedRepo + module BigCommit + ID = "913c66a37b4a45b9769037c55c2d238bd0942d2e".freeze + PARENT_ID = "cfe32cf61b73a0d5e9f13e774abde7ff789b1660".freeze + MESSAGE = "Files, encoding and much more".freeze + AUTHOR_FULL_NAME = "Dmitriy Zaporozhets".freeze + FILES_COUNT = 2 + end + + module Commit + ID = "570e7b2abdd848b95f2f578043fc23bd6f6fd24d".freeze + PARENT_ID = "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9".freeze + MESSAGE = "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n".freeze + AUTHOR_FULL_NAME = "Dmitriy Zaporozhets".freeze + FILES = ["files/ruby/popen.rb", "files/ruby/regex.rb"].freeze + FILES_COUNT = 2 + C_FILE_PATH = "files/ruby".freeze + C_FILES = ["popen.rb", "regex.rb", "version_info.rb"].freeze + BLOB_FILE = %{%h3= @key.title\n%hr\n%pre= @key.key\n.actions\n = link_to 'Remove', @key, :confirm => 'Are you sure?', :method => :delete, :class => \"btn danger delete-key\"\n\n\n}.freeze + BLOB_FILE_PATH = "app/views/keys/show.html.haml".freeze + end + + module EmptyCommit + ID = "b0e52af38d7ea43cf41d8a6f2471351ac036d6c9".freeze + PARENT_ID = "40f4a7a617393735a95a0bb67b08385bc1e7c66d".freeze + MESSAGE = "Empty commit".freeze + AUTHOR_FULL_NAME = "Rémy Coutable".freeze + FILES = [].freeze + FILES_COUNT = FILES.count + end + + module EncodingCommit + ID = "40f4a7a617393735a95a0bb67b08385bc1e7c66d".freeze + PARENT_ID = "66028349a123e695b589e09a36634d976edcc5e8".freeze + MESSAGE = "Add ISO-8859-encoded file".freeze + AUTHOR_FULL_NAME = "Stan Hu".freeze + FILES = ["encoding/iso8859.txt"].freeze + FILES_COUNT = FILES.count + end + + module FirstCommit + ID = "1a0b36b3cdad1d2ee32457c102a8c0b7056fa863".freeze + PARENT_ID = nil + MESSAGE = "Initial commit".freeze + AUTHOR_FULL_NAME = "Dmitriy Zaporozhets".freeze + FILES = ["LICENSE", ".gitignore", "README.md"].freeze + FILES_COUNT = 3 + end + + module LastCommit + ID = <%= capture!(%w[git show -s --format=%H HEAD], repo).inspect %>.freeze + PARENT_ID = <%= capture!(%w[git show -s --format=%P HEAD], repo).split.last.inspect %>.freeze + MESSAGE = <%= capture!(%w[git show -s --format=%s HEAD], repo).inspect %>.freeze + AUTHOR_FULL_NAME = <%= capture!(%w[git show -s --format=%an HEAD], repo).inspect %>.freeze + FILES = <%= + parents = capture!(%w[git show -s --format=%P HEAD], repo).split + merge_base = parents.size > 1 ? capture!(%w[git merge-base] + parents, repo) : parents.first + capture!( %W[git diff --name-only #{merge_base}..HEAD --], repo).split("\n").inspect + %>.freeze + FILES_COUNT = FILES.count + end + + module Repo + HEAD = "master".freeze + BRANCHES = %w[ +<%= capture!(%W[git for-each-ref --format=#{' ' * 3}%(refname:strip=2) refs/heads/], repo) %> + ].freeze + TAGS = %w[ +<%= capture!(%W[git for-each-ref --format=#{' ' * 3}%(refname:strip=2) refs/tags/], repo) %> + ].freeze + end + + module RubyBlob + ID = "7e3e39ebb9b2bf433b4ad17313770fbe4051649c".freeze + NAME = "popen.rb".freeze + CONTENT = <<-eos.freeze +require 'fileutils' +require 'open3' + +module Popen + extend self + + def popen(cmd, path=nil) + unless cmd.is_a?(Array) + raise RuntimeError, "System commands must be given as an array of strings" + end + + path ||= Dir.pwd + + vars = { + "PWD" => path + } + + options = { + chdir: path + } + + unless File.directory?(path) + FileUtils.mkdir_p(path) + end + + @cmd_output = "" + @cmd_status = 0 + + Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr| + @cmd_output << stdout.read + @cmd_output << stderr.read + @cmd_status = wait_thr.value.exitstatus + end + + return @cmd_output, @cmd_status + end +end + eos + end +end diff --git a/spec/support/kubernetes_helpers.rb b/spec/support/kubernetes_helpers.rb index b5ed71ba3be..d2a1ded57ff 100644 --- a/spec/support/kubernetes_helpers.rb +++ b/spec/support/kubernetes_helpers.rb @@ -5,7 +5,7 @@ module KubernetesHelpers { "kind" => "APIResourceList", "resources" => [ - { "name" => "pods", "namespaced" => true, "kind" => "Pod" }, + { "name" => "pods", "namespaced" => true, "kind" => "Pod" } ] } end @@ -22,13 +22,13 @@ module KubernetesHelpers "metadata" => { "name" => "kube-pod", "creationTimestamp" => "2016-11-25T19:55:19Z", - "labels" => { "app" => app }, + "labels" => { "app" => app } }, "spec" => { "containers" => [ { "name" => "container-0" }, - { "name" => "container-1" }, - ], + { "name" => "container-1" } + ] }, "status" => { "phase" => "Running" } } diff --git a/spec/support/prometheus_helpers.rb b/spec/support/prometheus_helpers.rb index a204365431b..6b9ebcf2bb3 100644 --- a/spec/support/prometheus_helpers.rb +++ b/spec/support/prometheus_helpers.rb @@ -1,10 +1,16 @@ module PrometheusHelpers def prometheus_memory_query(environment_slug) - %{(sum(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}) / count(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"})) /1024/1024} + %{avg(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}) / 2^20} end def prometheus_cpu_query(environment_slug) - %{sum(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) / count(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}) * 100} + %{avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) * 100} + end + + def prometheus_ping_url(prometheus_query) + query = { query: prometheus_query }.to_query + + "https://prometheus.example.com/api/v1/query?#{query}" end def prometheus_query_url(prometheus_query) @@ -13,11 +19,17 @@ module PrometheusHelpers "https://prometheus.example.com/api/v1/query?#{query}" end - def prometheus_query_range_url(prometheus_query, start: 8.hours.ago) + def prometheus_query_with_time_url(prometheus_query, time) + query = { query: prometheus_query, time: time.to_f }.to_query + + "https://prometheus.example.com/api/v1/query?#{query}" + end + + def prometheus_query_range_url(prometheus_query, start: 8.hours.ago, stop: Time.now.to_f) query = { query: prometheus_query, start: start.to_f, - end: Time.now.utc.to_f, + end: stop, step: 1.minute.to_i }.to_query @@ -39,7 +51,12 @@ module PrometheusHelpers def stub_all_prometheus_requests(environment_slug, body: nil, status: 200) stub_prometheus_request( - prometheus_query_url(prometheus_memory_query(environment_slug)), + prometheus_query_with_time_url(prometheus_memory_query(environment_slug), Time.now.utc), + status: status, + body: body || prometheus_value_body + ) + stub_prometheus_request( + prometheus_query_with_time_url(prometheus_memory_query(environment_slug), 8.hours.ago), status: status, body: body || prometheus_value_body ) @@ -49,7 +66,12 @@ module PrometheusHelpers body: body || prometheus_values_body ) stub_prometheus_request( - prometheus_query_url(prometheus_cpu_query(environment_slug)), + prometheus_query_with_time_url(prometheus_cpu_query(environment_slug), Time.now.utc), + status: status, + body: body || prometheus_value_body + ) + stub_prometheus_request( + prometheus_query_with_time_url(prometheus_cpu_query(environment_slug), 8.hours.ago), status: status, body: body || prometheus_value_body ) diff --git a/spec/support/repo_helpers.rb b/spec/support/repo_helpers.rb index e9d5c7b12ae..3c6956cf5e0 100644 --- a/spec/support/repo_helpers.rb +++ b/spec/support/repo_helpers.rb @@ -92,11 +92,11 @@ eos changes = [ { line_code: 'a5cc2925ca8258af241be7e5b0381edf30266302_20_20', - file_path: '.gitignore', + file_path: '.gitignore' }, { line_code: '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44_4_6', - file_path: '.gitmodules', + file_path: '.gitmodules' } ] diff --git a/spec/support/seed_repo.rb b/spec/support/seed_repo.rb index 99a500bbbb1..cfe7fc980a8 100644 --- a/spec/support/seed_repo.rb +++ b/spec/support/seed_repo.rb @@ -1,4 +1,8 @@ +# This file is generated by generate-seed-repo-rb. Do not edit this file manually. +# # Seed repo: +# 4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6 Merge branch 'master' into 'master' +# 0e1b353b348f8477bdbec1ef47087171c5032cd9 adds an executable with different permissions # 0e50ec4d3c7ce42ab74dda1d422cb2cbffe1e326 Merge branch 'lfs_pointers' into 'master' # 33bcff41c232a11727ac6d660bd4b0c2ba86d63d Add valid and invalid lfs pointers # 732401c65e924df81435deb12891ef570167d2e2 Update year in license file @@ -94,7 +98,12 @@ module SeedRepo master merge-test ].freeze - TAGS = %w[v1.0.0 v1.1.0 v1.2.0 v1.2.1].freeze + TAGS = %w[ + v1.0.0 + v1.1.0 + v1.2.0 + v1.2.1 + ].freeze end module RubyBlob diff --git a/spec/support/slack_mattermost_notifications_shared_examples.rb b/spec/support/slack_mattermost_notifications_shared_examples.rb index b902fe90707..7e35ebb6c97 100644 --- a/spec/support/slack_mattermost_notifications_shared_examples.rb +++ b/spec/support/slack_mattermost_notifications_shared_examples.rb @@ -328,7 +328,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do context 'only notify for the default branch' do context 'when enabled' do let(:pipeline) do - create(:ci_pipeline, project: project, status: 'failed', ref: 'not-the-default-branch') + create(:ci_pipeline, :failed, project: project, ref: 'not-the-default-branch') end before do @@ -342,6 +342,18 @@ RSpec.shared_examples 'slack or mattermost notifications' do expect(result).to be_falsy end end + + context 'when disabled' do + let(:pipeline) do + create(:ci_pipeline, :failed, project: project, ref: 'not-the-default-branch') + end + + before do + chat_service.notify_only_default_branch = false + end + + it_behaves_like 'call Slack/Mattermost API' + end end end end diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index 8e31c26591b..9bf9dc5d4b2 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -120,7 +120,7 @@ module TestEnv end def setup_gitaly - socket_path = Gitlab::GitalyClient.get_address('default').sub(/\Aunix:/, '') + socket_path = Gitlab::GitalyClient.address('default').sub(/\Aunix:/, '') gitaly_dir = File.dirname(socket_path) unless File.directory?(gitaly_dir) || system('rake', "gitlab:gitaly:install[#{gitaly_dir}]") @@ -133,7 +133,8 @@ module TestEnv def start_gitaly(gitaly_dir) gitaly_exec = File.join(gitaly_dir, 'gitaly') gitaly_config = File.join(gitaly_dir, 'config.toml') - @gitaly_pid = spawn(gitaly_exec, gitaly_config, [:out, :err] => '/dev/null') + log_file = Rails.root.join('log/gitaly-test.log').to_s + @gitaly_pid = spawn(gitaly_exec, gitaly_config, [:out, :err] => log_file) end def stop_gitaly diff --git a/spec/support/wait_for_requests.rb b/spec/support/wait_for_requests.rb index 73da23391ee..d41e83ae128 100644 --- a/spec/support/wait_for_requests.rb +++ b/spec/support/wait_for_requests.rb @@ -1,15 +1,16 @@ require_relative './wait_for_ajax' +require_relative './wait_for_vue_resource' module WaitForRequests extend self include WaitForAjax + include WaitForVueResource # This is inspired by http://www.salsify.com/blog/engineering/tearing-capybara-ajax-tests def wait_for_requests_complete Gitlab::Testing::RequestBlockerMiddleware.block_requests! wait_for('pending AJAX requests complete') do - Gitlab::Testing::RequestBlockerMiddleware.num_active_requests.zero? && - finished_all_ajax_requests? + Gitlab::Testing::RequestBlockerMiddleware.num_active_requests.zero? end ensure Gitlab::Testing::RequestBlockerMiddleware.allow_requests! diff --git a/spec/support/wait_for_vue_resource.rb b/spec/support/wait_for_vue_resource.rb index 4a4e2e16ee7..3bb3d9c2e51 100644 --- a/spec/support/wait_for_vue_resource.rb +++ b/spec/support/wait_for_vue_resource.rb @@ -1,7 +1,19 @@ module WaitForVueResource def wait_for_vue_resource(spinner: true) Timeout.timeout(Capybara.default_max_wait_time) do - loop until page.evaluate_script('window.activeVueResources').zero? + loop until finished_all_vue_resource_requests? end end + + private + + def finished_all_vue_resource_requests? + return true unless javascript_test? + + page.evaluate_script('window.activeVueResources || 0').zero? + end + + def javascript_test? + Capybara.current_driver == Capybara.javascript_driver + end end diff --git a/spec/support/workhorse_helpers.rb b/spec/support/workhorse_helpers.rb index 47673cd4c3a..ef1f9f68671 100644 --- a/spec/support/workhorse_helpers.rb +++ b/spec/support/workhorse_helpers.rb @@ -9,7 +9,7 @@ module WorkhorseHelpers header = split_header.join(':') [ type, - JSON.parse(Base64.urlsafe_decode64(header)), + JSON.parse(Base64.urlsafe_decode64(header)) ] end end diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb index df2f2ce95e6..0ff1a988a9e 100644 --- a/spec/tasks/gitlab/backup_rake_spec.rb +++ b/spec/tasks/gitlab/backup_rake_spec.rb @@ -236,7 +236,6 @@ describe 'gitlab:app namespace rake task' do 'custom' => { 'path' => Settings.absolute('tmp/tests/custom_storage'), 'gitaly_address' => gitaly_address } } allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) - Gitlab::GitalyClient.configure_channels # Create the projects now, after mocking the settings but before doing the backup project_a @@ -352,7 +351,7 @@ describe 'gitlab:app namespace rake task' do end it 'name has human readable time' do - expect(@backup_tar).to match(/\d+_\d{4}_\d{2}_\d{2}_\d+\.\d+\.\d+(-pre)?_gitlab_backup.tar$/) + expect(@backup_tar).to match(/\d+_\d{4}_\d{2}_\d{2}_\d+\.\d+\.\d+.*_gitlab_backup.tar$/) end end end # gitlab:app namespace diff --git a/spec/tasks/gitlab/gitaly_rake_spec.rb b/spec/tasks/gitlab/gitaly_rake_spec.rb index aaf998a546f..f035504320b 100644 --- a/spec/tasks/gitlab/gitaly_rake_spec.rb +++ b/spec/tasks/gitlab/gitaly_rake_spec.rb @@ -80,7 +80,7 @@ describe 'gitlab:gitaly namespace rake task' do it 'prints storage configuration in a TOML format' do config = { 'default' => { 'path' => '/path/to/default' }, - 'nfs_01' => { 'path' => '/path/to/nfs_01' }, + 'nfs_01' => { 'path' => '/path/to/nfs_01' } } allow(Gitlab.config.repositories).to receive(:storages).and_return(config) diff --git a/spec/views/projects/blob/_viewer.html.haml_spec.rb b/spec/views/projects/blob/_viewer.html.haml_spec.rb index 501f90c5f9a..08018767624 100644 --- a/spec/views/projects/blob/_viewer.html.haml_spec.rb +++ b/spec/views/projects/blob/_viewer.html.haml_spec.rb @@ -47,10 +47,10 @@ describe 'projects/blob/_viewer.html.haml', :view do expect(rendered).to have_css('.blob-viewer[data-url]') end - it 'displays a spinner' do + it 'renders the loading indicator' do render_view - expect(rendered).to have_css('i[aria-label="Loading content"]') + expect(view).to render_template('projects/blob/viewers/_loading') end end diff --git a/spec/views/projects/imports/new.html.haml_spec.rb b/spec/views/projects/imports/new.html.haml_spec.rb new file mode 100644 index 00000000000..9b293065797 --- /dev/null +++ b/spec/views/projects/imports/new.html.haml_spec.rb @@ -0,0 +1,22 @@ +require "spec_helper" + +describe "projects/imports/new.html.haml" do + let(:user) { create(:user) } + + context 'when import fails' do + let(:project) { create(:project_empty_repo, import_status: :failed, import_error: '<a href="http://googl.com">Foo</a>', import_type: :gitlab_project, import_source: '/var/opt/gitlab/gitlab-rails/shared/tmp/project_exports/uploads/t.tar.gz', import_url: nil) } + + before do + sign_in(user) + project.team << [user, :master] + end + + it "escapes HTML in import errors" do + assign(:project, project) + + render + + expect(rendered).not_to have_link('Foo', href: "http://googl.com") + end + end +end diff --git a/spec/views/projects/pipelines/_stage.html.haml_spec.rb b/spec/views/projects/pipelines/_stage.html.haml_spec.rb index 10095ad7694..9c91c4e0fbd 100644 --- a/spec/views/projects/pipelines/_stage.html.haml_spec.rb +++ b/spec/views/projects/pipelines/_stage.html.haml_spec.rb @@ -39,9 +39,8 @@ describe 'projects/pipelines/_stage', :view do context 'when there are retried builds present' do before do - create_list(:ci_build, 2, name: 'test:build', - stage: stage.name, - pipeline: pipeline) + create(:ci_build, name: 'test:build', stage: stage.name, pipeline: pipeline, retried: true) + create(:ci_build, name: 'test:build', stage: stage.name, pipeline: pipeline) end it 'shows only latest builds' do diff --git a/spec/views/projects/tree/show.html.haml_spec.rb b/spec/views/projects/tree/show.html.haml_spec.rb index 900f8d4732f..835a93e620e 100644 --- a/spec/views/projects/tree/show.html.haml_spec.rb +++ b/spec/views/projects/tree/show.html.haml_spec.rb @@ -31,7 +31,7 @@ describe 'projects/tree/show' do it 'displays correctly' do render expect(rendered).to have_css('.js-project-refs-dropdown .dropdown-toggle-text', text: ref) - expect(rendered).to have_css('.readme-holder .file-content', text: ref) + expect(rendered).to have_css('.readme-holder') end end end diff --git a/spec/workers/git_garbage_collect_worker_spec.rb b/spec/workers/git_garbage_collect_worker_spec.rb index 7a590f64e3c..8c5303b61cc 100644 --- a/spec/workers/git_garbage_collect_worker_spec.rb +++ b/spec/workers/git_garbage_collect_worker_spec.rb @@ -105,7 +105,7 @@ describe GitGarbageCollectWorker do author: Gitlab::Git.committer_hash(email: 'foo@bar', name: 'baz'), committer: Gitlab::Git.committer_hash(email: 'foo@bar', name: 'baz'), tree: old_commit.tree, - parents: [old_commit], + parents: [old_commit] ) GitOperationService.new(nil, project.repository).send( :update_ref, diff --git a/spec/workers/namespaceless_project_destroy_worker_spec.rb b/spec/workers/namespaceless_project_destroy_worker_spec.rb new file mode 100644 index 00000000000..8533b7b85e9 --- /dev/null +++ b/spec/workers/namespaceless_project_destroy_worker_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +describe NamespacelessProjectDestroyWorker do + subject { described_class.new } + + before do + # Stub after_save callbacks that will fail when Project has no namespace + allow_any_instance_of(Project).to receive(:ensure_dir_exist).and_return(nil) + allow_any_instance_of(Project).to receive(:update_project_statistics).and_return(nil) + end + + describe '#perform' do + context 'project has namespace' do + it 'does not do anything' do + project = create(:empty_project) + + subject.perform(project.id) + + expect(Project.unscoped.all).to include(project) + end + end + + context 'project has no namespace' do + let!(:project) do + project = build(:empty_project, namespace_id: nil) + project.save(validate: false) + project + end + + context 'project not a fork of another project' do + it "truncates the project's team" do + expect_any_instance_of(ProjectTeam).to receive(:truncate) + + subject.perform(project.id) + end + + it 'deletes the project' do + subject.perform(project.id) + + expect(Project.unscoped.all).not_to include(project) + end + + it 'does not call unlink_fork' do + is_expected.not_to receive(:unlink_fork) + + subject.perform(project.id) + end + + it 'does not do anything in Project#remove_pages method' do + expect(Gitlab::PagesTransfer).not_to receive(:new) + + subject.perform(project.id) + end + end + + context 'project forked from another' do + let!(:parent_project) { create(:empty_project) } + + before do + create(:forked_project_link, forked_to_project: project, forked_from_project: parent_project) + end + + it 'closes open merge requests' do + merge_request = create(:merge_request, source_project: project, target_project: parent_project) + + subject.perform(project.id) + + expect(merge_request.reload).to be_closed + end + + it 'destroys the link' do + subject.perform(project.id) + + expect(parent_project.forked_project_links).to be_empty + end + end + end + end +end diff --git a/spec/workers/pipeline_metrics_worker_spec.rb b/spec/workers/pipeline_metrics_worker_spec.rb index 5dbc0da95c2..ef71125c0b6 100644 --- a/spec/workers/pipeline_metrics_worker_spec.rb +++ b/spec/workers/pipeline_metrics_worker_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe PipelineMetricsWorker do let(:project) { create(:project, :repository) } - let!(:merge_request) { create(:merge_request, source_project: project, source_branch: pipeline.ref) } + let!(:merge_request) { create(:merge_request, source_project: project, source_branch: pipeline.ref, head_pipeline: pipeline) } let(:pipeline) do create(:ci_empty_pipeline, diff --git a/spec/workers/pipeline_schedule_worker_spec.rb b/spec/workers/pipeline_schedule_worker_spec.rb index 91d5a16993f..9c650354d72 100644 --- a/spec/workers/pipeline_schedule_worker_spec.rb +++ b/spec/workers/pipeline_schedule_worker_spec.rb @@ -11,40 +11,53 @@ describe PipelineScheduleWorker do end before do - project.add_master(user) - stub_ci_pipeline_to_return_yaml_file - end - context 'when there is a scheduled pipeline within next_run_at' do - let(:next_run_at) { 2.days.ago } + pipeline_schedule.update_column(:next_run_at, 1.day.ago) + end + context 'when the schedule is runnable by the user' do before do - pipeline_schedule.update_column(:next_run_at, next_run_at) + project.add_master(user) end - it 'creates a new pipeline' do - expect { subject }.to change { project.pipelines.count }.by(1) - end + context 'when there is a scheduled pipeline within next_run_at' do + it 'creates a new pipeline' do + expect { subject }.to change { project.pipelines.count }.by(1) + end - it 'updates the next_run_at field' do - subject + it 'updates the next_run_at field' do + subject + + expect(pipeline_schedule.reload.next_run_at).to be > Time.now + end - expect(pipeline_schedule.reload.next_run_at).to be > Time.now + it 'sets the schedule on the pipeline' do + subject + + expect(project.pipelines.last.pipeline_schedule).to eq(pipeline_schedule) + end end - it 'sets the schedule on the pipeline' do - subject - expect(project.pipelines.last.pipeline_schedule).to eq(pipeline_schedule) + context 'inactive schedule' do + before do + pipeline_schedule.deactivate! + end + + it 'does not creates a new pipeline' do + expect { subject }.not_to change { project.pipelines.count } + end end end - context 'inactive schedule' do - before do - pipeline_schedule.update(active: false) + context 'when the schedule is not runnable by the user' do + it 'deactivates the schedule' do + subject + + expect(pipeline_schedule.reload.active).to be_falsy end - it 'does not creates a new pipeline' do + it 'does not schedule a pipeline' do expect { subject }.not_to change { project.pipelines.count } end end diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb index 0260416dbe2..f4bc63bcc6a 100644 --- a/spec/workers/post_receive_spec.rb +++ b/spec/workers/post_receive_spec.rb @@ -4,13 +4,16 @@ describe PostReceive do let(:changes) { "123456 789012 refs/heads/tést\n654321 210987 refs/tags/tag" } let(:wrongly_encoded_changes) { changes.encode("ISO-8859-1").force_encoding("UTF-8") } let(:base64_changes) { Base64.encode64(wrongly_encoded_changes) } - let(:project) { create(:project, :repository) } let(:project_identifier) { "project-#{project.id}" } let(:key) { create(:key, user: project.owner) } let(:key_id) { key.shell_id } - context "as a resque worker" do - it "reponds to #perform" do + let(:project) do + create(:project, :repository, auto_cancel_pending_pipelines: 'disabled') + end + + context "as a sidekiq worker" do + it "responds to #perform" do expect(described_class.new).to respond_to(:perform) end end @@ -93,6 +96,27 @@ describe PostReceive do end end + describe '#process_repository_update' do + let(:changes) {'123456 789012 refs/heads/tést'} + let(:fake_hook_data) do + { event_name: 'repository_update' } + end + + before do + allow_any_instance_of(Gitlab::GitPostReceive).to receive(:identify).and_return(project.owner) + allow_any_instance_of(Gitlab::DataBuilder::Repository).to receive(:update).and_return(fake_hook_data) + # silence hooks so we can isolate + allow_any_instance_of(Key).to receive(:post_create_hook).and_return(true) + allow(subject).to receive(:process_project_changes).and_return(true) + end + + it 'calls SystemHooksService' do + expect_any_instance_of(SystemHooksService).to receive(:execute_hooks).with(fake_hook_data, :repository_update_hooks).and_return(true) + + subject.perform(pwd(project), key_id, base64_changes) + end + end + context "webhook" do it "fetches the correct project" do expect(Project).to receive(:find_by).with(id: project.id.to_s) diff --git a/spec/workers/process_commit_worker_spec.rb b/spec/workers/process_commit_worker_spec.rb index 9afe2e610b9..6295856b461 100644 --- a/spec/workers/process_commit_worker_spec.rb +++ b/spec/workers/process_commit_worker_spec.rb @@ -20,6 +20,14 @@ describe ProcessCommitWorker do worker.perform(project.id, -1, commit.to_hash) end + it 'does not process the commit when no issues are referenced' do + allow(worker).to receive(:build_commit).and_return(double(matches_cross_reference_regex?: false)) + + expect(worker).not_to receive(:process_commit_message) + + worker.perform(project.id, user.id, commit.to_hash) + end + it 'processes the commit message' do expect(worker).to receive(:process_commit_message).and_call_original diff --git a/spec/workers/repository_check/clear_worker_spec.rb b/spec/workers/repository_check/clear_worker_spec.rb index a3b70c74787..3b1a64c5057 100644 --- a/spec/workers/repository_check/clear_worker_spec.rb +++ b/spec/workers/repository_check/clear_worker_spec.rb @@ -5,7 +5,7 @@ describe RepositoryCheck::ClearWorker do project = create(:empty_project) project.update_columns( last_repository_check_failed: true, - last_repository_check_at: Time.now, + last_repository_check_at: Time.now ) described_class.new.perform diff --git a/vendor/Dockerfile/OpenJDK-alpine.Dockerfile b/vendor/Dockerfile/OpenJDK-alpine.Dockerfile new file mode 100644 index 00000000000..ee853d9cfd2 --- /dev/null +++ b/vendor/Dockerfile/OpenJDK-alpine.Dockerfile @@ -0,0 +1,8 @@ +FROM openjdk:8-alpine + +COPY . /usr/src/myapp +WORKDIR /usr/src/myapp + +RUN javac Main.java + +CMD ["java", "Main"] diff --git a/vendor/Dockerfile/OpenJDK.Dockerfile b/vendor/Dockerfile/OpenJDK.Dockerfile new file mode 100644 index 00000000000..8a2ae62d93b --- /dev/null +++ b/vendor/Dockerfile/OpenJDK.Dockerfile @@ -0,0 +1,8 @@ +FROM openjdk:9 + +COPY . /usr/src/myapp +WORKDIR /usr/src/myapp + +RUN javac Main.java + +CMD ["java", "Main"] diff --git a/vendor/Dockerfile/Python-alpine.Dockerfile b/vendor/Dockerfile/Python-alpine.Dockerfile new file mode 100644 index 00000000000..59ac9f504de --- /dev/null +++ b/vendor/Dockerfile/Python-alpine.Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.6-alpine + +# Edit with mysql-client, postgresql-client, sqlite3, etc. for your needs. +# Or delete entirely if not needed. +RUN apk --no-cache add postgresql-client + +WORKDIR /usr/src/app + +COPY requirements.txt /usr/src/app/ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /usr/src/app + +# For Django +EXPOSE 8000 +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] + +# For some other command +# CMD ["python", "app.py"] diff --git a/vendor/Dockerfile/Python.Dockerfile b/vendor/Dockerfile/Python.Dockerfile new file mode 100644 index 00000000000..7c43ad99060 --- /dev/null +++ b/vendor/Dockerfile/Python.Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.6 + +# Edit with mysql-client, postgresql-client, sqlite3, etc. for your needs. +# Or delete entirely if not needed. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src/app + +COPY requirements.txt /usr/src/app/ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /usr/src/app + +# For Django +EXPOSE 8000 +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] + +# For some other command +# CMD ["python", "app.py"] diff --git a/vendor/gitignore/Global/Archives.gitignore b/vendor/gitignore/Global/Archives.gitignore index e9eda68baf2..f440b808d98 100644 --- a/vendor/gitignore/Global/Archives.gitignore +++ b/vendor/gitignore/Global/Archives.gitignore @@ -5,6 +5,7 @@ *.rar *.zip *.gz +*.tgz *.bzip *.bz2 *.xz diff --git a/vendor/gitignore/Global/JetBrains.gitignore b/vendor/gitignore/Global/JetBrains.gitignore index a5d4cc86d33..ff23445e2b0 100644 --- a/vendor/gitignore/Global/JetBrains.gitignore +++ b/vendor/gitignore/Global/JetBrains.gitignore @@ -19,6 +19,9 @@ .idea/**/gradle.xml .idea/**/libraries +# CMake +cmake-build-debug/ + # Mongo Explorer plugin: .idea/**/mongoSettings.xml diff --git a/vendor/gitignore/Global/MicrosoftOffice.gitignore b/vendor/gitignore/Global/MicrosoftOffice.gitignore index cb891745660..0c203662d39 100644 --- a/vendor/gitignore/Global/MicrosoftOffice.gitignore +++ b/vendor/gitignore/Global/MicrosoftOffice.gitignore @@ -13,4 +13,4 @@ ~$*.ppt* # Visio autosave temporary files -*.~vsdx +*.~vsd* diff --git a/vendor/gitignore/Magento.gitignore b/vendor/gitignore/Magento.gitignore index b282f5cf547..6f1fa223992 100644 --- a/vendor/gitignore/Magento.gitignore +++ b/vendor/gitignore/Magento.gitignore @@ -3,14 +3,41 @@ #--------------------------# /app/etc/local.xml + /media/* !/media/.htaccess + +!/media/customer +/media/customer/* !/media/customer/.htaccess + +!/media/dhl +/media/dhl/* !/media/dhl/logo.jpg + +!/media/downloadable +/media/downloadable/* !/media/downloadable/.htaccess + +!/media/xmlconnect +/media/xmlconnect/* + +!/media/xmlconnect/custom +/media/xmlconnect/custom/* !/media/xmlconnect/custom/ok.gif + +!/media/xmlconnect/original +/media/xmlconnect/original/* !/media/xmlconnect/original/ok.gif + +!/media/xmlconnect/system +/media/xmlconnect/system/* !/media/xmlconnect/system/ok.gif + /var/* !/var/.htaccess + +!/var/package +/var/package/* !/var/package/*.xml + diff --git a/vendor/gitignore/Python.gitignore b/vendor/gitignore/Python.gitignore index ff65a437185..768d5f400bb 100644 --- a/vendor/gitignore/Python.gitignore +++ b/vendor/gitignore/Python.gitignore @@ -89,9 +89,13 @@ ENV/ # Spyder project settings .spyderproject +.spyproject # Rope project settings .ropeproject # mkdocs documentation /site + +# mypy +.mypy_cache/ diff --git a/vendor/gitignore/Qt.gitignore b/vendor/gitignore/Qt.gitignore index c7659c24f38..6732e72091c 100644 --- a/vendor/gitignore/Qt.gitignore +++ b/vendor/gitignore/Qt.gitignore @@ -20,6 +20,7 @@ *.qbs.user.* *.moc moc_*.cpp +moc_*.h qrc_*.cpp ui_*.h Makefile* diff --git a/vendor/gitignore/UnrealEngine.gitignore b/vendor/gitignore/UnrealEngine.gitignore index 2f096001fec..6c6e1c327fd 100644 --- a/vendor/gitignore/UnrealEngine.gitignore +++ b/vendor/gitignore/UnrealEngine.gitignore @@ -54,6 +54,11 @@ Binaries/* # Builds Build/* +# Whitelist PakBlacklist-<BuildConfiguration>.txt files +!Build/*/ +Build/*/** +!Build/*/PakBlacklist*.txt + # Don't ignore icon files in Build !Build/**/*.ico diff --git a/vendor/licenses.csv b/vendor/licenses.csv index 6441df25fe1..a8e7f5e3ea9 100644 --- a/vendor/licenses.csv +++ b/vendor/licenses.csv @@ -3,7 +3,7 @@ abbrev,1.0.9,ISC accepts,1.3.3,MIT ace-rails-ap,4.1.2,MIT acorn,4.0.11,MIT -acorn-dynamic-import,2.0.2,MIT +acorn-dynamic-import,2.0.1,MIT acorn-jsx,3.0.1,MIT actionmailer,4.2.8,MIT actionpack,4.2.8,MIT @@ -16,7 +16,7 @@ acts-as-taggable-on,4.0.0,MIT addressable,2.3.8,Apache 2.0 after,0.8.2,MIT after_commit_queue,1.3.0,MIT -ajv,4.11.5,MIT +ajv,4.11.2,MIT ajv-keywords,1.5.1,MIT akismet,2.0.0,MIT align-text,0.1.4,MIT @@ -29,7 +29,7 @@ ansi-regex,2.1.1,MIT ansi-styles,2.2.1,MIT anymatch,1.3.0,ISC append-transform,0.4.0,MIT -aproba,1.1.1,ISC +aproba,1.1.0,ISC are-we-there-yet,1.1.2,ISC arel,6.0.4,MIT argparse,1.0.9,MIT @@ -43,7 +43,7 @@ array-uniq,1.0.3,MIT array-unique,0.2.1,MIT arraybuffer.slice,0.0.6,MIT arrify,1.0.1,MIT -asana,0.4.0,MIT +asana,0.6.0,MIT asciidoctor,1.5.3,MIT asciidoctor-plantuml,0.0.7,MIT asn1,0.2.3,MIT @@ -62,8 +62,8 @@ aws-sign2,0.6.0,Apache 2.0 aws4,1.6.0,MIT axiom-types,0.1.1,MIT babel-code-frame,6.22.0,MIT -babel-core,6.24.0,MIT -babel-generator,6.24.0,MIT +babel-core,6.23.1,MIT +babel-generator,6.23.0,MIT babel-helper-bindify-decorators,6.22.0,MIT babel-helper-builder-binary-assignment-operator-visitor,6.22.0,MIT babel-helper-call-delegate,6.22.0,MIT @@ -78,10 +78,10 @@ babel-helper-regex,6.22.0,MIT babel-helper-remap-async-to-generator,6.22.0,MIT babel-helper-replace-supers,6.23.0,MIT babel-helpers,6.23.0,MIT -babel-loader,6.4.1,MIT +babel-loader,6.2.10,MIT babel-messages,6.23.0,MIT babel-plugin-check-es2015-constants,6.22.0,MIT -babel-plugin-istanbul,4.1.1,New BSD +babel-plugin-istanbul,4.0.0,New BSD babel-plugin-syntax-async-functions,6.13.0,MIT babel-plugin-syntax-async-generators,6.13.0,MIT babel-plugin-syntax-class-properties,6.13.0,MIT @@ -127,13 +127,13 @@ babel-preset-es2017,6.22.0,MIT babel-preset-latest,6.24.0,MIT babel-preset-stage-2,6.22.0,MIT babel-preset-stage-3,6.22.0,MIT -babel-register,6.24.0,MIT -babel-runtime,6.23.0,MIT +babel-register,6.23.0,MIT +babel-runtime,6.22.0,MIT babel-template,6.23.0,MIT babel-traverse,6.23.1,MIT babel-types,6.23.0,MIT babosa,1.0.2,MIT -babylon,6.16.1,MIT +babylon,6.15.0,MIT backo2,1.0.2,MIT balanced-match,0.4.2,MIT base32,0.3.2,MIT @@ -149,20 +149,20 @@ binary-extensions,1.8.0,MIT bindata,2.3.5,ruby blob,0.0.4,unknown block-stream,0.0.9,ISC -bluebird,3.5.0,MIT +bluebird,3.4.7,MIT bn.js,4.11.6,MIT -body-parser,1.17.1,MIT +body-parser,1.16.0,MIT boom,2.10.1,New BSD bootstrap-sass,3.3.6,MIT brace-expansion,1.1.6,MIT braces,1.8.5,MIT -brorand,1.1.0,MIT +brorand,1.0.7,MIT browser,2.2.0,MIT browserify-aes,1.0.6,MIT browserify-cipher,1.0.0,MIT browserify-des,1.0.0,MIT browserify-rsa,4.0.1,MIT -browserify-sign,4.0.4,ISC +browserify-sign,4.0.0,ISC browserify-zlib,0.1.4,MIT browserslist,1.7.7,MIT buffer,4.9.1,MIT @@ -178,8 +178,8 @@ callsites,0.2.0,MIT camelcase,1.2.1,MIT caniuse-api,1.6.1,MIT caniuse-db,1.0.30000649,CC-BY-4.0 -carrierwave,0.11.2,MIT -caseless,0.12.0,Apache 2.0 +carrierwave,1.0.0,MIT +caseless,0.11.0,Apache 2.0 cause,0.1,MIT center-align,0.1.3,MIT chalk,1.1.3,MIT @@ -194,6 +194,7 @@ citrus,3.0.2,MIT clap,1.1.3,MIT cli-cursor,1.0.2,MIT cli-width,2.1.0,ISC +clipboard,1.6.1,MIT cliui,2.1.0,ISC clone,1.0.2,MIT co,4.6.0,MIT @@ -216,14 +217,14 @@ commondir,1.0.1,MIT component-bind,1.0.0,unknown component-emitter,1.2.1,MIT component-inherit,0.0.3,unknown -compressible,2.0.10,MIT +compressible,2.0.9,MIT compression,1.6.2,MIT compression-webpack-plugin,0.3.2,MIT concat-map,0.0.1,MIT concat-stream,1.6.0,MIT config-chain,1.1.11,MIT configstore,1.4.0,Simplified BSD -connect,3.6.0,MIT +connect,3.5.0,MIT connect-history-api-fallback,1.3.0,MIT connection_pool,2.2.1,MIT console-browserify,1.1.0,MIT @@ -233,7 +234,7 @@ constants-browserify,1.0.0,MIT contains-path,0.1.0,MIT content-disposition,0.5.2,MIT content-type,1.0.2,MIT -convert-source-map,1.5.0,MIT +convert-source-map,1.3.0,MIT cookie,0.3.1,MIT cookie-signature,1.0.6,MIT core-js,2.4.1,MIT @@ -254,13 +255,13 @@ cssesc,0.1.0,MIT cssnano,3.10.0,MIT csso,2.3.2,MIT custom-event,1.0.1,MIT -d,1.0.0,MIT -d3,3.5.17,New BSD +d,0.1.1,MIT +d3,3.5.11,New BSD d3_rails,3.5.11,MIT dashdash,1.14.1,MIT date-now,0.1.4,MIT de-indent,1.0.2,MIT -debug,2.6.3,MIT +debug,2.6.0,MIT decamelize,1.2.0,MIT deckar01-task_list,1.0.6,MIT deep-extend,0.4.1,MIT @@ -271,6 +272,7 @@ defaults,1.0.3,MIT defined,1.0.0,MIT del,2.2.2,MIT delayed-stream,1.0.0,MIT +delegate,3.1.2,MIT delegates,1.0.0,MIT depd,1.1.0,MIT des.js,1.0.0,MIT @@ -283,8 +285,8 @@ di,0.0.1,MIT diff-lcs,1.2.5,"MIT,Perl Artistic v2,GNU GPL v2" diffie-hellman,5.0.2,MIT diffy,3.1.0,MIT -doctrine,2.0.0,Apache 2.0 -document-register-element,1.4.1,MIT +doctrine,1.5.0,BSD +document-register-element,1.3.0,MIT dom-serialize,2.2.1,MIT dom-serializer,0.1.0,MIT domain-browser,1.1.7,MIT @@ -294,7 +296,7 @@ domhandler,2.3.0,unknown domutils,1.5.1,unknown doorkeeper,4.2.0,MIT doorkeeper-openid_connect,1.1.2,MIT -dropzone,4.3.0,MIT +dropzone,4.2.0,MIT dropzonejs-rails,0.7.2,MIT duplexer,0.1.1,MIT duplexify,3.5.0,MIT @@ -303,36 +305,36 @@ editorconfig,0.13.2,MIT ee-first,1.1.1,MIT ejs,2.5.6,Apache 2.0 electron-to-chromium,1.3.3,ISC -elliptic,6.4.0,MIT +elliptic,6.3.3,MIT email_reply_trimmer,0.1.6,MIT emoji-unicode-version,0.2.1,MIT emojis-list,2.1.0,MIT encodeurl,1.0.1,MIT encryptor,3.0.0,MIT end-of-stream,1.0.0,MIT -engine.io,1.8.3,MIT -engine.io-client,1.8.3,MIT +engine.io,1.8.2,MIT +engine.io-client,1.8.2,MIT engine.io-parser,1.3.2,MIT enhanced-resolve,3.1.0,MIT ent,2.2.0,MIT entities,1.1.1,BSD-like equalizer,0.0.11,MIT errno,0.1.4,MIT -error-ex,1.3.1,MIT +error-ex,1.3.0,MIT erubis,2.7.0,MIT -es5-ext,0.10.15,MIT -es6-iterator,2.0.1,MIT -es6-map,0.1.5,MIT +es5-ext,0.10.12,MIT +es6-iterator,2.0.0,MIT +es6-map,0.1.4,MIT es6-promise,3.0.2,MIT -es6-set,0.1.5,MIT -es6-symbol,3.1.1,MIT -es6-weak-map,2.0.2,MIT +es6-set,0.1.4,MIT +es6-symbol,3.1.0,MIT +es6-weak-map,2.0.1,MIT escape-html,1.0.3,MIT escape-string-regexp,1.0.5,MIT escape_utils,1.1.1,MIT escodegen,1.8.1,Simplified BSD escope,3.6.0,Simplified BSD -eslint,3.19.0,MIT +eslint,3.15.0,MIT eslint-config-airbnb-base,10.0.1,MIT eslint-import-resolver-node,0.2.3,MIT eslint-import-resolver-webpack,0.8.1,MIT @@ -341,37 +343,39 @@ eslint-plugin-filenames,1.1.0,MIT eslint-plugin-html,2.0.1,ISC eslint-plugin-import,2.2.0,MIT eslint-plugin-jasmine,2.2.0,MIT -espree,3.4.1,Simplified BSD -esprima,2.7.3,Simplified BSD -esquery,1.0.0,BSD +eslint-plugin-promise,3.5.0,ISC +espree,3.4.0,Simplified BSD +esprima,3.1.3,Simplified BSD esrecurse,4.1.0,Simplified BSD estraverse,4.1.1,Simplified BSD esutils,2.0.2,BSD -etag,1.8.0,MIT +etag,1.7.0,MIT eve-raphael,0.5.0,Apache 2.0 -event-emitter,0.3.5,MIT +event-emitter,0.3.4,MIT event-stream,3.3.4,MIT eventemitter3,1.2.0,MIT events,1.1.1,MIT eventsource,0.1.6,MIT evp_bytestokey,1.0.0,MIT -excon,0.52.0,MIT +excon,0.55.0,MIT execjs,2.6.0,MIT exit-hook,1.1.1,MIT expand-braces,0.1.2,MIT expand-brackets,0.1.5,MIT expand-range,1.8.2,MIT -express,4.15.2,MIT +exports-loader,0.6.4,MIT +express,4.14.1,MIT expression_parser,0.9.0,MIT extend,3.0.0,MIT extglob,0.3.2,MIT extlib,0.9.16,MIT extract-zip,1.5.0,Simplified BSD extsprintf,1.0.2,MIT -faraday,0.9.2,MIT -faraday_middleware,0.10.0,MIT +faraday,0.11.0,MIT +faraday_middleware,0.11.0.1,MIT faraday_middleware-multi_json,0.0.6,MIT fast-levenshtein,2.0.6,MIT +fast_gettext,1.4.0,"MIT,ruby" fastparse,1.1.1,MIT faye-websocket,0.7.3,MIT fd-slicer,1.0.1,MIT @@ -383,37 +387,37 @@ filename-regex,2.0.0,MIT fileset,2.0.3,MIT filesize,3.3.0,New BSD fill-range,2.2.3,MIT -finalhandler,1.0.1,MIT +finalhandler,0.5.1,MIT find-cache-dir,0.1.1,MIT find-root,0.1.2,MIT find-up,2.1.0,MIT flat-cache,1.2.2,MIT flatten,1.0.2,MIT flowdock,0.7.1,MIT -fog-aws,0.11.0,MIT -fog-core,1.42.0,MIT +fog-aws,0.13.0,MIT +fog-core,1.44.1,MIT fog-google,0.5.0,MIT fog-json,1.0.2,MIT fog-local,0.3.0,MIT fog-openstack,0.1.6,MIT fog-rackspace,0.1.1,MIT -fog-xml,0.1.2,MIT +fog-xml,0.1.3,MIT font-awesome-rails,4.7.0.1,"MIT,SIL Open Font License" -for-in,1.0.2,MIT -for-own,0.1.5,MIT +for-in,0.1.6,MIT +for-own,0.1.4,MIT forever-agent,0.6.1,Apache 2.0 form-data,2.1.2,MIT formatador,0.2.5,MIT forwarded,0.1.0,MIT -fresh,0.5.0,MIT +fresh,0.3.0,MIT from,0.1.7,MIT fs-extra,1.0.0,MIT fs.realpath,1.0.0,ISC fsevents,,unknown -fstream,1.0.11,ISC +fstream,1.0.10,ISC fstream-ignore,1.0.5,ISC function-bind,1.1.0,MIT -gauge,2.7.3,ISC +gauge,2.7.2,ISC gemnasium-gitlab-service,0.2.6,MIT gemojione,3.0.1,MIT generate-function,2.0.0,MIT @@ -421,7 +425,9 @@ generate-object-property,1.2.0,MIT get-caller-file,1.0.2,ISC get_process_mem,0.2.0,MIT getpass,0.1.6,MIT -gitaly,0.5.0,MIT +gettext_i18n_rails,1.8.0,MIT +gettext_i18n_rails_js,1.2.0,MIT +gitaly,0.6.0,MIT github-linguist,4.7.6,MIT github-markup,1.4.0,MIT gitlab-flowdock-git-hook,1.0.1,MIT @@ -432,12 +438,13 @@ glob,7.1.1,ISC glob-base,0.3.0,MIT glob-parent,2.0.0,ISC globalid,0.3.7,MIT -globals,9.17.0,MIT +globals,9.14.0,MIT globby,5.0.0,MIT gollum-grit_adapter,1.0.1,MIT gollum-lib,4.2.1,MIT gollum-rugged_adapter,0.4.4,MIT gon,6.1.0,MIT +good-listener,1.2.2,MIT google-api-client,0.8.7,Apache 2.0 google-protobuf,3.2.0.2,New BSD googleauth,0.5.1,Apache 2.0 @@ -446,13 +453,12 @@ graceful-fs,4.1.11,ISC graceful-readlink,1.0.1,MIT grape,0.19.1,MIT grape-entity,0.6.0,MIT -grpc,1.1.2,New BSD +grpc,1.2.5,New BSD gzip-size,3.0.0,MIT hamlit,2.6.1,MIT handle-thing,1.2.5,MIT handlebars,4.0.6,MIT -har-schema,1.0.5,ISC -har-validator,4.2.1,ISC +har-validator,2.0.6,ISC has,1.0.1,MIT has-ansi,2.0.0,MIT has-binary,0.1.7,MIT @@ -463,14 +469,14 @@ hash-sum,1.0.2,MIT hash.js,1.0.3,MIT hasha,2.2.0,MIT hashie,3.5.5,MIT +hashie-forbidden_attributes,0.1.1,MIT hawk,3.1.3,New BSD he,1.1.1,MIT health_check,2.6.0,MIT hipchat,1.5.2,MIT -hmac-drbg,1.0.0,MIT hoek,2.16.3,New BSD home-or-tmp,2.0.0,MIT -hosted-git-info,2.4.1,ISC +hosted-git-info,2.2.0,ISC hpack.js,2.1.6,MIT html-comment-regex,1.1.1,MIT html-entities,1.2.0,MIT @@ -481,7 +487,7 @@ htmlparser2,3.9.2,MIT http,0.9.8,MIT http-cookie,1.0.3,MIT http-deceiver,1.2.7,MIT -http-errors,1.6.1,MIT +http-errors,1.5.1,MIT http-form_data,1.0.1,MIT http-proxy,1.16.2,MIT http-proxy-middleware,0.17.4,MIT @@ -495,7 +501,7 @@ ice_nine,0.11.2,MIT iconv-lite,0.4.15,MIT icss-replace-symbols,1.0.2,ISC ieee754,1.1.8,New BSD -ignore,3.2.6,MIT +ignore,3.2.2,MIT ignore-by-default,1.0.1,ISC immediate,3.0.6,MIT imurmurhash,0.1.4,MIT @@ -507,16 +513,16 @@ influxdb,0.2.3,MIT inherits,2.0.3,ISC ini,1.3.4,ISC inquirer,0.12.0,MIT -interpret,1.0.2,MIT +interpret,1.0.1,MIT invariant,2.2.2,New BSD invert-kv,1.0.0,MIT -ipaddr.js,1.3.0,MIT +ipaddr.js,1.2.0,MIT ipaddress,0.8.3,MIT is-absolute,0.2.6,MIT is-absolute-url,2.1.0,MIT is-arrayish,0.2.1,MIT is-binary-path,1.0.1,MIT -is-buffer,1.1.5,MIT +is-buffer,1.1.4,MIT is-builtin-module,1.0.0,MIT is-dotfile,1.0.2,MIT is-equal-shallow,0.1.3,MIT @@ -525,7 +531,7 @@ is-extglob,1.0.0,MIT is-finite,1.0.2,MIT is-fullwidth-code-point,1.0.0,MIT is-glob,2.0.1,MIT -is-my-json-valid,2.16.0,MIT +is-my-json-valid,2.15.0,MIT is-npm,1.0.0,MIT is-number,2.1.0,MIT is-path-cwd,1.0.0,MIT @@ -546,31 +552,32 @@ is-utf8,0.2.1,MIT is-windows,0.2.0,MIT isarray,1.0.0,MIT isbinaryfile,3.0.2,MIT -isexe,2.0.0,ISC +isexe,1.1.2,ISC isobject,2.1.0,MIT isstream,0.1.2,MIT istanbul,0.4.5,New BSD -istanbul-api,1.1.7,New BSD -istanbul-lib-coverage,1.0.2,New BSD -istanbul-lib-hook,1.0.5,New BSD -istanbul-lib-instrument,1.7.0,New BSD -istanbul-lib-report,1.0.0,New BSD -istanbul-lib-source-maps,1.1.1,New BSD -istanbul-reports,1.0.2,New BSD +istanbul-api,1.1.1,New BSD +istanbul-lib-coverage,1.0.1,New BSD +istanbul-lib-hook,1.0.0,New BSD +istanbul-lib-instrument,1.4.2,New BSD +istanbul-lib-report,1.0.0-alpha.3,New BSD +istanbul-lib-source-maps,1.1.0,New BSD +istanbul-reports,1.0.1,New BSD jasmine-core,2.5.2,MIT jasmine-jquery,2.1.1,MIT +jed,1.1.1,MIT jira-ruby,1.1.2,MIT jodid25519,1.0.2,MIT -jquery,2.2.4,MIT +jquery,2.2.1,MIT jquery-atwho-rails,1.3.2,MIT jquery-rails,4.1.1,MIT -jquery-ujs,1.2.2,MIT +jquery-ujs,1.2.1,MIT js-base64,2.1.9,BSD js-beautify,1.6.12,MIT -js-cookie,2.1.4,MIT +js-cookie,2.1.3,MIT js-tokens,3.0.1,MIT js-yaml,3.7.0,MIT -jsbn,0.1.1,MIT +jsbn,0.1.0,BSD jsesc,1.3.0,MIT json,1.8.6,ruby json-jwt,1.7.1,MIT @@ -583,18 +590,18 @@ json5,0.5.1,MIT jsonfile,2.4.0,MIT jsonify,0.0.0,Public Domain jsonpointer,4.0.1,MIT -jsprim,1.4.0,MIT +jsprim,1.3.1,MIT jszip,3.1.3,(MIT OR GPL-3.0) jszip-utils,0.0.2,MIT or GPLv3 jwt,1.5.6,MIT kaminari,0.17.0,MIT -karma,1.6.0,MIT -karma-coverage-istanbul-reporter,0.2.3,MIT +karma,1.4.1,MIT +karma-coverage-istanbul-reporter,0.2.0,MIT karma-jasmine,1.1.0,MIT -karma-mocha-reporter,2.2.3,MIT -karma-phantomjs-launcher,1.0.4,MIT +karma-mocha-reporter,2.2.2,MIT +karma-phantomjs-launcher,1.0.2,MIT karma-sourcemap-loader,0.3.7,MIT -karma-webpack,2.0.3,MIT +karma-webpack,2.0.2,MIT kew,0.7.0,Apache 2.0 kgio,2.10.0,LGPL-2.1+ kind-of,3.1.0,MIT @@ -610,7 +617,8 @@ lie,3.1.1,MIT little-plugger,1.1.4,MIT load-json-file,1.1.0,MIT loader-runner,2.3.0,MIT -loader-utils,0.2.17,MIT +loader-utils,0.2.16,MIT +locale,2.1.2,"ruby,LGPLv3+" locate-path,2.0.0,MIT lodash,4.17.4,MIT lodash._baseassign,3.2.0,MIT @@ -638,16 +646,17 @@ lodash.snakecase,4.0.1,MIT lodash.uniq,4.5.0,MIT lodash.words,4.2.0,MIT log4js,0.6.38,Apache 2.0 -logging,2.1.0,MIT +logging,2.2.2,MIT longest,1.0.1,MIT loofah,2.0.3,MIT loose-envify,1.3.1,MIT lowercase-keys,1.0.0,MIT lru-cache,3.2.0,ISC macaddress,0.2.8,MIT -mail,2.6.4,MIT +mail,2.6.5,MIT mail_room,0.9.1,MIT map-stream,0.1.0,unknown +marked,0.3.6,MIT math-expression-evaluator,1.2.16,MIT media-typer,0.3.0,MIT memoist,0.15.0,MIT @@ -658,17 +667,16 @@ methods,1.1.2,MIT micromatch,2.3.11,MIT miller-rabin,4.0.0,MIT mime,1.3.4,MIT -mime-db,1.27.0,MIT +mime-db,1.26.0,MIT mime-types,2.99.3,"MIT,Artistic-2.0,GPL-2.0" mimemagic,0.3.0,MIT mini_portile2,2.1.0,MIT minimalistic-assert,1.0.0,ISC -minimalistic-crypto-utils,1.0.1,MIT minimatch,3.0.3,ISC minimist,0.0.8,MIT mkdirp,0.5.1,MIT -moment,2.18.1,MIT -mousetrap,1.6.1,Apache 2.0 +moment,2.17.1,MIT +mousetrap,1.4.6,Apache 2.0 mousetrap-rails,1.4.6,"MIT,Apache" ms,0.7.2,MIT multi_json,1.12.1,MIT @@ -684,14 +692,15 @@ nested-error-stacks,1.0.2,MIT net-ldap,0.12.1,MIT net-ssh,3.0.1,MIT netrc,0.11.0,MIT +node-ensure,0.0.0,MIT node-libs-browser,2.0.0,MIT -node-pre-gyp,0.6.34,New BSD +node-pre-gyp,0.6.33,New BSD node-zopfli,2.0.2,MIT nodemon,1.11.0,MIT nokogiri,1.6.8.1,MIT -nopt,4.0.1,ISC -normalize-package-data,2.3.6,Simplified BSD -normalize-path,2.1.1,MIT +nopt,3.0.6,ISC +normalize-package-data,2.3.5,Simplified BSD +normalize-path,2.0.1,MIT normalize-range,0.1.2,MIT normalize-url,1.9.1,MIT npmlog,4.0.2,ISC @@ -700,13 +709,13 @@ number-is-nan,1.0.1,MIT numerizer,0.1.1,MIT oauth,0.5.1,MIT oauth-sign,0.8.2,Apache 2.0 -oauth2,1.2.0,MIT +oauth2,1.3.1,MIT object-assign,4.1.1,MIT object-component,0.0.3,unknown object.omit,2.0.1,MIT obuf,1.1.1,MIT octokit,4.6.2,MIT -oj,2.17.4,MIT +oj,2.17.5,MIT omniauth,1.4.2,MIT omniauth-auth0,1.4.1,MIT omniauth-authentiq,0.3.0,MIT @@ -727,7 +736,7 @@ omniauth-twitter,1.2.1,MIT omniauth_crowd,2.2.3,MIT on-finished,2.3.0,MIT on-headers,1.0.1,MIT -once,1.4.0,ISC +once,1.3.3,ISC onetime,1.1.0,MIT opener,1.4.3,(WTFPL OR MIT) opn,4.0.2,MIT @@ -748,7 +757,7 @@ p-locate,2.0.0,MIT package-json,1.2.0,MIT pako,1.0.5,(MIT AND Zlib) paranoia,2.2.0,MIT -parse-asn1,5.1.0,ISC +parse-asn1,5.0.0,ISC parse-glob,3.0.4,MIT parse-json,2.2.0,MIT parsejson,0.0.3,MIT @@ -762,10 +771,10 @@ path-is-inside,1.0.2,(WTFPL OR MIT) path-parse,1.0.5,MIT path-to-regexp,0.1.7,MIT path-type,1.1.0,MIT -pause-stream,0.0.11,"Apache2,MIT" +pause-stream,0.0.11,"MIT,Apache2" pbkdf2,3.0.9,MIT +pdfjs-dist,1.8.252,Apache 2.0 pend,1.2.0,MIT -performance-now,0.2.0,MIT pg,0.18.4,"BSD,ruby,GPL" phantomjs-prebuilt,2.1.14,Apache 2.0 pify,2.3.0,MIT @@ -775,6 +784,7 @@ pinkie-promise,2.0.1,MIT pkg-dir,1.0.0,MIT pkg-up,1.0.0,MIT pluralize,1.2.1,MIT +po_to_json,1.0.1,MIT portfinder,1.0.13,MIT posix-spawn,0.3.11,"MIT,LGPL" postcss,5.2.16,MIT @@ -818,12 +828,13 @@ premailer,1.8.6,New BSD premailer-rails,1.9.2,MIT prepend-http,1.0.4,MIT preserve,0.2.0,MIT +prismjs,1.6.0,MIT private,0.1.7,MIT process,0.11.9,MIT process-nextick-args,1.0.7,MIT progress,1.1.8,MIT proto-list,1.2.4,ISC -proxy-addr,1.1.4,MIT +proxy-addr,1.1.3,MIT prr,0.0.0,MIT ps-tree,1.1.0,MIT pseudomap,1.0.2,ISC @@ -832,7 +843,7 @@ punycode,1.4.1,MIT pyu-ruby-sasl,0.0.3.3,MIT q,1.5.0,MIT qjobs,1.1.5,MIT -qs,6.4.0,New BSD +qs,6.2.0,New BSD query-string,4.3.2,MIT querystring,0.2.0,MIT querystring-es3,0.2.1,MIT @@ -857,15 +868,16 @@ randomatic,1.1.6,MIT randombytes,2.0.3,MIT range-parser,1.2.0,MIT raphael,2.2.7,MIT +raven-js,3.15.0,Simplified BSD raw-body,2.2.0,MIT raw-loader,0.5.1,MIT -rc,1.2.1,(BSD-2-Clause OR MIT OR Apache-2.0) +rc,1.1.6,(BSD-2-Clause OR MIT OR Apache-2.0) rdoc,4.2.2,ruby react-dev-utils,0.5.2,New BSD read-all-stream,3.1.0,MIT read-pkg,1.1.0,MIT read-pkg-up,1.0.1,MIT -readable-stream,2.0.6,MIT +readable-stream,2.2.2,MIT readdirp,2.1.0,MIT readline2,1.0.1,MIT recaptcha,3.0.0,MIT @@ -873,7 +885,7 @@ rechoir,0.6.2,MIT recursive-open-struct,1.0.0,MIT recursive-readdir,2.1.1,MIT redcarpet,3.4.0,MIT -redis,3.2.2,MIT +redis,3.3.3,MIT redis-actionpack,5.0.1,MIT redis-activesupport,5.0.1,MIT redis-namespace,1.5.2,MIT @@ -883,18 +895,17 @@ redis-store,1.2.0,MIT reduce-css-calc,1.3.0,MIT reduce-function-call,1.0.2,MIT regenerate,1.3.2,MIT -regenerator-runtime,0.10.3,MIT +regenerator-runtime,0.10.1,MIT regenerator-transform,0.9.8,BSD regex-cache,0.4.3,MIT regexpu-core,2.0.0,MIT registry-url,3.1.0,MIT regjsgen,0.2.0,MIT regjsparser,0.1.5,BSD -remove-trailing-separator,1.0.1,ISC repeat-element,1.1.2,MIT repeat-string,1.6.1,MIT repeating,2.0.1,MIT -request,2.81.0,Apache 2.0 +request,2.79.0,Apache 2.0 request-progress,2.0.1,MIT request_store,1.3.1,MIT require-directory,2.1.1,MIT @@ -902,14 +913,14 @@ require-from-string,1.2.1,MIT require-main-filename,1.0.1,ISC require-uncached,1.0.3,MIT requires-port,1.0.0,MIT -resolve,1.3.2,MIT +resolve,1.2.0,MIT resolve-from,1.0.1,MIT responders,2.3.0,MIT rest-client,2.0.0,MIT restore-cursor,1.0.1,MIT retriable,1.4.1,MIT right-align,0.1.3,MIT -rimraf,2.6.1,ISC +rimraf,2.5.4,ISC rinku,2.0.0,ISC ripemd160,1.0.1,New BSD rotp,2.1.2,MIT @@ -919,6 +930,7 @@ rqrcode-rails3,0.1.7,MIT ruby-fogbugz,0.2.1,MIT ruby-prof,0.16.2,Simplified BSD ruby-saml,1.4.1,MIT +ruby_parser,3.8.4,MIT rubyntlm,0.5.2,MIT rubypants,0.2.0,BSD rufus-scheduler,3.1.10,MIT @@ -934,23 +946,25 @@ sawyer,0.8.1,MIT sax,1.2.2,ISC securecompare,1.0.0,MIT seed-fu,2.3.6,MIT +select,1.1.2,MIT select-hose,2.0.0,MIT select2,3.5.2-browserify,unknown select2-rails,3.5.9.3,MIT semver,5.3.0,ISC semver-diff,2.1.0,MIT -send,0.15.1,MIT +send,0.14.2,MIT sentry-raven,2.4.0,Apache 2.0 serve-index,1.8.0,MIT -serve-static,1.12.1,MIT +serve-static,1.11.2,MIT set-blocking,2.0.0,ISC set-immediate-shim,1.0.1,MIT setimmediate,1.0.5,MIT -setprototypeof,1.0.3,ISC +setprototypeof,1.0.2,ISC settingslogic,2.0.9,MIT +sexp_processor,4.8.0,MIT sha.js,2.4.8,MIT -shelljs,0.7.7,New BSD -sidekiq,4.2.7,LGPL +shelljs,0.7.6,New BSD +sidekiq,5.0.0,LGPL sidekiq-cron,0.4.4,MIT sidekiq-limit_fetch,3.4.0,MIT sigmund,1.0.1,ISC @@ -961,16 +975,16 @@ slash,1.0.0,MIT slice-ansi,0.0.4,MIT slide,1.1.6,ISC sntp,1.0.9,BSD -socket.io,1.7.3,MIT +socket.io,1.7.2,MIT socket.io-adapter,0.5.0,MIT -socket.io-client,1.7.3,MIT +socket.io-client,1.7.2,MIT socket.io-parser,2.3.1,MIT sockjs,0.3.18,MIT sockjs-client,1.0.1,MIT sort-keys,1.1.2,MIT source-list-map,0.1.8,MIT source-map,0.5.6,New BSD -source-map-support,0.4.14,MIT +source-map-support,0.4.11,MIT spdx-correct,1.0.2,Apache 2.0 spdx-expression-parse,1.0.4,(MIT AND CC-BY-3.0) spdx-license-ids,1.2.2,Unlicense @@ -980,7 +994,8 @@ split,0.3.3,MIT sprintf-js,1.0.3,New BSD sprockets,3.7.1,MIT sprockets-rails,3.2.0,MIT -sshpk,1.11.0,MIT +sql.js,0.4.0,MIT +sshpk,1.10.2,MIT state_machines,0.4.0,MIT state_machines-activemodel,0.4.0,MIT state_machines-activerecord,0.4.0,MIT @@ -988,7 +1003,7 @@ stats-webpack-plugin,0.4.3,MIT statuses,1.3.1,MIT stream-browserify,2.0.1,MIT stream-combiner,0.0.4,MIT -stream-http,2.7.0,MIT +stream-http,2.6.3,MIT stream-shift,1.0.0,MIT strict-uri-encode,1.1.0,MIT string-length,1.0.1,MIT @@ -998,16 +1013,17 @@ stringex,2.5.2,MIT stringstream,0.0.5,MIT strip-ansi,3.0.1,MIT strip-bom,2.0.0,MIT -strip-json-comments,2.0.1,MIT -supports-color,3.2.3,MIT +strip-json-comments,1.0.4,MIT +supports-color,0.2.0,MIT svgo,0.7.2,MIT sys-filesystem,1.1.6,Artistic 2.0 table,3.8.3,New BSD tapable,0.2.6,MIT tar,2.2.1,ISC -tar-pack,3.4.0,Simplified BSD +tar-pack,3.3.0,Simplified BSD temple,0.7.7,MIT -test-exclude,4.0.3,ISC +test-exclude,4.0.0,ISC +text,1.3.1,MIT text-table,0.2.0,MIT thor,0.19.4,MIT thread_safe,0.3.6,Apache 2.0 @@ -1021,7 +1037,8 @@ timeago.js,2.0.5,MIT timed-out,2.0.0,MIT timers-browserify,2.0.2,MIT timfel-krb5-auth,0.8.3,LGPL -tmp,0.0.31,MIT +tiny-emitter,1.1.0,MIT +tmp,0.0.28,MIT to-array,0.1.4,MIT to-arraybuffer,1.0.1,MIT to-fast-properties,1.0.2,MIT @@ -1034,10 +1051,10 @@ trim-right,1.0.1,MIT truncato,0.7.8,MIT tryit,1.0.3,MIT tty-browserify,0.0.0,MIT -tunnel-agent,0.6.0,Apache 2.0 +tunnel-agent,0.4.3,Apache 2.0 tweetnacl,0.14.5,Unlicense type-check,0.3.2,MIT -type-is,1.6.15,MIT +type-is,1.6.14,MIT typedarray,0.0.6,MIT tzinfo,1.2.2,MIT u2f,0.2.1,MIT @@ -1060,17 +1077,18 @@ uniqs,2.0.0,MIT unpipe,1.0.0,MIT update-notifier,0.5.0,Simplified BSD url,0.11.0,MIT +url-loader,0.5.8,MIT url-parse,1.0.5,MIT url_safe_base64,0.2.2,MIT user-home,2.0.0,MIT -useragent,2.1.13,MIT +useragent,2.1.12,MIT util,0.10.3,MIT util-deprecate,1.0.2,MIT utils-merge,1.0.0,MIT uuid,3.0.1,MIT validate-npm-package-license,3.0.1,Apache 2.0 validates_hostname,1.0.6,MIT -vary,1.1.1,MIT +vary,1.1.0,MIT vendors,1.0.1,MIT verror,1.3.6,MIT version_sorter,2.1.0,MIT @@ -1085,30 +1103,31 @@ vue-loader,11.3.4,MIT vue-resource,0.9.3,MIT vue-style-loader,2.0.5,MIT vue-template-compiler,2.2.6,MIT -vue-template-es2015-compiler,1.5.2,MIT +vue-template-es2015-compiler,1.5.1,MIT warden,1.2.6,MIT watchpack,1.3.1,MIT wbuf,1.7.2,MIT webpack,2.3.3,MIT -webpack-bundle-analyzer,2.3.1,MIT -webpack-dev-middleware,1.10.1,MIT +webpack-bundle-analyzer,2.3.0,MIT +webpack-dev-middleware,1.10.0,MIT webpack-dev-server,2.4.2,MIT webpack-rails,0.9.10,MIT -webpack-sources,0.1.5,MIT +webpack-sources,0.1.4,MIT websocket-driver,0.6.5,MIT websocket-extensions,0.1.1,MIT whet.extend,0.9.9,MIT -which,1.2.14,ISC +which,1.2.12,ISC which-module,1.0.0,ISC wide-align,1.1.0,ISC wikicloth,0.8.1,MIT window-size,0.1.0,MIT -wordwrap,1.0.0,MIT +wordwrap,0.0.2,MIT/X11 +worker-loader,0.8.0,MIT wrap-ansi,2.1.0,MIT wrappy,1.0.2,ISC write,0.2.1,MIT write-file-atomic,1.3.1,ISC -ws,1.1.2,MIT +ws,1.1.1,MIT wtf-8,1.0.0,MIT xdg-basedir,2.0.0,MIT xmlhttprequest-ssl,1.5.3,MIT |